Skip to content
Merged
39 changes: 39 additions & 0 deletions docs/book/src/clustercloudstack/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,45 @@ After setting the environment variables, execute the following command to genera
clusterctl generate cluster capc-cluster --flavor with-kube-vip > capc-cluster-spec.yaml
```

##### Option for Multiple Networks

Multiple networks can be specified at each node configuration in CloudStackMachineTemplate.
This is configured under `spec.template.spec.networks`, where you can list one or more networks by name or id,
and optionally assign static IP addresses.

When defining multiple networks for a VM in CAPC, the first network listed under `spec.template.spec.networks` is treated as
the primary network. This primary network must match the network defined in the failure domain’s zone (`failureDomains[].zone.network`),
either by name or by ID. It is used as the default NIC and is critical for VM boot and cluster communication.

Any networks listed after the primary are considered extra networks. These extra networks are attached as secondary NICs
on the VM and can be used for purposes such as service segmentation or additional routing. Each network entry, primary or
extra can optionally include a static IP address. If an IP is not specified, CloudStack will dynamically allocate one.

For example:

```yaml
apiVersion: infrastructure.cluster.x-k8s.io/v1beta3
kind: CloudStackMachineTemplate
metadata:
name: capc-cluster-control-plane
namespace: default
spec:
template:
spec:
offering:
name: Large Instance
networks:
- name: cloudstack-network # (optional) default primary network; must match with network at failureDomains.zone.network.name
ip: 10.1.1.21 # (optional) static IP in the primary network

# Additional (extra) networks can be specified below. Use either 'name' or 'id', and optionally an 'ip'.
- name: cloudstack-network-2 # (optional) extra network by name
ip: 10.1.1.31 # (optional) static IP in this network

