From 567149d339f69d982a38284da3acf9d1a876b9a0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 7 May 2026 22:36:37 +0000 Subject: [PATCH 1/2] Initial plan From 49a3a45e831984db3dce114e36d9fd56e6ffaade Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 7 May 2026 22:47:34 +0000 Subject: [PATCH 2/2] fix(hetzner): truncate schematic label to 63 chars and expand ~ in kubeconfig path Agent-Logs-Url: https://github.com/devantler-tech/ksail/sessions/91b58726-f6a6-49a0-80dc-e3ae777e4be9 Co-authored-by: devantler <26203420+devantler@users.noreply.github.com> --- pkg/svc/provider/hetzner/labels.go | 16 +++++ pkg/svc/provider/hetzner/labels_test.go | 67 +++++++++++++++++++ pkg/svc/provider/hetzner/snapshot.go | 4 +- pkg/svc/provider/hetzner/snapshot_test.go | 56 ++++++++++++++++ .../cluster/talos/provisioner_hetzner.go | 14 +++- 5 files changed, 153 insertions(+), 4 deletions(-) create mode 100644 pkg/svc/provider/hetzner/labels_test.go diff --git a/pkg/svc/provider/hetzner/labels.go b/pkg/svc/provider/hetzner/labels.go index 66afd1061..bb8ab5a17 100644 --- a/pkg/svc/provider/hetzner/labels.go +++ b/pkg/svc/provider/hetzner/labels.go @@ -34,12 +34,28 @@ const ( LabelTalosVersion = "ksail.io/talos-version" // LabelTalosSchematic identifies the Talos factory schematic ID of the snapshot image. + // The stored value is truncated to maxLabelValueLen characters via SchematicLabelValue. LabelTalosSchematic = "ksail.io/talos-schematic" // LabelTalosCluster identifies which cluster created the snapshot image. LabelTalosCluster = "ksail.io/cluster" + + // maxLabelValueLen is the maximum length of a Hetzner Cloud label value. + // See https://github.com/hetznercloud/hcloud-go/blob/main/hcloud/label.go. + maxLabelValueLen = 63 ) +// SchematicLabelValue returns the schematic ID truncated to fit within +// Hetzner Cloud's 63-character label value limit. Talos factory schematic +// IDs are SHA256 hex digests (64 chars), which exceed the limit by one. +func SchematicLabelValue(schematicID string) string { + if len(schematicID) > maxLabelValueLen { + return schematicID[:maxLabelValueLen] + } + + return schematicID +} + // Node type values for LabelNodeType. const ( // NodeTypeControlPlane indicates a control-plane node. diff --git a/pkg/svc/provider/hetzner/labels_test.go b/pkg/svc/provider/hetzner/labels_test.go new file mode 100644 index 000000000..f709c3cbb --- /dev/null +++ b/pkg/svc/provider/hetzner/labels_test.go @@ -0,0 +1,67 @@ +package hetzner_test + +import ( + "strings" + "testing" + + "github.com/devantler-tech/ksail/v7/pkg/svc/provider/hetzner" + "github.com/stretchr/testify/assert" +) + +func TestSchematicLabelValue(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + wantLen int + wantFull bool // whether the output should equal the input + }{ + { + name: "short value unchanged", + input: "abc123", + wantLen: 6, + wantFull: true, + }, + { + name: "63-char value unchanged", + input: strings.Repeat("a", 63), + wantLen: 63, + wantFull: true, + }, + { + name: "64-char SHA256 truncated to 63", + input: "e187c9b90f773cd8c84e5a3265c5554ee787b2fe67b508d9f955e90e7ae8c96c", + wantLen: 63, + wantFull: false, + }, + { + name: "longer value truncated to 63", + input: strings.Repeat("b", 100), + wantLen: 63, + wantFull: false, + }, + { + name: "empty value unchanged", + input: "", + wantLen: 0, + wantFull: true, + }, + } + + for _, testCase := range tests { + t.Run(testCase.name, func(t *testing.T) { + t.Parallel() + + got := hetzner.SchematicLabelValue(testCase.input) + + assert.Len(t, got, testCase.wantLen) + + if testCase.wantFull { + assert.Equal(t, testCase.input, got) + } else { + assert.Equal(t, testCase.input[:63], got) + } + }) + } +} diff --git a/pkg/svc/provider/hetzner/snapshot.go b/pkg/svc/provider/hetzner/snapshot.go index 7328a58b5..8710c486f 100644 --- a/pkg/svc/provider/hetzner/snapshot.go +++ b/pkg/svc/provider/hetzner/snapshot.go @@ -85,7 +85,7 @@ func (sm *SnapshotManager) EnsureTalosSnapshot( Architecture: hcloud.ArchitectureX86, Labels: map[string]string{ LabelTalosVersion: talosVersion, - LabelTalosSchematic: schematicID, + LabelTalosSchematic: SchematicLabelValue(schematicID), LabelTalosCluster: clusterName, }, }) @@ -146,7 +146,7 @@ func (sm *SnapshotManager) findExistingSnapshot( ListOpts: hcloud.ListOpts{ LabelSelector: fmt.Sprintf("%s=%s,%s=%s,%s=%s", LabelTalosVersion, talosVersion, - LabelTalosSchematic, schematicID, + LabelTalosSchematic, SchematicLabelValue(schematicID), LabelTalosCluster, clusterName, ), }, diff --git a/pkg/svc/provider/hetzner/snapshot_test.go b/pkg/svc/provider/hetzner/snapshot_test.go index 7a92d2f82..86b896f44 100644 --- a/pkg/svc/provider/hetzner/snapshot_test.go +++ b/pkg/svc/provider/hetzner/snapshot_test.go @@ -380,3 +380,59 @@ func TestSnapshotManager_EnsureTalosSnapshot_SkipsNonAvailableSnapshot(t *testin "Upload should have been called when existing snapshot is not available", ) } + +func TestSnapshotManager_EnsureTalosSnapshot_SHA256SchematicID(t *testing.T) { + t.Parallel() + + const ( + clusterName = "sha256-cluster" + version = "v1.12.4" + // A real 64-char SHA256 schematic ID that exceeds Hetzner's 63-char label limit. + schematic = "e187c9b90f773cd8c84e5a3265c5554ee787b2fe67b508d9f955e90e7ae8c96c" + imageID = int64(55) + ) + + truncatedSchematic := hetzner.SchematicLabelValue(schematic) + + mux := http.NewServeMux() + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + + mux.HandleFunc("/images", func(responseWriter http.ResponseWriter, r *http.Request) { + responseWriter.Header().Set("Content-Type", "application/json") + + // Verify the label selector uses the truncated value. + labelSelector := r.URL.Query().Get("label_selector") + assert.Contains(t, labelSelector, truncatedSchematic) + assert.NotContains(t, labelSelector, schematic) + + resp := hcloudImageListResponse{ + Images: []hcloudImageSchema{ + { + ID: imageID, + Labels: map[string]string{ + "ksail.io/talos-version": version, + "ksail.io/talos-schematic": truncatedSchematic, + "ksail.io/cluster": clusterName, + }, + Type: "snapshot", + Status: "available", + }, + }, + } + _, _ = responseWriter.Write(marshalJSON(t, resp)) + }) + + client := newTestHcloudClient(srv.URL) + uploader := &mockUploader{} + + var logBuf bytes.Buffer + + sm := hetzner.NewSnapshotManagerWithUploaderForTest(client, uploader, &logBuf) + + gotID, err := sm.EnsureTalosSnapshot(context.Background(), clusterName, version, schematic) + + require.NoError(t, err) + assert.Equal(t, imageID, gotID) + assert.False(t, uploader.called) +} diff --git a/pkg/svc/provisioner/cluster/talos/provisioner_hetzner.go b/pkg/svc/provisioner/cluster/talos/provisioner_hetzner.go index bbe009390..d910f6e4d 100644 --- a/pkg/svc/provisioner/cluster/talos/provisioner_hetzner.go +++ b/pkg/svc/provisioner/cluster/talos/provisioner_hetzner.go @@ -285,7 +285,12 @@ func (p *Provisioner) ensureHcloudSecret(ctx context.Context, clusterName string return fmt.Errorf("creating kubeclient for hcloud secret: %w", k8s.ErrKubeconfigPathEmpty) } - kubeconfigPath, err := fsutil.EvalCanonicalPath(p.options.KubeconfigPath) + expandedPath, err := fsutil.ExpandHomePath(p.options.KubeconfigPath) + if err != nil { + return fmt.Errorf("expanding kubeconfig path for hcloud secret: %w", err) + } + + kubeconfigPath, err := fsutil.EvalCanonicalPath(expandedPath) if err != nil { return fmt.Errorf("canonicalizing kubeconfig path for hcloud secret: %w", err) } @@ -407,7 +412,12 @@ func (p *Provisioner) ensureAutoscalerSecret( ) } - kubeconfigPath, err := fsutil.EvalCanonicalPath(p.options.KubeconfigPath) + expandedPath, err := fsutil.ExpandHomePath(p.options.KubeconfigPath) + if err != nil { + return fmt.Errorf("expanding kubeconfig path for autoscaler secret: %w", err) + } + + kubeconfigPath, err := fsutil.EvalCanonicalPath(expandedPath) if err != nil { return fmt.Errorf("canonicalizing kubeconfig path for autoscaler secret: %w", err) }