Skip to content
Draft
Show file tree
Hide file tree
Changes from all 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
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,24 @@ data:
priorities: |- {{ ClusterAutoscalerPriorities | nindent 4 }}
{{ end }}
---
{{ if (eq GetCloudProvider "hetzner") }}
apiVersion: v1
kind: Secret
metadata:
name: hcloud-autoscaler-config
namespace: kube-system
labels:
k8s-addon: cluster-autoscaler.addons.k8s.io
stringData:
# Base64-encoded JSON blob consumed by the Hetzner cluster-autoscaler cloud provider.
# Contains per-node-group labels so autoscaler-created servers carry the same
# kops tags as manually provisioned ones.
# NOTE: cloudInit within each nodeConfig is currently empty; nodes created by
# the autoscaler will not bootstrap correctly until cloud-init generation is
# implemented (see tracking issue).
clusterConfig: "{{ HetznerClusterAutoscalerConfig }}"
---
{{ end }}
# Source: cluster-autoscaler/templates/deployment.yaml
apiVersion: apps/v1
kind: Deployment
Expand Down Expand Up @@ -374,6 +392,23 @@ spec:
env:
- name: AWS_REGION
value: "{{ Region }}"
{{ else if (eq GetCloudProvider "hetzner") }}
env:
- name: HCLOUD_TOKEN
valueFrom:
secretKeyRef:
name: hcloud
key: token
- name: HCLOUD_NETWORK
valueFrom:
secretKeyRef:
name: hcloud
key: network
- name: HCLOUD_CLUSTER_CONFIG
valueFrom:
secretKeyRef:
name: hcloud-autoscaler-config
key: clusterConfig
{{ end }}
livenessProbe:
failureThreshold: 3
Expand Down
78 changes: 78 additions & 0 deletions upup/pkg/fi/cloudup/template_functions.go
Original file line number Diff line number Diff line change
Expand Up @@ -992,6 +992,12 @@ func (tf *TemplateFunctions) GetClusterAutoscalerNodeGroups() map[string]Cluster
cloud := tf.cloud.(gce.GCECloud)
format := "https://www.googleapis.com/compute/v1/projects/%s/zones/%s/instanceGroups/%s"
group.Other = fmt.Sprintf(format, cloud.Project(), ig.Spec.Zones[0], gce.NameForInstanceGroupManager(cluster.ObjectMeta.Name, ig.ObjectMeta.Name, ig.Spec.Zones[0]))
} else if cluster.GetCloudProvider() == kops.CloudProviderHetzner {
// Hetzner autoscaler expects --nodes=min:max:instanceType:region:name.
// The subnet name for Hetzner is the location (e.g. "hel1"), which is
// also used as the region argument by the Hetzner cloud provider.
region := ig.Spec.Subnets[0]
group.Other = fmt.Sprintf("%s:%s:%s", ig.Spec.MachineType, region, ig.Name)
} else {
group.Other = ig.Name + "." + cluster.Name
}
Expand All @@ -1001,6 +1007,78 @@ func (tf *TemplateFunctions) GetClusterAutoscalerNodeGroups() map[string]Cluster
return groups
}

// HetznerClusterAutoscalerConfig returns a base64-encoded JSON blob for the
// HCLOUD_CLUSTER_CONFIG environment variable expected by the Hetzner
// cluster-autoscaler cloud provider.
//
// The JSON encodes a ClusterConfig value (see hetzner_manager.go in the
// cluster-autoscaler source). Each node instance group gets a NodeConfig entry
// whose Labels map contains the same Hetzner server labels that kops applies
// to servers it creates directly, ensuring autoscaler-created nodes are
// recognised by kops' cloud instance group reconciliation.
//
// The NodeConfig.CloudInit field is intentionally left empty in this
// implementation. Generating the nodeup bootstrap script requires keypairs
// and node-up binary asset URLs that are not yet accessible at addon-template
// render time. Completing this requires either threading the keystore and
// NodeUpAssets through TemplateFunctions, or implementing a dedicated task
// that builds and stores the config after the bootstrap-script tasks execute.
// Until then, the cluster-autoscaler should be deployed with
// HCLOUD_CLOUD_INIT / HCLOUD_IMAGE as a fallback (legacy mode).
func (tf *TemplateFunctions) HetznerClusterAutoscalerConfig() (string, error) {
type imageList struct {
Amd64 string `json:"amd64,omitempty"`
Arm64 string `json:"arm64,omitempty"`
}
type nodeConfig struct {
CloudInit string `json:"cloudInit"`
Labels map[string]string `json:"labels,omitempty"`
}
type clusterConfig struct {
ImagesForArch imageList `json:"imagesForArch"`
NodeConfigs map[string]*nodeConfig `json:"nodeConfigs"`
}

cfg := clusterConfig{
NodeConfigs: make(map[string]*nodeConfig),
}

for _, ig := range tf.KopsModelContext.InstanceGroups {
if ig.Spec.Role != kops.InstanceGroupRoleNode {
continue
}
if ig.Spec.Autoscale != nil && !fi.ValueOf(ig.Spec.Autoscale) {
continue
}

labels, err := tf.CloudTagsForInstanceGroup(ig)
if err != nil {
return "", fmt.Errorf("error getting labels for instance group %q: %w", ig.Name, err)
}

// The node group name in the Hetzner autoscaler is the 5th field of the
// --nodes flag (min:max:instanceType:region:name), which equals ig.Name.
cfg.NodeConfigs[ig.Name] = &nodeConfig{
Labels: labels,
// CloudInit intentionally empty; see function comment.
}

// Use the image from the first eligible IG (same image per cluster is typical for Hetzner).
if cfg.ImagesForArch.Amd64 == "" && ig.Spec.Image != "" {
cfg.ImagesForArch.Amd64 = ig.Spec.Image
cfg.ImagesForArch.Arm64 = ig.Spec.Image
}
}

data, err := json.Marshal(cfg)
if err != nil {
return "", fmt.Errorf("error marshaling Hetzner cluster autoscaler config: %w", err)
}

// The autoscaler reads HCLOUD_CLUSTER_CONFIG as a base64-encoded JSON string.
return base64.StdEncoding.EncodeToString(data), nil
}

func (tf *TemplateFunctions) architectureOfAMI(amiID string) string {
image, _ := tf.cloud.(awsup.AWSCloud).ResolveImage(amiID)
switch image.Architecture {
Expand Down
Loading