Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
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
7 changes: 6 additions & 1 deletion cluster-autoscaler/cloudprovider/hetzner/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ The cluster autoscaler for Hetzner Cloud scales worker nodes.
"arm64": "",
"amd64": ""
},
"ipRange": "10.0.0.0/16", // Optional, make sure to match your private network configuration and to use the cidr notation - if not set the hetzner cloud default will be used
"nodeConfigs": {
"pool1": { // This equals the pool name. Required for each pool that you have
"cloudInit": "", // HCLOUD_CLOUD_INIT make sure it isn't base64 encoded twice ;]
Expand All @@ -47,7 +48,11 @@ Can be useful when you have many different node pools and run into issues of the

**NOTE**: In contrast to `HCLOUD_CLUSTER_CONFIG`, this file is not base64 encoded.

The global `imagesForArch` configuration can be overridden on a per-nodepool basis by adding an `imagesForArch` field to individual nodepool configurations.
The `ipRange` configuration can be used to place nodes within a specific IP range. This only applies to private networks. Make sure that the IP range is within the configured private network IP Range. If you do not set this value, the default setting from Hetzner Cloud will be used.
Copy link
Contributor

Choose a reason for hiding this comment

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

Needs update when config values change. Please add a small notice, that the subnetwork needs to exist beforehand.


Following global configuration options can be overridden on a per-nodepool basis by adding them to the individual nodepool configurations:
- `imagesForArch`
- `ipRange`

The image selection logic works as follows:

Expand Down
2 changes: 2 additions & 0 deletions cluster-autoscaler/cloudprovider/hetzner/hetzner_manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ type ClusterConfig struct {
NodeConfigs map[string]*NodeConfig
IsUsingNewFormat bool
LegacyConfig LegacyConfig
IPRange string
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
IPRange string
DefaultSubnetIPRange string

}

// ImageList holds the image id/names for the different architectures
Expand All @@ -78,6 +79,7 @@ type NodeConfig struct {
Taints []apiv1.Taint
Labels map[string]string
ImagesForArch *ImageList
IPRange string
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
IPRange string
SubnetIPRange string

}

// LegacyConfig holds the configuration in the legacy format
Expand Down
75 changes: 73 additions & 2 deletions cluster-autoscaler/cloudprovider/hetzner/hetzner_node_group.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import (
"fmt"
"maps"
"math/rand"
"net"
"strings"
"sync"

Expand Down Expand Up @@ -452,6 +453,39 @@ func instanceTypeArch(manager *hetznerManager, instanceType string) (string, err
}
}

func findIpRange(nodeId string, clusterConfig *ClusterConfig) (*net.IPNet, error) {
if !clusterConfig.IsUsingNewFormat {
return nil, nil
}
if nodeConfig, exists := clusterConfig.NodeConfigs[nodeId]; exists && nodeConfig.IPRange != "" {
_, ipNet, err := net.ParseCIDR(nodeConfig.IPRange)
if err != nil {
return nil, fmt.Errorf("failed to parse IP range %s for node %s: %v", nodeConfig.IPRange, nodeId, err)
}
return ipNet, nil
}
if clusterConfig.IPRange != "" {
_, ipNet, err := net.ParseCIDR(clusterConfig.IPRange)
if err != nil {
return nil, fmt.Errorf("failed to parse global IP range %s: %v", clusterConfig.IPRange, err)
}
return ipNet, nil
}
Copy link
Contributor

Choose a reason for hiding this comment

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

Please move the parsing part into the BuildHetzner(...) cloudprovider.Cloudprovider function and add new fields of type *net.IPNet to hetznerManager and hetznerNodeGroup. The config is static during runtime of the autoscaler, so parsing them once is enough and provides immediate feedback to the user.

type hetznerManager struct {
    // ....
    defaultSubnetIPRange *net.IPNet
}
type hetznerNodeGroup struct {
    // ....
    subnetIPRange *net.IPNet
}

Copy link
Contributor Author

@tloesch tloesch Oct 6, 2025

Choose a reason for hiding this comment

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

The attribute defaultSubnetIPRange within hetznerManager would not be used by anything if the pasring is done within BuildHetzner. This is because we can set the default value immediately in the nodeGroups.

I am currently testing the changes. I will publish the changes afterwards.

Please give me feedback if necessary :)

return nil, nil
}

func isIpRangeInNetwork(ipRange *net.IPNet, network *hcloud.Network) bool {
found := false
for _, nSubnet := range network.Subnets {
_, nSubnetRange, _ := net.ParseCIDR(nSubnet.IPRange.String())
if nSubnetRange.String() == ipRange.String() {
found = true
break
}
}
return found
}
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
func isIpRangeInNetwork(ipRange *net.IPNet, network *hcloud.Network) bool {
found := false
for _, nSubnet := range network.Subnets {
_, nSubnetRange, _ := net.ParseCIDR(nSubnet.IPRange.String())
if nSubnetRange.String() == ipRange.String() {
found = true
break
}
}
return found
}
func isIpRangeInNetwork(ipRange *net.IPNet, network *hcloud.Network) bool {
return slices.ContainsFunc(network.Subnets, func(subnet hcloud.NetworkSubnet) bool {
if ipRange == nil || subnet.IPRange == nil {
return false
}
return subnet.IPRange.IP.Equal(ipRange.IP) && len(subnet.IPRange.Mask) == len(ipRange.Mask)
})
}


func createServer(n *hetznerNodeGroup) error {
ctx, cancel := context.WithTimeout(n.manager.apiCallContext, n.manager.createTimeout)
defer cancel()
Expand All @@ -466,13 +500,22 @@ func createServer(n *hetznerNodeGroup) error {
return err
}

ipRange, err := findIpRange(n.Id(), n.manager.clusterConfig)
if err != nil {
return err
}
if ipRange != nil && n.manager.network != nil && !isIpRangeInNetwork(ipRange, n.manager.network) {
return fmt.Errorf("the specified IP range %s for node %s is not part of the network %s", ipRange.String(), n.Id(), n.manager.network.Name)
}

cloudInit := n.manager.clusterConfig.LegacyConfig.CloudInit

if n.manager.clusterConfig.IsUsingNewFormat {
cloudInit = n.manager.clusterConfig.NodeConfigs[n.id].CloudInit
}

StartAfterCreate := true
// dont start the server if we need to attach the server to a private subnet network
StartAfterCreate := ipRange == nil
opts := hcloud.ServerCreateOpts{
Name: newNodeName(n),
UserData: cloudInit,
Expand All @@ -492,7 +535,7 @@ func createServer(n *hetznerNodeGroup) error {
if n.manager.sshKey != nil {
opts.SSHKeys = []*hcloud.SSHKey{n.manager.sshKey}
}
if n.manager.network != nil {
if n.manager.network != nil && ipRange == nil {
opts.Networks = []*hcloud.Network{n.manager.network}
}
if n.manager.firewall != nil {
Expand All @@ -516,6 +559,34 @@ func createServer(n *hetznerNodeGroup) error {
return fmt.Errorf("failed to start server %s error: %v", server.Name, err)
}

if n.manager.network != nil && ipRange != nil {
// Attach server to private network with ipRange
attachAction, _, err := n.manager.client.Server.AttachToNetwork(ctx, server, hcloud.ServerAttachToNetworkOpts{
Network: n.manager.network,
IPRange: ipRange,
})
if err != nil {
_ = n.manager.deleteServer(server)
return fmt.Errorf("failed to attach server %s to network %s with IP range %s error: %v", server.Name, n.manager.network.Name, ipRange.String(), err)
}
if err = n.manager.client.Action.WaitFor(ctx, attachAction); err != nil {
_ = n.manager.deleteServer(server)
return fmt.Errorf("failed waiting for network action for server %s error: %v", server.Name, err)
}
}

if !StartAfterCreate {
powerOnAction, _, err := n.manager.client.Server.Poweron(ctx, server)
if err != nil {
_ = n.manager.deleteServer(server)
return fmt.Errorf("failed to power on server %s error: %v", server.Name, err)
}
if err = n.manager.client.Action.WaitFor(ctx, powerOnAction); err != nil {
_ = n.manager.deleteServer(server)
return fmt.Errorf("failed waiting for power on action for server %s error: %v", server.Name, err)
}
}

return nil
}

Expand Down
Loading