From 7d5cf6c28951ec426371a83224ded1ac93f2847d Mon Sep 17 00:00:00 2001 From: Kurt Garloff Date: Sat, 25 Apr 2026 16:03:00 +0200 Subject: [PATCH 1/2] Allow for dashes in clusterstack names. We do this because the mere existence of a openstack-hosted-control-plane-1-34-v0-sha-0ewkztd tag in the registry confused csctl and prevented us from releasing a new cluster stack. So, we tolerate dashes in clusterstack names now by being more robust in parsing, So, if we don't have '-v' at the right position in the string spliited by '-', we look for a '-v' further to the right. We then assume that the extra dashes belong to the name and process accordingly. The string parsing code is not very beautiful, but has been tested successfully. This fixes https://github.com/SovereignCloudStack/csctl/issues/220. Signed-off-by: Kurt Garloff --- .../pkg/clusterstack/clusterstack.go | 74 ++++++++++++------- 1 file changed, 48 insertions(+), 26 deletions(-) diff --git a/vendor/github.com/SovereignCloudStack/cluster-stack-operator/pkg/clusterstack/clusterstack.go b/vendor/github.com/SovereignCloudStack/cluster-stack-operator/pkg/clusterstack/clusterstack.go index 47f5b99e..2f97afa1 100644 --- a/vendor/github.com/SovereignCloudStack/cluster-stack-operator/pkg/clusterstack/clusterstack.go +++ b/vendor/github.com/SovereignCloudStack/cluster-stack-operator/pkg/clusterstack/clusterstack.go @@ -59,13 +59,24 @@ var ( // e.g. - "docker-ferrol-1-27-v1", "docker-ferrol-1-27-v1-alpha.1", etc. func NewFromClusterClassProperties(str string) (ClusterStack, error) { splitted := strings.Split(str, Separator) - if len(splitted) != 5 && len(splitted) != 6 { + splen := len(splitted) + // search for rightmost -vX + offset := 0 + for ((4+offset < splen) && (splitted[4+offset][0] != 'v')) { + offset += 1 + } + if ((splen < 5+offset) || (4+offset == splen) || (splitted[4+offset][0] != 'v') || (splen > 6+offset)) { return ClusterStack{}, ErrInvalidFormat } - + var name string + if offset != 0 { + name = strings.Join(splitted[1:2+offset], Separator) + } else { + name = splitted[1] + } clusterStack := ClusterStack{ Provider: splitted[0], - Name: splitted[1], + Name: name, } if clusterStack.Provider == "" { @@ -78,18 +89,22 @@ func NewFromClusterClassProperties(str string) (ClusterStack, error) { var err error - clusterStack.KubernetesVersion, err = kubernetesversion.New(splitted[2], splitted[3]) + clusterStack.KubernetesVersion, err = kubernetesversion.New(splitted[2+offset], splitted[3+offset]) if err != nil { - return ClusterStack{}, fmt.Errorf("failed to create Kubernetes version from %s-%s: %w", splitted[2], splitted[3], err) + return ClusterStack{}, fmt.Errorf("failed to create Kubernetes version from %s-%s: %w", + splitted[2+offset], splitted[3+offset], err) } var versionString string - if len(splitted) == 5 { + if splen-offset == 5 { // e.g. myprovider-myclusterstack-1-26-v1 - versionString = splitted[4] - } else if len(splitted) == 6 { + versionString = splitted[4+offset] + } else if splen-offset == 6 { // e.g. myprovider-myclusterstack-1-26-v1-alpha.0 - versionString = strings.Join(splitted[4:6], Separator) + versionString = strings.Join(splitted[4+offset:6+offset], Separator) + } else { + // this should be impossible + panic("The impossible error") } // version string like v1-alpha.0 @@ -107,37 +122,44 @@ func NewFromClusterClassProperties(str string) (ClusterStack, error) { // e.g. - "docker-ferrol-1-27-v1", "docker-ferrol-1-27-v1-alpha-1", etc. func NewFromClusterStackReleaseProperties(str string) (ClusterStack, error) { splitted := strings.Split(str, Separator) - if len(splitted) != 5 && len(splitted) != 7 { + splen := len(splitted) + // search for rightmost -vX + offset := 0 + for ((4+offset < splen) && (splitted[4+offset][0] != 'v')) { + offset += 1 + } + if ((splen < 5+offset) || (4+offset == splen) || (splitted[4+offset][0] != 'v') || (splen > 7+offset)) { return ClusterStack{}, ErrInvalidFormat } - + var name string + if offset != 0 { + name = strings.Join(splitted[1:2+offset], Separator) + } else { + name = splitted[1] + } clusterStack := ClusterStack{ Provider: splitted[0], - Name: splitted[1], - } - - if clusterStack.Provider == "" { - return ClusterStack{}, ErrInvalidProvider - } - - if clusterStack.Name == "" { - return ClusterStack{}, ErrInvalidName + Name: name, } var err error - clusterStack.KubernetesVersion, err = kubernetesversion.New(splitted[2], splitted[3]) + clusterStack.KubernetesVersion, err = kubernetesversion.New(splitted[2+offset], splitted[3+offset]) if err != nil { - return ClusterStack{}, fmt.Errorf("failed to create Kubernetes version from %s-%s: %w", splitted[2], splitted[3], err) + return ClusterStack{}, fmt.Errorf("failed to create Kubernetes version from %s-%s: %w", + splitted[2+offset], splitted[3+offset], err) } var versionString string - if len(splitted) == 5 { + if splen-offset == 5 { // e.g. myprovider-myclusterstack-1-26-v1 - versionString = splitted[4] - } else if len(splitted) == 7 { + versionString = splitted[4+offset] + } else if splen-offset == 6 { + // e.g. myprovider-myclusterstack-1-26-v1-alpha + versionString = strings.Join(splitted[4+offset:6+offset], Separator) + } else if splen-offset == 7 { // e.g. myprovider-myclusterstack-1-26-v1-alpha-0 - versionString = strings.Join(splitted[4:7], Separator) + versionString = strings.Join(splitted[4+offset:7+offset], Separator) } // version string like v1-alpha-0 From 48c0e897c3385cb49f1d3669906354d5f78997dc Mon Sep 17 00:00:00 2001 From: Kurt Garloff Date: Wed, 6 May 2026 14:57:00 +0000 Subject: [PATCH 2/2] Add a hash version also for cluster class. This is the main thing and it should be hashed, as changes there are consequential. Signed-off-by: Kurt Garloff --- pkg/clusterstack/metadata.go | 1 + pkg/clusterstack/mode.go | 28 ++++++++++++++++++++++++++-- pkg/cmd/create.go | 10 +++++++++- pkg/cshash/hash.go | 9 ++++++++- 4 files changed, 44 insertions(+), 4 deletions(-) diff --git a/pkg/clusterstack/metadata.go b/pkg/clusterstack/metadata.go index 0141f480..3a6814a0 100644 --- a/pkg/clusterstack/metadata.go +++ b/pkg/clusterstack/metadata.go @@ -28,6 +28,7 @@ import ( type Component struct { ClusterAddon string `yaml:"clusterAddon"` NodeImage string `yaml:"nodeImage,omitempty"` + ClusterClass string `yaml:"clusterClass"` } // Versions contains version information. diff --git a/pkg/clusterstack/mode.go b/pkg/clusterstack/mode.go index 9db57dd8..0fff3a67 100644 --- a/pkg/clusterstack/mode.go +++ b/pkg/clusterstack/mode.go @@ -35,7 +35,9 @@ func HandleStableMode(gitHubReleasePath string, currentReleaseHash, latestReleas return nil, fmt.Errorf("failed to bump cluster stack: %w", err) } - if currentReleaseHash.ClusterAddonDir != latestReleaseHash.ClusterAddonDir || currentReleaseHash.ClusterAddonValues != latestReleaseHash.ClusterAddonValues { + if currentReleaseHash.ClusterAddonDir != latestReleaseHash.ClusterAddonDir || + currentReleaseHash.ClusterAddonValues != latestReleaseHash.ClusterAddonValues || + currentReleaseHash.ClusterClassDir != latestReleaseHash.ClusterClassDir { metadata.Versions.Components.ClusterAddon, err = BumpVersion(metadata.Versions.Components.ClusterAddon) if err != nil { return nil, fmt.Errorf("failed to bump cluster addon: %w", err) @@ -58,6 +60,22 @@ func HandleStableMode(gitHubReleasePath string, currentReleaseHash, latestReleas fmt.Printf("NodeImage Version unchanged: %s\n", metadata.Versions.Components.NodeImage) } } + if currentReleaseHash.ClusterClassDir != latestReleaseHash.ClusterClassDir { + metadata.Versions.Components.ClusterClass, err = BumpVersion(metadata.Versions.Components.ClusterClass) + if err != nil { + metadata.Versions.Components.ClusterClass = "v1" + fmt.Printf("Initial ClusterClass Version: %s\n", metadata.Versions.Components.ClusterClass) + //return nil, fmt.Errorf("failed to bump cluster class: %w", err) + } else { + fmt.Printf("Bumped ClusterClass Version: %s\n", metadata.Versions.Components.ClusterClass) + } + } else { + if metadata.Versions.Components.ClusterClass == "" { + fmt.Println("No ClusterClass Version.") + } else { + fmt.Printf("ClusterClass Version unchanged: %s\n", metadata.Versions.Components.ClusterClass) + } + } return metadata, nil } @@ -75,19 +93,24 @@ func HandleHashMode(currentRelease cshash.ReleaseHash, kubernetesVersion string) Components: Component{ ClusterAddon: clusterStackHash, NodeImage: clusterStackHash, + ClusterClass: clusterStackHash, }, }, } } // HandleCustomMode handles custom mode with version for all components. -func HandleCustomMode(kubernetesVersion, clusterStackVersion, clusterAddonVersion, nodeImageVersion string) (*MetaData, error) { +func HandleCustomMode(kubernetesVersion, clusterStackVersion, clusterAddonVersion, + clusterClassVersion, nodeImageVersion string) (*MetaData, error) { if _, err := version.New(clusterStackVersion); err != nil { return nil, fmt.Errorf("failed to verify custom version for cluster stack: %q: %w", clusterStackVersion, err) } if _, err := version.New(clusterAddonVersion); err != nil { return nil, fmt.Errorf("failed to verify custom version for cluster addon: %q: %w", clusterAddonVersion, err) } + if _, err := version.New(clusterClassVersion); err != nil { + return nil, fmt.Errorf("failed to verify custom version for cluster class: %q: %w", clusterClassVersion, err) + } if _, err := version.New(nodeImageVersion); err != nil { return nil, fmt.Errorf("failed to verify custom version for node image: %q: %w", nodeImageVersion, err) } @@ -99,6 +122,7 @@ func HandleCustomMode(kubernetesVersion, clusterStackVersion, clusterAddonVersio ClusterStack: clusterStackVersion, Components: Component{ ClusterAddon: clusterAddonVersion, + ClusterClass: clusterClassVersion, NodeImage: nodeImageVersion, }, }, diff --git a/pkg/cmd/create.go b/pkg/cmd/create.go index 90b0459c..72b7dffa 100644 --- a/pkg/cmd/create.go +++ b/pkg/cmd/create.go @@ -61,6 +61,7 @@ var ( nodeImageRegistry string clusterStackVersion string clusterAddonVersion string + clusterClassVersion string nodeImageVersion string remote string publish bool @@ -95,6 +96,7 @@ func init() { createCmd.Flags().StringVarP(&nodeImageRegistry, "node-image-registry", "r", "", "It defines the node image registry. For example oci://ghcr.io/foo/bar/node-images/staging/") createCmd.Flags().StringVar(&clusterStackVersion, "cluster-stack-version", "", "It is used to specify the semver version for the cluster stack in the custom mode") createCmd.Flags().StringVar(&clusterAddonVersion, "cluster-addon-version", "", "It is used to specify the semver version for the cluster addon in the custom mode") + createCmd.Flags().StringVar(&clusterClassVersion, "cluster-class-version", "", "It is used to specify the semver version for the cluster class in the custom mode") createCmd.Flags().StringVar(&nodeImageVersion, "node-image-version", "", "It is used to specify the semver version for the node images in the custom mode") createCmd.Flags().StringVar(&remote, "remote", "github", "Which remote repository to use and thus which credentials are required. Currently supported are 'github' and 'oci'.") createCmd.Flags().BoolVar(&publish, "publish", false, "Publish release after creation is done. This is only implemented for OCI currently.") @@ -168,6 +170,7 @@ func GetCreateOptions(ctx context.Context, clusterStackPath string) (*CreateOpti createOption.Metadata.Versions.ClusterStack = "v1" createOption.Metadata.Versions.Components.ClusterAddon = "v1" createOption.Metadata.Versions.Components.NodeImage = "v1" + createOption.Metadata.Versions.Components.ClusterClass = "v1" } else { if err := downloadReleaseAssets(ctx, latestRepoRelease, "./.tmp/release/", ac); err != nil { return nil, fmt.Errorf("failed to download release asset: %w", err) @@ -196,8 +199,12 @@ func GetCreateOptions(ctx context.Context, clusterStackPath string) (*CreateOpti if nodeImageVersion == "" { return nil, errors.New("please specify a semver for custom version with --node-image-version flag") } + if clusterClassVersion == "" { + return nil, errors.New("please specify a semver for custom version with --cluster-class-version flag") + } - createOption.Metadata, err = clusterstack.HandleCustomMode(createOption.Config.Config.KubernetesVersion, clusterStackVersion, clusterAddonVersion, nodeImageVersion) + createOption.Metadata, err = clusterstack.HandleCustomMode(createOption.Config.Config.KubernetesVersion, + clusterStackVersion, clusterAddonVersion, clusterClassVersion, nodeImageVersion) if err != nil { return nil, fmt.Errorf("failed to handle custom mode: %w", err) } @@ -257,6 +264,7 @@ func createAction(cmd *cobra.Command, args []string) error { func (c *CreateOptions) validateHash() error { if c.CurrentReleaseHash.ClusterAddonDir == c.LatestReleaseHash.ClusterAddonDir && c.CurrentReleaseHash.ClusterAddonValues == c.LatestReleaseHash.ClusterAddonValues && + c.CurrentReleaseHash.ClusterClassDir == c.LatestReleaseHash.ClusterClassDir && c.CurrentReleaseHash.NodeImageDir == c.LatestReleaseHash.NodeImageDir { return errors.New("no change in the cluster stack") } diff --git a/pkg/cshash/hash.go b/pkg/cshash/hash.go index f8ad01c6..2c659ba2 100644 --- a/pkg/cshash/hash.go +++ b/pkg/cshash/hash.go @@ -35,6 +35,7 @@ const ( clusterAddonDirName = "cluster-addon" nodeImageDirName = "node-image" clusterAddonValuesFileName = "cluster-addon-values.yaml" + clusterClassDirName = "cluster-class" ) // ReleaseHash contains the information of release hash. @@ -42,6 +43,7 @@ type ReleaseHash struct { ClusterStack string `json:"clusterStack"` ClusterAddonDir string `json:"clusterAddonDir"` ClusterAddonValues string `json:"clusterAddonValues"` + ClusterClassDir string `json:"clusterClassDir"` NodeImageDir string `json:"nodeImageDir,omitempty"` } @@ -80,7 +82,9 @@ func GetHash(path string) (ReleaseHash, error) { for _, entry := range entries { entryPath := filepath.Join(path, entry.Name()) - if entry.IsDir() && (entry.Name() == clusterAddonDirName || entry.Name() == nodeImageDirName) { + if entry.IsDir() && (entry.Name() == clusterAddonDirName || + entry.Name() == nodeImageDirName || + entry.Name() == clusterClassDirName) { hash, err := dirhash.HashDir(entryPath, "", dirhash.DefaultHash) if err != nil { return ReleaseHash{}, fmt.Errorf("failed to hash dir: %w", err) @@ -92,6 +96,8 @@ func GetHash(path string) (ReleaseHash, error) { releaseHash.ClusterAddonDir = hash case nodeImageDirName: releaseHash.NodeImageDir = hash + case clusterClassDirName: + releaseHash.ClusterClassDir = hash default: // Should not happen return ReleaseHash{}, fmt.Errorf("unknown name type %s", entryPath) @@ -114,6 +120,7 @@ func GetHash(path string) (ReleaseHash, error) { func (r ReleaseHash) ValidateWithLatestReleaseHash(latestReleaseHash ReleaseHash) error { if r.ClusterAddonDir == latestReleaseHash.ClusterAddonDir && r.ClusterAddonValues == latestReleaseHash.ClusterAddonValues && + r.ClusterClassDir == latestReleaseHash.ClusterClassDir && r.NodeImageDir == latestReleaseHash.NodeImageDir { return errors.New("no change in the cluster stack") }