- id: a1b2c3d4-5678-90ef-gh12-3456789ijklm # (optional) extra network by ID
ip: 10.1.1.41 # (optional) static IP in this network
```

#### CloudStack Endpoint Credentials Secret (*optional for provided templates when used with provided getting-started process*)

A reference to a Kubernetes Secret containing a YAML object containing credentials for accessing a particular CloudStack
Expand Down
164 changes: 140 additions & 24 deletions pkg/cloud/instance.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ package cloud
import (
"encoding/base64"
"fmt"
"net"
"strconv"
"strings"

Expand All @@ -44,7 +45,15 @@ func setMachineDataFromVMMetrics(vmResponse *cloudstack.VirtualMachinesMetric, c
csMachine.Spec.ProviderID = ptr.To(fmt.Sprintf("cloudstack:///%s", vmResponse.Id))
// InstanceID is later used as required parameter to destroy VM.
csMachine.Spec.InstanceID = ptr.To(vmResponse.Id)
csMachine.Status.Addresses = []corev1.NodeAddress{{Type: corev1.NodeInternalIP, Address: vmResponse.Ipaddress}}
csMachine.Status.Addresses = []corev1.NodeAddress{}
Copy link
Preview

Copilot AI Jul 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After looping over NICs, if no addresses were added but vmResponse.Ipaddress is non-empty, consider falling back to the original single-IP behavior to avoid dropping the only available address.

Copilot uses AI. Check for mistakes.

for _, nic := range vmResponse.Nic {
if nic.Ipaddress != "" {
csMachine.Status.Addresses = append(csMachine.Status.Addresses, corev1.NodeAddress{
Type: corev1.NodeInternalIP,
Comment on lines +51 to +52
Copy link
Preview

Copilot AI Jul 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] All NICs are labeled as NodeInternalIP. For public networks or external interfaces, consider using corev1.NodeExternalIP to accurately represent the address type.

Suggested change
csMachine.Status.Addresses = append(csMachine.Status.Addresses, corev1.NodeAddress{
Type: corev1.NodeInternalIP,
addressType := corev1.NodeInternalIP // Default to internal IP
if nic.IsPublic { // Assuming `nic.IsPublic` indicates external/public IP
addressType = corev1.NodeExternalIP
}
csMachine.Status.Addresses = append(csMachine.Status.Addresses, corev1.NodeAddress{
Type: addressType,

Copilot uses AI. Check for mistakes.

Address: nic.Ipaddress,
})
}
}
newInstanceState := vmResponse.State
if newInstanceState != csMachine.Status.InstanceState || (newInstanceState != "" && csMachine.Status.InstanceStateLastUpdated.IsZero()) {
csMachine.Status.InstanceState = newInstanceState
Expand Down Expand Up @@ -302,38 +311,151 @@ func (c *client) CheckLimits(
return nil
}

func (c *client) resolveNetworkIDByName(name string) (string, error) {
func (c *client) isFreeIPAvailable(networkID, ip string) (bool, error) {
params := c.cs.Address.NewListPublicIpAddressesParams()
params.SetNetworkid(networkID)
params.SetAllocatedonly(false)
params.SetForvirtualnetwork(false)
params.SetListall(true)

if ip != "" {
params.SetIpaddress(ip)
}

resp, err := c.cs.Address.ListPublicIpAddresses(params)
if err != nil {
return false, errors.Wrapf(err, "failed to list public IP addresses for network %q", networkID)
}

for _, addr := range resp.PublicIpAddresses {
if addr.State == "Free" {
return true, nil
}
}

return false, nil
}

func (c *client) buildIPEntry(resolvedNet *cloudstack.Network, ip string) (map[string]string, error) {
if ip != "" {
if err := validateIPInCIDR(ip, resolvedNet.Cidr); err != nil {
return nil, err
}
}

if resolvedNet.Type == NetworkTypeShared {
isAvailable, err := c.isFreeIPAvailable(resolvedNet.Id, ip)
if err != nil {
return nil, err
}
if !isAvailable {
if ip != "" {
return nil, errors.Errorf("IP %q is already allocated in network %q or out of range", ip, resolvedNet.Id)
}
return nil, errors.Errorf("no free IPs available in network %q", resolvedNet.Id)
}
}

entry := map[string]string{
"networkid": resolvedNet.Id,
}
if ip != "" {
entry["ip"] = ip
}
return entry, nil
}

func (c *client) resolveNetworkByName(name string) (*cloudstack.Network, error) {
net, count, err := c.cs.Network.GetNetworkByName(name, cloudstack.WithProject(c.user.Project.ID))
if err != nil {
return "", errors.Wrapf(err, "failed to look up network %q", name)
return nil, errors.Wrapf(err, "failed to look up network %q", name)
}
if count != 1 {
return "", errors.Errorf("expected 1 network named %q, but got %d", name, count)
return nil, errors.Errorf("expected 1 network named %q, but got %d", name, count)
}
return net.Id, nil
return net, nil
}

func (c *client) buildIPToNetworkList(csMachine *infrav1.CloudStackMachine) ([]map[string]string, error) {
ipToNetworkList := []map[string]string{}
var ipToNetworkList []map[string]string

for _, net := range csMachine.Spec.Networks {
networkID := net.ID
if networkID == "" {
var err error
networkID, err = c.resolveNetworkIDByName(net.Name)
if err != nil {
return nil, err
}
resolvedNet, err := c.resolveNetwork(net)
if err != nil {
return nil, err
}
entry := map[string]string{"networkid": networkID}
if net.IP != "" {
entry["ip"] = net.IP

entry, err := c.buildIPEntry(resolvedNet, net.IP)
if err != nil {
return nil, err
}

ipToNetworkList = append(ipToNetworkList, entry)
}

return ipToNetworkList, nil
}

func (c *client) resolveNetwork(net infrav1.NetworkSpec) (*cloudstack.Network, error) {
if net.ID == "" {
return c.resolveNetworkByName(net.Name)
}

resolvedNet, _, err := c.cs.Network.GetNetworkByID(net.ID, cloudstack.WithProject(c.user.Project.ID))
if err != nil {
return nil, errors.Wrapf(err, "failed to get network %q by ID", net.ID)
}
return resolvedNet, nil
}

func validateIPInCIDR(ipStr, cidrStr string) error {
ip := net.ParseIP(ipStr)
if ip == nil {
return errors.Errorf("invalid IP address %q", ipStr)
}

_, cidr, err := net.ParseCIDR(cidrStr)
if err != nil {
return errors.Wrapf(err, "invalid CIDR %q", cidrStr)
}

if !cidr.Contains(ip) {
return errors.Errorf("IP %q is not within network CIDR %q", ipStr, cidrStr)
}

return nil
}

func (c *client) configureNetworkParams(
p *cloudstack.DeployVirtualMachineParams,
csMachine *infrav1.CloudStackMachine,
fd *infrav1.CloudStackFailureDomain,
) error {
if len(csMachine.Spec.Networks) == 0 {
if fd.Spec.Zone.Network.ID != "" {
p.SetNetworkids([]string{fd.Spec.Zone.Network.ID})
}
} else {
firstNetwork := csMachine.Spec.Networks[0]
zoneNet := fd.Spec.Zone.Network

if zoneNet.ID != "" && firstNetwork.ID != "" && firstNetwork.ID != zoneNet.ID {
return errors.Errorf("first network ID %q does not match zone network ID %q", firstNetwork.ID, zoneNet.ID)
}
if zoneNet.Name != "" && firstNetwork.Name != "" && firstNetwork.Name != zoneNet.Name {
return errors.Errorf("first network name %q does not match zone network name %q", firstNetwork.Name, zoneNet.Name)
}

ipToNetworkList, err := c.buildIPToNetworkList(csMachine)
if err != nil {
return err
}
p.SetIptonetworklist(ipToNetworkList)
}

return nil
}

// DeployVM will create a VM instance,
// and sets the infrastructure machine spec and status accordingly.
func (c *client) DeployVM(
Expand All @@ -355,14 +477,8 @@ func (c *client) DeployVM(

p := c.cs.VirtualMachine.NewDeployVirtualMachineParams(offering.Id, templateID, fd.Spec.Zone.ID)

if len(csMachine.Spec.Networks) == 0 && fd.Spec.Zone.Network.ID != "" {
p.SetNetworkids([]string{fd.Spec.Zone.Network.ID})
} else {
ipToNetworkList, err := c.buildIPToNetworkList(csMachine)
if err != nil {
return err
}
p.SetIptonetworklist(ipToNetworkList)
if err := c.configureNetworkParams(p, csMachine, fd); err != nil {
return err
}

setIfNotEmpty(csMachine.Name, p.SetName)
Expand Down