From 56a48af7ebdc2ebb9b9aaad60ff3423292cbeff6 Mon Sep 17 00:00:00 2001 From: Ciprian Hacman Date: Fri, 8 Aug 2025 21:40:14 +0300 Subject: [PATCH 1/2] Add cluster creation test --- cmd/kops/create_cluster_integration_test.go | 5 ++ .../zero-nodes/expected-v1alpha2.yaml | 76 +++++++++++++++++++ .../create_cluster/zero-nodes/options.yaml | 7 ++ 3 files changed, 88 insertions(+) create mode 100644 tests/integration/create_cluster/zero-nodes/expected-v1alpha2.yaml create mode 100644 tests/integration/create_cluster/zero-nodes/options.yaml diff --git a/cmd/kops/create_cluster_integration_test.go b/cmd/kops/create_cluster_integration_test.go index bb625ea1fa45a..a0408353d2d19 100644 --- a/cmd/kops/create_cluster_integration_test.go +++ b/cmd/kops/create_cluster_integration_test.go @@ -195,6 +195,11 @@ func TestCreateClusterKarpenter(t *testing.T) { runCreateClusterIntegrationTest(t, "../../tests/integration/create_cluster/karpenter", "v1alpha2") } +// TestCreateClusterZeroNodes runs kops create cluster --node-count=0 +func TestCreateClusterZeroNodes(t *testing.T) { + runCreateClusterIntegrationTest(t, "../../tests/integration/create_cluster/zero-nodes", "v1alpha2") +} + func runCreateClusterIntegrationTest(t *testing.T, srcDir string, version string) { ctx := context.Background() diff --git a/tests/integration/create_cluster/zero-nodes/expected-v1alpha2.yaml b/tests/integration/create_cluster/zero-nodes/expected-v1alpha2.yaml new file mode 100644 index 0000000000000..d48648167816f --- /dev/null +++ b/tests/integration/create_cluster/zero-nodes/expected-v1alpha2.yaml @@ -0,0 +1,76 @@ +apiVersion: kops.k8s.io/v1alpha2 +kind: Cluster +metadata: + creationTimestamp: "2017-01-01T00:00:00Z" + name: minimal.example.com +spec: + api: + loadBalancer: + class: Network + type: Public + authorization: + rbac: {} + channel: stable + cloudProvider: aws + configBase: memfs://tests/minimal.example.com + etcdClusters: + - cpuRequest: 200m + etcdMembers: + - encryptedVolume: true + instanceGroup: control-plane-us-test-1a + name: a + manager: + backupRetentionDays: 90 + memoryRequest: 100Mi + name: main + - cpuRequest: 100m + etcdMembers: + - encryptedVolume: true + instanceGroup: control-plane-us-test-1a + name: a + manager: + backupRetentionDays: 90 + memoryRequest: 100Mi + name: events + iam: + allowContainerRegistry: true + legacy: false + kubelet: + anonymousAuth: false + kubernetesApiAccess: + - 0.0.0.0/0 + - ::/0 + kubernetesVersion: v1.32.0 + networkCIDR: 172.20.0.0/16 + networking: + cni: {} + nonMasqueradeCIDR: 100.64.0.0/10 + sshAccess: + - 0.0.0.0/0 + - ::/0 + subnets: + - cidr: 172.20.0.0/16 + name: us-test-1a + type: Public + zone: us-test-1a + topology: + dns: + type: None + +--- + +apiVersion: kops.k8s.io/v1alpha2 +kind: InstanceGroup +metadata: + creationTimestamp: "2017-01-01T00:00:00Z" + labels: + kops.k8s.io/cluster: minimal.example.com + name: control-plane-us-test-1a +spec: + image: 099720109477/ubuntu/images/hvm-ssd-gp3/ubuntu-noble-24.04-amd64-server-20250610 + machineType: m3.medium + maxSize: 1 + minSize: 1 + role: Master + subnets: + - us-test-1a diff --git a/tests/integration/create_cluster/zero-nodes/options.yaml b/tests/integration/create_cluster/zero-nodes/options.yaml new file mode 100644 index 0000000000000..bc0aeb20313b0 --- /dev/null +++ b/tests/integration/create_cluster/zero-nodes/options.yaml @@ -0,0 +1,7 @@ +ClusterName: minimal.example.com +Zones: +- us-test-1a +CloudProvider: aws +Networking: cni +KubernetesVersion: v1.32.0 +NodeCount: 0 \ No newline at end of file From 84de39f6901f5f588943d5abd9b8bb861b4464b1 Mon Sep 17 00:00:00 2001 From: Ciprian Hacman Date: Thu, 7 Aug 2025 23:29:47 +0300 Subject: [PATCH 2/2] Allow running without worker nodes --- cmd/kops/create_cluster.go | 7 +++- pkg/apis/kops/validation/legacy.go | 4 --- .../k8s-1.12.yaml.template | 2 +- upup/pkg/fi/cloudup/deepvalidate_test.go | 5 ++- upup/pkg/fi/cloudup/new_cluster.go | 32 +++++++++++++------ upup/pkg/fi/cloudup/template_functions.go | 18 +++++++++++ 6 files changed, 51 insertions(+), 17 deletions(-) diff --git a/cmd/kops/create_cluster.go b/cmd/kops/create_cluster.go index 22ead7143ae4f..ba27a1055413f 100644 --- a/cmd/kops/create_cluster.go +++ b/cmd/kops/create_cluster.go @@ -175,6 +175,7 @@ func NewCmdCreateCluster(f *util.Factory, out io.Writer) *cobra.Command { sshPublicKey := "" associatePublicIP := false encryptEtcdStorage := false + nodeCount := int32(0) cmd := &cobra.Command{ Use: "cluster [CLUSTER]", @@ -194,6 +195,10 @@ func NewCmdCreateCluster(f *util.Factory, out io.Writer) *cobra.Command { options.EncryptEtcdStorage = &encryptEtcdStorage } + if cmd.Flag("node-count").Changed { + options.NodeCount = &nodeCount + } + if sshPublicKey != "" { options.SSHPublicKeys, err = loadSSHPublicKeys(sshPublicKey) if err != nil { @@ -268,7 +273,7 @@ func NewCmdCreateCluster(f *util.Factory, out io.Writer) *cobra.Command { cmd.Flags().Int32Var(&options.ControlPlaneCount, "master-count", options.ControlPlaneCount, "Number of control-plane nodes. Defaults to one control-plane node per control-plane-zone") cmd.Flags().MarkDeprecated("master-count", "use --control-plane-count instead") cmd.Flags().Int32Var(&options.ControlPlaneCount, "control-plane-count", options.ControlPlaneCount, "Number of control-plane nodes. Defaults to one control-plane node per control-plane-zone") - cmd.Flags().Int32Var(&options.NodeCount, "node-count", options.NodeCount, "Total number of worker nodes. Defaults to one node per zone") + cmd.Flags().Int32Var(&nodeCount, "node-count", 0, "Total number of worker nodes. Defaults to one node per zone") cmd.Flags().StringVar(&options.Image, "image", options.Image, "Machine image for all instances") cmd.RegisterFlagCompletionFunc("image", completeInstanceImage) diff --git a/pkg/apis/kops/validation/legacy.go b/pkg/apis/kops/validation/legacy.go index e5427cb5d8c76..d16281532c455 100644 --- a/pkg/apis/kops/validation/legacy.go +++ b/pkg/apis/kops/validation/legacy.go @@ -274,10 +274,6 @@ func DeepValidate(c *kops.Cluster, groups []*kops.InstanceGroup, strict bool, vf return fmt.Errorf("must configure at least one ControlPlane InstanceGroup") } - if nodeGroupCount == 0 { - return fmt.Errorf("must configure at least one Node InstanceGroup") - } - for _, g := range groups { errs := CrossValidateInstanceGroup(g, c, cloud, strict) diff --git a/upup/models/cloudup/resources/addons/coredns.addons.k8s.io/k8s-1.12.yaml.template b/upup/models/cloudup/resources/addons/coredns.addons.k8s.io/k8s-1.12.yaml.template index 58d9554bfa269..5ac86ced0ff6f 100644 --- a/upup/models/cloudup/resources/addons/coredns.addons.k8s.io/k8s-1.12.yaml.template +++ b/upup/models/cloudup/resources/addons/coredns.addons.k8s.io/k8s-1.12.yaml.template @@ -138,7 +138,7 @@ spec: - key: "CriticalAddonsOnly" operator: "Exists" {{- end }} - {{- if KarpenterEnabled }} + {{- if or IsControlPlaneOnly KarpenterEnabled }} - key: node-role.kubernetes.io/master operator: Exists - key: node-role.kubernetes.io/control-plane diff --git a/upup/pkg/fi/cloudup/deepvalidate_test.go b/upup/pkg/fi/cloudup/deepvalidate_test.go index d1d36f42d614f..1b0311b9f8bb1 100644 --- a/upup/pkg/fi/cloudup/deepvalidate_test.go +++ b/upup/pkg/fi/cloudup/deepvalidate_test.go @@ -44,7 +44,10 @@ func TestDeepValidate_NoNodeZones(t *testing.T) { c := buildDefaultCluster(t) var groups []*kopsapi.InstanceGroup groups = append(groups, buildMinimalMasterInstanceGroup("subnet-us-test-1a")) - expectErrorFromDeepValidate(t, c, groups, "must configure at least one Node InstanceGroup") + err := validation.DeepValidate(c, groups, true, vfs.Context, nil) + if err != nil { + t.Fatalf("Expected no error from DeepValidate, got %v", err) + } } func TestDeepValidate_NoMasterZones(t *testing.T) { diff --git a/upup/pkg/fi/cloudup/new_cluster.go b/upup/pkg/fi/cloudup/new_cluster.go index 133e612db954f..dd5a69b5ec974 100644 --- a/upup/pkg/fi/cloudup/new_cluster.go +++ b/upup/pkg/fi/cloudup/new_cluster.go @@ -134,7 +134,7 @@ type NewClusterOptions struct { // NodeCount is the number of nodes to create. Defaults to leaving the count unspecified // on the InstanceGroup, which results in a count of 2. - NodeCount int32 + NodeCount *int32 // Bastion enables the creation of a Bastion instance. Bastion bool // BastionLoadBalancerType is the bastion loadbalancer type to use; "public" or "internal". @@ -1040,9 +1040,15 @@ func setupNodes(opt *NewClusterOptions, cluster *api.Cluster, zoneToSubnetsMap m var nodes []*api.InstanceGroup if featureflag.AWSSingleNodesInstanceGroup.Enabled() && cloudProvider == api.CloudProviderAWS && len(opt.SubnetIDs) == 0 { - nodeCount := opt.NodeCount - if nodeCount == 0 { + var nodeCount int32 + if opt.NodeCount == nil { nodeCount = 1 + } else { + nodeCount = fi.ValueOf(opt.NodeCount) + if nodeCount == 0 { + // If the node count is 0, there's nothing to do + return nil, nil + } } g := &api.InstanceGroup{} @@ -1078,19 +1084,25 @@ func setupNodes(opt *NewClusterOptions, cluster *api.Cluster, zoneToSubnetsMap m // The node count is the number of zones unless explicitly set // We then divvy up amongst the zones - numZones := len(opt.Zones) - nodeCount := opt.NodeCount - if nodeCount == 0 { - // If node count is not specified, default to the number of zones - nodeCount = int32(numZones) + numZones := int32(len(opt.Zones)) + var nodeCount int32 + if opt.NodeCount == nil { + // If the node count is not specified, default to the number of zones + nodeCount = numZones + } else { + nodeCount = fi.ValueOf(opt.NodeCount) + if nodeCount == 0 { + // If the node count is 0, there's nothing to do + return nil, nil + } } countPerIG := nodeCount / int32(numZones) - remainder := int(nodeCount) % numZones + remainder := nodeCount % numZones for i, zone := range opt.Zones { count := countPerIG - if i < remainder { + if i < int(remainder) { count++ } diff --git a/upup/pkg/fi/cloudup/template_functions.go b/upup/pkg/fi/cloudup/template_functions.go index 0ae70874b27d9..d50771b43f122 100644 --- a/upup/pkg/fi/cloudup/template_functions.go +++ b/upup/pkg/fi/cloudup/template_functions.go @@ -399,6 +399,8 @@ func (tf *TemplateFunctions) AddTo(dest template.FuncMap, secretStore fi.SecretS dest["ParseTaint"] = util.ParseTaint + dest["IsControlPlaneOnly"] = tf.IsControlPlaneOnly + dest["KarpenterEnabled"] = func() bool { return cluster.Spec.Karpenter != nil && cluster.Spec.Karpenter.Enabled } @@ -511,6 +513,22 @@ func (tf *TemplateFunctions) HasHighlyAvailableControlPlane() bool { return false } +// IsControlPlaneOnly returns true if the cluster has only control plane node(s). False otherwise. +func (tf *TemplateFunctions) IsControlPlaneOnly() bool { + var cp, wn int + for _, ig := range tf.InstanceGroups { + switch ig.Spec.Role { + case kops.InstanceGroupRoleControlPlane: + cp++ + case kops.InstanceGroupRoleNode: + wn++ + default: + // Ignore Bastion and APIServer + } + } + return cp > 0 && wn == 0 +} + // CloudControllerConfigArgv returns the args to external cloud controller func (tf *TemplateFunctions) CloudControllerConfigArgv() ([]string, error) { cluster := tf.Cluster