From d266446a84ae368deda78e2b0d545a7d5071d45d Mon Sep 17 00:00:00 2001 From: Artiom Diomin Date: Tue, 23 Jun 2026 16:39:06 +0300 Subject: [PATCH 1/2] List images based on provider Signed-off-by: Artiom Diomin --- pkg/apis/kubeone/config/config.go | 43 ++++--- pkg/apis/kubeone/v1beta2/helpers.go | 4 + pkg/apis/kubeone/v1beta3/helpers.go | 4 + pkg/cmd/config-images.go | 97 +++++++++++++++- pkg/templates/images/images.go | 169 ++++++++++++++++++++++++++++ pkg/templates/images/images_test.go | 141 +++++++++++++++++++++++ 6 files changed, 440 insertions(+), 18 deletions(-) create mode 100644 pkg/templates/images/images_test.go diff --git a/pkg/apis/kubeone/config/config.go b/pkg/apis/kubeone/config/config.go index a5a4383d0..d2539e6a9 100644 --- a/pkg/apis/kubeone/config/config.go +++ b/pkg/apis/kubeone/config/config.go @@ -128,39 +128,50 @@ func TFOutput(tfOutputPath string) ([]byte, error) { return tfOutput, nil } -// BytesToKubeOneCluster parses the bytes of the versioned KubeOneCluster manifests -func BytesToKubeOneCluster(cluster, tfOutput []byte, credentialsFilePath string, logger logrus.FieldLogger, baseDir string) (*kubeoneapi.KubeOneCluster, error) { - // Get the GVK from the given KubeOneCluster manifest +// KubeOneClusterAPIVersion parses the TypeMeta from the raw KubeOneCluster +// manifest, validates that kind and apiVersion are present and supported, and +// returns the apiVersion string. The caller is responsible for acting on +// deprecated API versions. +func KubeOneClusterAPIVersion(cluster []byte) (string, error) { typeMeta := runtime.TypeMeta{} if err := yaml.Unmarshal(cluster, &typeMeta); err != nil { - return nil, fail.Config(err, "unmarshal cluster typeMeta") + return "", fail.Config(err, "unmarshal cluster typeMeta") } if len(typeMeta.APIVersion) == 0 || len(typeMeta.Kind) == 0 { - return nil, fail.ConfigValidation(fmt.Errorf("apiVersion and kind must be present in the manifest")) + return "", fail.ConfigValidation(fmt.Errorf("apiVersion and kind must be present in the manifest")) } if typeMeta.Kind != KubeOneClusterKind { - return nil, fail.ConfigValidation(fmt.Errorf("provided object %q is not KubeOneCluster object", typeMeta.Kind)) + return "", fail.ConfigValidation(fmt.Errorf("provided object %q is not KubeOneCluster object", typeMeta.Kind)) } if _, ok := AllowedAPIs[typeMeta.APIVersion]; !ok { - return nil, fail.ConfigValidation(fmt.Errorf("provided apiVersion %q is not supported", typeMeta.APIVersion)) + return "", fail.ConfigValidation(fmt.Errorf("provided apiVersion %q is not supported", typeMeta.APIVersion)) } - if _, ok := DeprecatedAPIs[typeMeta.APIVersion]; ok { - logger.Warningf(`The provided APIVersion %q is deprecated. Please use "kubeone config migrate" command to migrate to the latest version.`, typeMeta.APIVersion) + + return typeMeta.APIVersion, nil +} + +// BytesToKubeOneCluster parses the bytes of the versioned KubeOneCluster manifests +func BytesToKubeOneCluster(cluster, tfOutput []byte, credentialsFilePath string, logger logrus.FieldLogger, baseDir string) (*kubeoneapi.KubeOneCluster, error) { + apiVersion, err := KubeOneClusterAPIVersion(cluster) + if err != nil { + return nil, err } - var ( - internalCluster *kubeoneapi.KubeOneCluster - err error - ) + if _, ok := DeprecatedAPIs[apiVersion]; ok { + logger.Warningf(`The provided APIVersion %q is deprecated. Please use "kubeone config migrate" command to migrate to the latest version.`, apiVersion) + } + + var internalCluster *kubeoneapi.KubeOneCluster // Parse the cluster bytes depending on the GVK - switch typeMeta.APIVersion { + switch apiVersion { case kubeonev1beta2.SchemeGroupVersion.String(): v1beta2Cluster := kubeonev1beta2.NewKubeOneCluster() - if err = runtime.DecodeInto(kubeonescheme.Codecs.UniversalDecoder(), cluster, v1beta2Cluster); err != nil { + if err := runtime.DecodeInto(kubeonescheme.Codecs.UniversalDecoder(), cluster, v1beta2Cluster); err != nil { return nil, fail.Config(err, fmt.Sprintf("decoding %s", v1beta2Cluster.GroupVersionKind())) } + var err error internalCluster, err = DefaultedV1Beta2KubeOneCluster(v1beta2Cluster, tfOutput) if err != nil { return nil, err @@ -176,7 +187,7 @@ func BytesToKubeOneCluster(cluster, tfOutput []byte, credentialsFilePath string, // return nil, err // } default: - return nil, fail.Config(fmt.Errorf("invalid api version %q", typeMeta.APIVersion), "api version") + return nil, fail.Config(fmt.Errorf("invalid api version %q", apiVersion), "api version") } if len(internalCluster.ControlPlane.NodeSets) > 0 { diff --git a/pkg/apis/kubeone/v1beta2/helpers.go b/pkg/apis/kubeone/v1beta2/helpers.go index 98b6025da..29d28f58a 100644 --- a/pkg/apis/kubeone/v1beta2/helpers.go +++ b/pkg/apis/kubeone/v1beta2/helpers.go @@ -59,6 +59,10 @@ func SetCloudProvider(cp *CloudProviderSpec, name string) error { return nil } +func (c *KubeOneCluster) CloudProviderName() string { + return c.CloudProvider.Name() +} + func (cps *CloudProviderSpec) Name() string { switch { case cps.AWS != nil: diff --git a/pkg/apis/kubeone/v1beta3/helpers.go b/pkg/apis/kubeone/v1beta3/helpers.go index cea67cf07..dafa3cddc 100644 --- a/pkg/apis/kubeone/v1beta3/helpers.go +++ b/pkg/apis/kubeone/v1beta3/helpers.go @@ -59,6 +59,10 @@ func SetCloudProvider(cp *CloudProviderSpec, name string) error { return nil } +func (c *KubeOneCluster) CloudProviderName() string { + return c.CloudProvider.Name() +} + func (cps *CloudProviderSpec) Name() string { switch { case cps.AWS != nil: diff --git a/pkg/cmd/config-images.go b/pkg/cmd/config-images.go index 184c25cc8..f17a8a71e 100644 --- a/pkg/cmd/config-images.go +++ b/pkg/cmd/config-images.go @@ -18,12 +18,20 @@ package cmd import ( "fmt" + "os" + "strings" "github.com/MakeNowJust/heredoc/v2" "github.com/pkg/errors" "github.com/spf13/cobra" "github.com/spf13/pflag" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/util/sets" + kubeoneconfig "k8c.io/kubeone/pkg/apis/kubeone/config" + kubeonescheme "k8c.io/kubeone/pkg/apis/kubeone/scheme" + kubeonev1beta2 "k8c.io/kubeone/pkg/apis/kubeone/v1beta2" + kubeonev1beta3 "k8c.io/kubeone/pkg/apis/kubeone/v1beta3" "k8c.io/kubeone/pkg/fail" "k8c.io/kubeone/pkg/templates/images" ) @@ -31,6 +39,7 @@ import ( type listImagesOpts struct { ManifestFile string `longflag:"manifest" shortflag:"m"` Filter string `longflag:"filter"` + Provider string `longflag:"provider"` KubernetesVersion string `longflag:"kubernetes-version" shortflag:"k"` AllImages bool `longflag:"all" shortflag:"a"` } @@ -69,6 +78,9 @@ func listImagesCmd(rootFlags *pflag.FlagSet) *cobra.Command { # To see images list affected by the registryConfiguration configuration (in case if any) kubeone config images list -m mycluster.yaml + + # To see images only related to a specific provider + kubeone config images list --provider aws `), SilenceErrors: true, RunE: func(*cobra.Command, []string) error { @@ -101,6 +113,14 @@ func listImagesCmd(rootFlags *pflag.FlagSet) *cobra.Command { "list all images, including optional ones", ) + cmd.Flags().StringVar( + &opts.Provider, + longFlagName(opts, "Provider"), + "", + fmt.Sprintf("filter images for a specific cloud provider, one of [%s]", + strings.Join(images.SupportedProviders(), "|")), + ) + return cmd } @@ -125,16 +145,89 @@ func listImages(opts *listImagesOpts) error { return err } + // Determine the active provider: explicit flag takes priority, then + // auto-detect from the manifest's cloudProvider field. + provider := opts.Provider + if provider == "" && opts.ManifestFile != "" { + provider, err = detectProviderFromManifest(opts.ManifestFile) + if err != nil { + return err + } + } + var images []string if opts.AllImages { - images = imgResolver.ListAll() + images = append(images, imgResolver.ListAll()...) } else { images = imgResolver.List(listFilter) } - for _, img := range images { + if provider != "" { + provImages, err := imgResolver.ListForProvider(provider) + if err != nil { + return fail.RuntimeError{Op: "listing images for provider", Err: err} + } + images = append(images, provImages...) + } + + for _, img := range sets.NewString(images...).List() { fmt.Println(img) } return nil } + +// detectProviderFromManifest reads the KubeOneCluster manifest and returns the +// cloud provider name as reported by CloudProviderSpec.Name(). Returns an +// empty string when the manifest file cannot be read or when no provider is +// configured ("none" / "unknown"). +func detectProviderFromManifest(manifestFile string) (string, error) { + configBuf, err := os.ReadFile(manifestFile) + if err != nil { + // manifest not accessible – silently skip provider detection + return "", nil + } + + apiVersion, err := kubeoneconfig.KubeOneClusterAPIVersion(configBuf) + if err != nil { + return "", fail.RuntimeError{Op: "parsing manifest for provider detection", Err: err} + } + + var providerName string + + switch apiVersion { + case kubeonev1beta2.SchemeGroupVersion.String(): + providerName, err = inspectCluster(configBuf, kubeonev1beta2.NewKubeOneCluster) + case kubeonev1beta3.SchemeGroupVersion.String(): + providerName, err = inspectCluster(configBuf, kubeonev1beta3.NewKubeOneCluster) + } + if err != nil { + return "", err + } + + if providerName == "none" || providerName == "unknown" || providerName == "" { + return "", nil + } + + return providerName, nil +} + +type cloudProviderNamer interface { + CloudProviderName() string + runtime.Object +} + +// inspectCluster decodes a raw KubeOneCluster manifest into the +// versioned type T (created by newCluster), then calls inspectFn to +// extract the cloud provider name string. T must implement runtime.Object. +func inspectCluster[T cloudProviderNamer]( + configBuf []byte, + newCluster func() T, +) (string, error) { + cluster := newCluster() + if err := runtime.DecodeInto(kubeonescheme.Codecs.UniversalDecoder(), configBuf, cluster); err != nil { + return "", fail.Config(err, fmt.Sprintf("decoding %s", cluster.GetObjectKind().GroupVersionKind())) + } + + return cluster.CloudProviderName(), nil +} diff --git a/pkg/templates/images/images.go b/pkg/templates/images/images.go index 05d477950..03253b2e5 100644 --- a/pkg/templates/images/images.go +++ b/pkg/templates/images/images.go @@ -23,6 +23,7 @@ import ( "maps" "slices" "sort" + "strings" "github.com/Masterminds/semver/v3" "github.com/distribution/reference" @@ -446,6 +447,144 @@ func allResources() map[Resource]map[string]string { return ret } +// providerToResources maps a cloud provider name (as returned by +// CloudProviderSpec.Name()) to the set of optional Resource constants that are +// deployed for that provider. Shared infrastructure images such as the CSI +// snapshot controller are included in every provider that relies on them. +func providerToResources() map[string][]Resource { + return map[string][]Resource{ + "aws": { + AwsCCM, + CSISnapshotController, + CSISnapshotWebhook, + AwsEbsCSI, + AwsEbsCSIAttacher, + AwsEbsCSILivenessProbe, + AwsEbsCSINodeDriverRegistrar, + AwsEbsCSIProvisioner, + AwsEbsCSIResizer, + AwsEbsCSISnapshotter, + }, + "azure": { + AzureCCM, + AzureCNM, + CSISnapshotController, + CSISnapshotWebhook, + AzureFileCSI, + AzureFileCSIAttacher, + AzureFileCSILivenessProbe, + AzureFileCSINodeDriverRegistar, + AzureFileCSIProvisioner, + AzureFileCSIResizer, + AzureFileCSISnapshotter, + AzureDiskCSI, + AzureDiskCSIAttacher, + AzureDiskCSILivenessProbe, + AzureDiskCSINodeDriverRegistar, + AzureDiskCSIProvisioner, + AzureDiskCSIResizer, + AzureDiskCSISnapshotter, + }, + "digitalocean": { + DigitaloceanCCM, + CSISnapshotController, + CSISnapshotWebhook, + DigitalOceanCSI, + DigitalOceanCSIAlpine, + DigitalOceanCSIAttacher, + DigitalOceanCSINodeDriverRegistar, + DigitalOceanCSIProvisioner, + DigitalOceanCSIResizer, + DigitalOceanCSISnapshotter, + }, + "gce": { + GCPCCM, + CSISnapshotController, + CSISnapshotWebhook, + GCPComputeCSIDriver, + GCPComputeCSIProvisioner, + GCPComputeCSIAttacher, + GCPComputeCSIResizer, + GCPComputeCSISnapshotter, + GCPComputeCSINodeDriverRegistrar, + }, + "hetzner": { + HetznerCCM, + HetznerCSI, + HetznerCSIAttacher, + HetznerCSIResizer, + HetznerCSIProvisioner, + HetznerCSILivenessProbe, + HetznerCSINodeDriverRegistar, + }, + "kubevirt": { + KubeVirtCCM, + KubeVirtCSI, + KubeVirtCSINodeDriverRegistrar, + KubeVirtCSILivenessProbe, + KubeVirtCSIProvisioner, + KubeVirtCSIAttacher, + }, + "nutanix": { + NutanixCCM, + NutanixCSI, + NutanixCSILivenessProbe, + NutanixCSIExternalHealthMonitor, + NutanixCSIAttacher, + NutanixCSIPrecheck, + NutanixCSIProvisioner, + NutanixCSIRegistrar, + NutanixCSIResizer, + NutanixCSISnapshotter, + }, + "openstack": { + OpenstackCCM, + CSISnapshotController, + CSISnapshotWebhook, + OpenstackCSI, + OpenstackCSINodeDriverRegistar, + OpenstackCSILivenessProbe, + OpenstackCSIAttacher, + OpenstackCSIProvisioner, + OpenstackCSIResizer, + OpenstackCSISnapshotter, + }, + "equinixmetal": { + EquinixMetalCCM, + }, + "vmwareCloudDirector": { + VMwareCloudDirectorCSI, + VMwareCloudDirectorCSIAttacher, + VMwareCloudDirectorCSIProvisioner, + VMwareCloudDirectorCSIResizer, + VMwareCloudDirectorCSINodeDriverRegistrar, + }, + "vsphere": { + VsphereCCM, + CSISnapshotController, + CSISnapshotWebhook, + VsphereCSIDriver, + VsphereCSISyncer, + VsphereCSIAttacher, + VsphereCSILivenessProbe, + VsphereCSINodeDriverRegistar, + VsphereCSIProvisioner, + VsphereCSIResizer, + VsphereCSISnapshotter, + }, + "none": {}, + } +} + +// SupportedProviders returns the sorted list of cloud provider names that can +// be used with --provider. +func SupportedProviders() []string { + providers := slices.Collect(maps.Keys(providerToResources())) + sort.Strings(providers) + + return providers +} + type Opt func(*Resolver) func WithOverwriteRegistryGetter(getter func() string) Opt { @@ -532,6 +671,36 @@ func (r *Resolver) ListAll() []string { return list } +// ListForProvider returns the sorted list of images required by the given cloud +// provider. The provider name must match one of the values returned by +// SupportedProviders() (i.e. the string returned by CloudProviderSpec.Name()). +// Shared infra images (e.g. CSISnapshotController) are included for every +// provider that uses them. +func (r *Resolver) ListForProvider(provider string) ([]string, error) { + resources, ok := providerToResources()[provider] + if !ok { + return nil, fmt.Errorf("unknown provider %q, must be one of: %s", + provider, strings.Join(SupportedProviders(), ", ")) + } + + // deduplicate with a map (multiple resources can resolve to the same image) + listMap := make(map[string]bool) + for _, res := range resources { + img := r.Get(res) + if img != "" { + listMap[img] = true + } + } + + list := slices.Collect(maps.Keys(listMap)) + if list == nil { + list = []string{} + } + sort.Strings(list) + + return list, nil +} + func (r *Resolver) Tag(res Resource) string { named := res.namedReference(r.kubernetesVersionGetter) if tagged, ok := named.(reference.Tagged); ok { diff --git a/pkg/templates/images/images_test.go b/pkg/templates/images/images_test.go new file mode 100644 index 000000000..91969ca87 --- /dev/null +++ b/pkg/templates/images/images_test.go @@ -0,0 +1,141 @@ +/* +Copyright 2026 The KubeOne Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package images + +import ( + "slices" + "strings" + "testing" +) + +func TestSupportedProviders(t *testing.T) { + providers := SupportedProviders() + if len(providers) == 0 { + t.Fatal("SupportedProviders returned empty list") + } + + // must be sorted + for i := 1; i < len(providers); i++ { + if providers[i] < providers[i-1] { + t.Errorf("SupportedProviders not sorted: %q before %q", providers[i-1], providers[i]) + } + } + + // spot-check well-known providers are present + for _, want := range []string{"aws", "azure", "gce", "hetzner", "openstack", "vsphere"} { + if !slices.Contains(providers, want) { + t.Errorf("SupportedProviders missing expected provider %q", want) + } + } +} + +func TestListForProvider_UnknownProvider(t *testing.T) { + r := NewResolver() + _, err := r.ListForProvider("totally-unknown") + if err == nil { + t.Fatal("expected error for unknown provider, got nil") + } + if !strings.Contains(err.Error(), "totally-unknown") { + t.Errorf("error message should mention the unknown provider; got: %v", err) + } +} + +func TestListForProvider_NoneProvider(t *testing.T) { + r := NewResolver() + imgs, err := r.ListForProvider("none") + if err != nil { + t.Fatalf("unexpected error for 'none' provider: %v", err) + } + if len(imgs) != 0 { + t.Errorf("expected empty list for 'none' provider, got %d images", len(imgs)) + } +} + +func TestListForProvider_AWS(t *testing.T) { + r := NewResolver(WithKubernetesVersionGetter(func() string { return "1.34.0" })) + imgs, err := r.ListForProvider("aws") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(imgs) == 0 { + t.Fatal("expected images for 'aws' provider, got none") + } + + // All returned images should be non-empty strings. + for _, img := range imgs { + if img == "" { + t.Error("ListForProvider returned an empty image string") + } + } + + // Result must be sorted. + for i := 1; i < len(imgs); i++ { + if imgs[i] < imgs[i-1] { + t.Errorf("result not sorted: %q before %q", imgs[i-1], imgs[i]) + } + } + + // AWS CCM image must be present. + found := false + for _, img := range imgs { + if strings.Contains(img, "provider-aws") { + found = true + + break + } + } + if !found { + t.Errorf("AWS CCM image (provider-aws) not found in result: %v", imgs) + } +} + +func TestListForProvider_SharedImagesIncluded(t *testing.T) { + r := NewResolver(WithKubernetesVersionGetter(func() string { return "1.34.0" })) + + // CSISnapshotController should appear in both AWS and vSphere results. + for _, provider := range []string{"aws", "vsphere"} { + imgs, err := r.ListForProvider(provider) + if err != nil { + t.Fatalf("provider %q: unexpected error: %v", provider, err) + } + + found := false + for _, img := range imgs { + if strings.Contains(img, "snapshot-controller") { + found = true + + break + } + } + if !found { + t.Errorf("provider %q: CSISnapshotController not found in result: %v", provider, imgs) + } + } +} + +func TestListForProvider_AllProvidersReturnNonNil(t *testing.T) { + r := NewResolver(WithKubernetesVersionGetter(func() string { return "1.34.0" })) + for _, provider := range SupportedProviders() { + imgs, err := r.ListForProvider(provider) + if err != nil { + t.Errorf("provider %q: unexpected error: %v", provider, err) + } + if imgs == nil { + t.Errorf("provider %q: expected non-nil slice, got nil", provider) + } + } +} From 575248243c42b03fa230e97a61fe82acf84f888d Mon Sep 17 00:00:00 2001 From: Artiom Diomin Date: Wed, 24 Jun 2026 23:05:50 +0300 Subject: [PATCH 2/2] tmp --- pkg/cmd/config-images.go | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/pkg/cmd/config-images.go b/pkg/cmd/config-images.go index f17a8a71e..6f5513e59 100644 --- a/pkg/cmd/config-images.go +++ b/pkg/cmd/config-images.go @@ -155,11 +155,12 @@ func listImages(opts *listImagesOpts) error { } } - var images []string + var images sets.Set[string] + if opts.AllImages { - images = append(images, imgResolver.ListAll()...) + images = sets.New(imgResolver.ListAll()...) } else { - images = imgResolver.List(listFilter) + images = sets.New(imgResolver.List(listFilter)...) } if provider != "" { @@ -167,10 +168,10 @@ func listImages(opts *listImagesOpts) error { if err != nil { return fail.RuntimeError{Op: "listing images for provider", Err: err} } - images = append(images, provImages...) + images = images.Intersection(sets.New(provImages...)) } - for _, img := range sets.NewString(images...).List() { + for _, img := range sets.List(images) { fmt.Println(img) }