Skip to content

Commit

Permalink
feat: allow template selection based on tags
Browse files Browse the repository at this point in the history
* `sourceNode + templateID` and `templateSelector` are mutually exclusive
* automatically detects both `sourceNode` + `templateID`
* errors out if anything but one (1) VM template with desired flags was found
  • Loading branch information
pborn-ionos committed Dec 3, 2024
1 parent 51a9d0a commit ed2d3ce
Show file tree
Hide file tree
Showing 16 changed files with 618 additions and 9 deletions.
25 changes: 24 additions & 1 deletion api/v1alpha1/proxmoxmachine_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -162,12 +162,16 @@ type VirtualMachineCloneSpec struct {
// will be cloned onto the same node as SourceNode.
//
// +kubebuilder:validation:MinLength=1
// +optional
SourceNode string `json:"sourceNode"`

// TemplateID the vm_template vmid used for cloning a new VM.
// +optional
TemplateID *int32 `json:"templateID,omitempty"`

// +optional
TemplateSelector *TemplateSelector `json:"templateSelector,omitempty"`

// Description for the new VM.
// +optional
Description *string `json:"description,omitempty"`
Expand Down Expand Up @@ -202,6 +206,14 @@ type VirtualMachineCloneSpec struct {
Target *string `json:"target,omitempty"`
}

// TemplateSelector defines tags for looking up images.
type TemplateSelector struct {
// Specifies all tags to look for, when looking up the VM template.
//
// +kubebuilder:validation:MinItems=1
MatchTags []string `json:"matchTags"`
}

// NetworkSpec defines the virtual machine's network configuration.
type NetworkSpec struct {
// Default is the default network device,
Expand Down Expand Up @@ -526,9 +538,20 @@ func (r *ProxmoxMachine) GetTemplateID() int32 {
return -1
}

// GetTemplateSelectorTags get the tags, the desired vm template should have.
func (r *ProxmoxMachine) GetTemplateSelectorTags() []string {
if r.Spec.TemplateSelector != nil && r.Spec.TemplateSelector.MatchTags != nil {
return r.Spec.TemplateSelector.MatchTags
}
return nil
}

// GetNode get the Proxmox node used to provision this machine.
func (r *ProxmoxMachine) GetNode() string {
return r.Spec.SourceNode
if r.Spec.SourceNode != "" {
return r.Spec.SourceNode
}
return ""
}

// FormatSize returns the format required for the Proxmox API.
Expand Down
25 changes: 25 additions & 0 deletions 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 @@ -548,13 +548,25 @@ spec:
a new VM.
format: int32
type: integer
templateSelector:
description: TemplateSelector defines tags for looking up
images.
properties:
matchTags:
description: Specifies all tags to look for, when looking
up the VM template.
items:
type: string
minItems: 1
type: array
required:
- matchTags
type: object
virtualMachineID:
description: VirtualMachineID is the Proxmox identifier
for the ProxmoxMachine VM.
format: int64
type: integer
required:
- sourceNode
type: object
type: object
x-kubernetes-validations:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -588,13 +588,25 @@ spec:
for cloning a new VM.
format: int32
type: integer
templateSelector:
description: TemplateSelector defines tags for looking
up images.
properties:
matchTags:
description: Specifies all tags to look for,
when looking up the VM template.
items:
type: string
minItems: 1
type: array
required:
- matchTags
type: object
virtualMachineID:
description: VirtualMachineID is the Proxmox identifier
for the ProxmoxMachine VM.
format: int64
type: integer
required:
- sourceNode
type: object
type: object
x-kubernetes-validations:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -513,13 +513,24 @@ spec:
VM.
format: int32
type: integer
templateSelector:
description: TemplateSelector defines tags for looking up images.
properties:
matchTags:
description: Specifies all tags to look for, when looking up the
VM template.
items:
type: string
minItems: 1
type: array
required:
- matchTags
type: object
virtualMachineID:
description: VirtualMachineID is the Proxmox identifier for the ProxmoxMachine
VM.
format: int64
type: integer
required:
- sourceNode
type: object
x-kubernetes-validations:
- message: Must set full=true when specifying format
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -546,13 +546,25 @@ spec:
a new VM.
format: int32
type: integer
templateSelector:
description: TemplateSelector defines tags for looking up
images.
properties:
matchTags:
description: Specifies all tags to look for, when looking
up the VM template.
items:
type: string
minItems: 1
type: array
required:
- matchTags
type: object
virtualMachineID:
description: VirtualMachineID is the Proxmox identifier for
the ProxmoxMachine VM.
format: int64
type: integer
required:
- sourceNode
type: object
required:
- spec
Expand Down
7 changes: 7 additions & 0 deletions docs/advanced-setups.md
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,13 @@ This behaviour can be configured in the `ProxmoxCluster` CR through the field `.

For example, setting it to `0` (zero), entirely disables scheduling based on memory. Alternatively, if you set it to any value greater than `0`, the scheduler will treat your host as it would have `${value}%` of memory. In real numbers that would mean, if you have a host with 64GB of memory and set the number to `300`, the scheduler would allow you to provision guests with a total of 192GB memory and therefore overprovision the host. (Use with caution! It's strongly suggested to have memory ballooning configured everywhere.). Or, if you were to set it to `95` for example, it would treat your host as it would only have 60,8GB of memory, and leave the remaining 3,2GB for the host.

## Template lookup based on Proxmox tags

Our provider is able to look up templates based on their attached tags, for `ProxmoxMachine` resources, that make use of an tag selector.

For example, you can set the `TEMPLATE_TAGS="tag1,tag2"` environment variable. Your custom image will then be used when using the [auto-image](https://github.com/ionos-cloud/cluster-api-provider-ionoscloud/blob/main/templates/cluster-template-auto-image.yaml) template.


## Proxmox RBAC with least privileges

For the Proxmox API user/token you create for CAPMOX, these are the minimum required permissions.
Expand Down
1 change: 1 addition & 0 deletions envfile.example
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ export PROXMOX_TOKEN=""
export PROXMOX_SECRET=""
export PROXMOX_SOURCENODE="pve"
export TEMPLATE_VMID=100
export TEMPLATE_TAGS="tag1,tag2"
export VM_SSH_KEYS="ssh-ed25519 ..., ssh-ed25519 ..."
export KUBERNETES_VERSION="1.25.1"
export CONTROL_PLANE_ENDPOINT_IP=10.10.10.4
Expand Down
11 changes: 11 additions & 0 deletions internal/service/vmservice/vm.go
Original file line number Diff line number Diff line change
Expand Up @@ -371,6 +371,17 @@ func createVM(ctx context.Context, scope *scope.MachineScope) (proxmox.VMCloneRe
}

templateID := scope.ProxmoxMachine.GetTemplateID()
if templateID == -1 {
var err error

templateSelectorTags := scope.ProxmoxMachine.GetTemplateSelectorTags()
options.Node, templateID, err = scope.InfraCluster.ProxmoxClient.FindVMTemplateByTags(ctx, templateSelectorTags)

if err != nil {
scope.SetFailureMessage(err)
return proxmox.VMCloneResponse{}, err
}
}
res, err := scope.InfraCluster.ProxmoxClient.CloneVM(ctx, int(templateID), options)
if err != nil {
return res, err
Expand Down
38 changes: 38 additions & 0 deletions internal/webhook/proxmoxmachine_webhook.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,12 @@ func (p *ProxmoxMachine) ValidateCreate(_ context.Context, obj runtime.Object) (
return warnings, err
}

err = validateTemplate(machine)
if err != nil {
warnings = append(warnings, fmt.Sprintf("cannot create proxmox machine %s", machine.GetName()))
return warnings, err
}

return warnings, nil
}

Expand All @@ -75,6 +81,12 @@ func (p *ProxmoxMachine) ValidateUpdate(_ context.Context, _, newObj runtime.Obj
return warnings, err
}

err = validateTemplate(newMachine)
if err != nil {
warnings = append(warnings, fmt.Sprintf("cannot create proxmox machine %s", newMachine.GetName()))
return warnings, err
}

return warnings, nil
}

Expand All @@ -83,6 +95,32 @@ func (p *ProxmoxMachine) ValidateDelete(_ context.Context, _ runtime.Object) (wa
return nil, nil
}

func validateTemplate(machine *infrav1.ProxmoxMachine) error {
gk, name := machine.GroupVersionKind().GroupKind(), machine.GetName()

if (machine.Spec.TemplateID != nil || machine.Spec.SourceNode != "") && (machine.Spec.TemplateSelector != nil) {
return apierrors.NewInvalid(
gk,
name,
field.ErrorList{
field.Invalid(
field.NewPath("spec"), machine.Spec, "spec.sourceNode AND spec.templateID can not be used in combination with spec.templateSelector"),
})
}

if (machine.Spec.TemplateID == nil || machine.Spec.SourceNode == "") && (machine.Spec.TemplateSelector == nil) {
return apierrors.NewInvalid(
gk,
name,
field.ErrorList{
field.Invalid(
field.NewPath("spec"), machine.Spec, "must define either spec.sourceNode AND spec.templateID, or spec.templateSelector"),
})
}

return nil
}

func validateNetworks(machine *infrav1.ProxmoxMachine) error {
if machine.Spec.Network == nil {
return nil
Expand Down
9 changes: 9 additions & 0 deletions internal/webhook/proxmoxmachine_webhook_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,14 @@ var _ = Describe("Controller Test", func() {
machine.Spec.Network.AdditionalDevices[0].InterfaceConfig.Routing.RoutingPolicy[0].Table = nil
g.Expect(k8sClient.Create(testEnv.GetContext(), &machine)).To(MatchError(ContainSubstring("routing policy [0] requires a table")))
})

It("should disallow machine with both sourceNode/templateID AND TemplateSelector", func() {
machine := validProxmoxMachine("test-machine")
machine.Spec.TemplateSelector = &infrav1.TemplateSelector{
MatchTags: []string{"foo", "bar"},
}
g.Expect(k8sClient.Create(testEnv.GetContext(), &machine)).To(MatchError(ContainSubstring("spec.sourceNode AND spec.templateID can not be used in combination with spec.templateSelector")))
})
})

Context("update proxmox cluster", func() {
Expand Down Expand Up @@ -111,6 +119,7 @@ func validProxmoxMachine(name string) infrav1.ProxmoxMachine {
Spec: infrav1.ProxmoxMachineSpec{
VirtualMachineCloneSpec: infrav1.VirtualMachineCloneSpec{
SourceNode: "pve",
TemplateID: ptr.To(int32(1337)),
},
NumSockets: 1,
NumCores: 1,
Expand Down
1 change: 1 addition & 0 deletions pkg/proxmox/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ type Client interface {
ConfigureVM(ctx context.Context, vm *proxmox.VirtualMachine, options ...VirtualMachineOption) (*proxmox.Task, error)

FindVMResource(ctx context.Context, vmID uint64) (*proxmox.ClusterResource, error)
FindVMTemplateByTags(ctx context.Context, templateTags []string) (string, int32, error)

GetVM(ctx context.Context, nodeName string, vmID int64) (*proxmox.VirtualMachine, error)

Expand Down
46 changes: 46 additions & 0 deletions pkg/proxmox/goproxmox/api_client.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import (
"context"
"fmt"
"net/url"
"slices"
"strings"

"github.com/go-logr/logr"
Expand Down Expand Up @@ -141,6 +142,51 @@ func (c *APIClient) FindVMResource(ctx context.Context, vmID uint64) (*proxmox.C
return nil, fmt.Errorf("unable to find VM with ID %d on any of the nodes", vmID)
}

// FindVMTemplateByTags tries to find a VMID by its tags across the whole cluster.
func (c *APIClient) FindVMTemplateByTags(ctx context.Context, templateTags []string) (string, int32, error) {
vmTemplates := make([]*proxmox.ClusterResource, 0)

sortedTags := make([]string, len(templateTags))
for i, tag := range templateTags {
// Proxmox VM tags are always lowercase
sortedTags[i] = strings.ToLower(tag)
}
slices.Sort(sortedTags)
uniqueTags := slices.Compact(sortedTags)

cluster, err := c.Cluster(ctx)
if err != nil {
return "", -1, fmt.Errorf("cannot get cluster status: %w", err)
}

vmResources, err := cluster.Resources(ctx, "vm")
if err != nil {
return "", -1, fmt.Errorf("could not list vm resources: %w", err)
}

for _, vm := range vmResources {
if vm.Template == 0 {
continue
}
if len(vm.Tags) == 0 {
continue
}

vmTags := strings.Split(vm.Tags, ";")
slices.Sort(vmTags)

if slices.Equal(vmTags, uniqueTags) {
vmTemplates = append(vmTemplates, vm)
}
}

if n := len(vmTemplates); n != 1 {
return "", -1, fmt.Errorf("found %d VM templates with tags %q", n, templateTags)
}

return vmTemplates[0].Node, int32(vmTemplates[0].VMID), nil
}

// DeleteVM deletes a VM based on the nodeName and vmID.
func (c *APIClient) DeleteVM(ctx context.Context, nodeName string, vmID int64) (*proxmox.Task, error) {
// A vmID can not be lower than 100.
Expand Down
Loading

0 comments on commit ed2d3ce

Please sign in to comment.