Skip to content
Merged
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
14 changes: 10 additions & 4 deletions test/e2e/cluster_upgrade_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,10 +36,10 @@ var _ = Describe("Cluster upgrade test [K8s-Upgrade-Cluster]", func() {
dockerClient *client.Client
allbyohostContainerIDs []string
allAgentLogFiles []string
kubernetesVersionUpgradeFrom = "v1.25.11"
kubernetesVersionUpgradeTo = "v1.26.6"
etcdUpgradeVersion = "3.5.6-0"
coreDNSUpgradeVersion = "v1.9.3"
kubernetesVersionUpgradeFrom = "v1.31.0"
kubernetesVersionUpgradeTo = "v1.31.2"
etcdUpgradeVersion = "3.5.15-0"
coreDNSUpgradeVersion = "v1.11.3"
)

BeforeEach(func() {
Expand All @@ -58,6 +58,12 @@ var _ = Describe("Cluster upgrade test [K8s-Upgrade-Cluster]", func() {
})

It("Should successfully upgrade cluster", func() {
// Fail fast: no Kubernetes version in this project's OCI bundle registry
// (quay.io/platform9, v1.31.0+ only) can complete a kubeadm upgrade against the vendored
// CAPI v1.4.4 — its kubeadm-config decoder predates the kubeadm.k8s.io/v1beta4 API that
// v1.31+ writes. Needs CAPI bumped past v1.4.4 before this can run.
Fail("blocked on CAPI v1.4.4 -> kubeadm v1beta4 incompatibility")

clusterName := fmt.Sprintf("%s-%s", specName, util.RandomString(6))
var err error
dockerClient, err = client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())
Expand Down
14 changes: 10 additions & 4 deletions test/e2e/clusterclass_upgrade_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,10 +36,10 @@ var _ = Describe("Clusterclass upgrade test [K8s-Upgrade-ClusterClass]", func()
dockerClient *client.Client
allbyohostContainerIDs []string
allAgentLogFiles []string
kubernetesVersionUpgradeFrom = "v1.25.11"
kubernetesVersionUpgradeTo = "v1.26.6"
etcdUpgradeVersion = "3.5.6-0"
coreDNSUpgradeVersion = "v1.9.3"
kubernetesVersionUpgradeFrom = "v1.31.0"
kubernetesVersionUpgradeTo = "v1.31.2"
etcdUpgradeVersion = "3.5.15-0"
coreDNSUpgradeVersion = "v1.11.3"
)

BeforeEach(func() {
Expand All @@ -58,6 +58,12 @@ var _ = Describe("Clusterclass upgrade test [K8s-Upgrade-ClusterClass]", func()
})

It("Should successfully upgrade cluster", func() {
// Fail fast: no Kubernetes version in this project's OCI bundle registry
// (quay.io/platform9, v1.31.0+ only) can complete a kubeadm upgrade against the vendored
// CAPI v1.4.4 — its kubeadm-config decoder predates the kubeadm.k8s.io/v1beta4 API that
// v1.31+ writes. Needs CAPI bumped past v1.4.4 before this can run.
Fail("blocked on CAPI v1.4.4 -> kubeadm v1beta4 incompatibility")

clusterName := fmt.Sprintf("%s-%s", specName, util.RandomString(6))
var err error
dockerClient, err = client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())
Expand Down
37 changes: 27 additions & 10 deletions test/e2e/docker_helper.go
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
// Copyright 2021 VMware, Inc. All Rights Reserved.
// Copyright 2026 Platform9, Inc. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0

package e2e

import (
"context"
"fmt"
"io"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"

"github.com/docker/cli/cli/command"
Expand All @@ -20,6 +23,7 @@ import (
"github.com/docker/docker/pkg/archive"
"github.com/docker/docker/pkg/system"
"github.com/docker/go-units"
ginkgo "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega" //nolint: staticcheck
"github.com/pkg/errors"
"k8s.io/client-go/tools/clientcmd"
Expand Down Expand Up @@ -351,6 +355,24 @@ func (r *ByoHostRunner) ExecByoDockerHost(byohost *container.CreateResponse) (ty
return output, byohost.ID, err
}

// controlPlaneEndpointIPBaseOctet is the lowest last-octet value handed out for a control-plane
// endpoint IP (process 1 gets this value, process 2 gets this+1, and so on). It's a static IP in
// the kind network's subnet but outside its DHCP range.
const controlPlaneEndpointIPBaseOctet = 151

// controlPlaneEndpointIP derives a control-plane endpoint IP from the kind network's subnet,
// offset by processIndex so that concurrently running Ginkgo processes (GINKGO_NODES>1) each get
// their own unique endpoint IP instead of colliding on the same static address.
func controlPlaneEndpointIP(subnet string, processIndex int) (string, error) {
ipOctets := strings.Split(subnet, ".")
if len(ipOctets) < ipv4OctetCount {
return "", fmt.Errorf("unexpected subnet format: %s", subnet)
}

ipOctets[3] = strconv.Itoa(controlPlaneEndpointIPBaseOctet + processIndex - 1)
return strings.Join(ipOctets[:ipv4OctetCount], "."), nil
}

func setControlPlaneIP(ctx context.Context, dockerClient *client.Client) {
_, ok := os.LookupEnv("CONTROL_PLANE_ENDPOINT_IP")
if ok {
Expand All @@ -367,16 +389,11 @@ func setControlPlaneIP(ctx context.Context, dockerClient *client.Client) {
}
}
Expect(ipv4Subnet).NotTo(BeEmpty(), "no IPv4 subnet found in kind network IPAM config")
ipOctets := strings.Split(ipv4Subnet, ".")

// The ControlPlaneEndpoint is a static IP that is in the hosts'
// subnet but outside of its DHCP range. We believe 151 is a pretty
// high number and we have < 10 containers being spun up, so we
// can safely use this IP for the ControlPlaneEndpoint
Expect(len(ipOctets)).To(BeNumerically(">=", ipv4OctetCount), "unexpected subnet format: %s", ipv4Subnet)
ipOctets[3] = "151"
ip := strings.Join(ipOctets, ".")
err := os.Setenv("CONTROL_PLANE_ENDPOINT_IP", ip)

ip, err := controlPlaneEndpointIP(ipv4Subnet, ginkgo.GinkgoParallelProcess())
Expect(err).NotTo(HaveOccurred())

err = os.Setenv("CONTROL_PLANE_ENDPOINT_IP", ip)
if err != nil {
Expect(err).NotTo(HaveOccurred())
}
Expand Down
53 changes: 53 additions & 0 deletions test/e2e/docker_helper_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
// Copyright 2026 Platform9, Inc. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0

// nolint: testpackage
package e2e

import (
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestControlPlaneEndpointIP(t *testing.T) {
testCases := []struct {
name string
subnet string
processIndex int
want string
}{
{
name: "process 1 gets the base offset",
subnet: "172.18.0.0/16",
processIndex: 1,
want: "172.18.0.151",
},
{
name: "distinct concurrent processes get distinct IPs",
subnet: "172.18.0.0/16",
processIndex: 2,
want: "172.18.0.152",
},
{
name: "process index offsets the last octet regardless of subnet size",
subnet: "10.0.0.0/24",
processIndex: 7,
want: "10.0.0.157",
},
}

for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
got, err := controlPlaneEndpointIP(tc.subnet, tc.processIndex)
require.NoError(t, err)
assert.Equal(t, tc.want, got)
})
}
}

func TestControlPlaneEndpointIPInvalidSubnet(t *testing.T) {
_, err := controlPlaneEndpointIP("not-a-subnet", 1)
require.Error(t, err)
}
50 changes: 50 additions & 0 deletions test/e2e/e2e_suite_shared_data_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
// Copyright 2026 Platform9, Inc. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0

// nolint: testpackage
package e2e

import (
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestParseSharedSuiteData(t *testing.T) {
testCases := []struct {
name string
data sharedSuiteData
}{
{
name: "typical values",
data: sharedSuiteData{
artifactFolder: "/tmp/artifacts",
configPath: "/tmp/e2e-config.yaml",
clusterctlConfigPath: "/tmp/artifacts/repository/clusterctl-config.yaml",
kubeconfigPath: "/tmp/kind-bootstrap.kubeconfig",
clusterConName: "test-ab12cd",
pathToHostAgentBinary: "/tmp/agent-binary/byoh-hostagent",
},
},
{
name: "empty fields round-trip as empty strings",
data: sharedSuiteData{},
},
}

for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
encoded := formatSharedSuiteData(&tc.data)

decoded, err := parseSharedSuiteData(encoded)
require.NoError(t, err)
assert.Equal(t, tc.data, decoded)
})
}
}

func TestParseSharedSuiteDataWrongFieldCount(t *testing.T) {
_, err := parseSharedSuiteData([]byte("only,four,comma,fields"))
require.Error(t, err)
}
89 changes: 68 additions & 21 deletions test/e2e/e2e_suite_test.go
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
// Copyright 2021 VMware, Inc. All Rights Reserved.
// Copyright 2026 Platform9, Inc. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0

// nolint: testpackage
Expand Down Expand Up @@ -96,6 +97,46 @@ func TestE2E(t *testing.T) {
RunSpecs(t, "Controller Suite")
}

// sharedSuiteData is the state the "run once" SynchronizedBeforeSuite closure hands to the
// "run on every ParallelNode" closure. Every field here must be read by more than one Ginkgo
// process under GINKGO_NODES>1, so it has to cross the process boundary via this struct rather
// than a plain package-level assignment.
type sharedSuiteData struct {
artifactFolder string
configPath string
clusterctlConfigPath string
kubeconfigPath string
clusterConName string
pathToHostAgentBinary string
}

func formatSharedSuiteData(d *sharedSuiteData) []byte {
return []byte(strings.Join([]string{
d.artifactFolder,
d.configPath,
d.clusterctlConfigPath,
d.kubeconfigPath,
d.clusterConName,
d.pathToHostAgentBinary,
}, ","))
}

func parseSharedSuiteData(data []byte) (sharedSuiteData, error) {
parts := strings.Split(string(data), ",")
if len(parts) != 6 {
return sharedSuiteData{}, fmt.Errorf("expected 6 comma-separated fields in shared suite data, got %d", len(parts))
}

return sharedSuiteData{
artifactFolder: parts[0],
configPath: parts[1],
clusterctlConfigPath: parts[2],
kubeconfigPath: parts[3],
clusterConName: parts[4],
pathToHostAgentBinary: parts[5],
}, nil
}

// Using a SynchronizedBeforeSuite for controlling how to create resources shared across ParallelNodes (~ginkgo threads).
// The local clusterctl repository & the bootstrap cluster are created once and shared across all the tests.
var _ = SynchronizedBeforeSuite(func() []byte {
Expand Down Expand Up @@ -130,27 +171,28 @@ var _ = SynchronizedBeforeSuite(func() []byte {
Expect(err).NotTo(HaveOccurred())

clusterConName = e2eConfig.ManagementClusterName
return []byte(
strings.Join([]string{
artifactFolder,
configPath,
clusterctlConfigPath,
bootstrapClusterProxy.GetKubeconfigPath(),
}, ","),
)
return formatSharedSuiteData(&sharedSuiteData{
artifactFolder: artifactFolder,
configPath: configPath,
clusterctlConfigPath: clusterctlConfigPath,
kubeconfigPath: bootstrapClusterProxy.GetKubeconfigPath(),
clusterConName: clusterConName,
pathToHostAgentBinary: pathToHostAgentBinary,
})
}, func(data []byte) {
// Before each ParallelNode.

parts := strings.Split(string(data), ",")
Expect(parts).To(HaveLen(4))
shared, err := parseSharedSuiteData(data)
Expect(err).NotTo(HaveOccurred())

artifactFolder = parts[0]
configPath = parts[1]
clusterctlConfigPath = parts[2]
kubeconfigPath := parts[3]
artifactFolder = shared.artifactFolder
configPath = shared.configPath
clusterctlConfigPath = shared.clusterctlConfigPath
clusterConName = shared.clusterConName
pathToHostAgentBinary = shared.pathToHostAgentBinary

e2eConfig = loadE2EConfig(configPath)
bootstrapClusterProxy = framework.NewClusterProxy("bootstrap", kubeconfigPath, initScheme(), framework.WithMachineLogCollector(framework.DockerLogCollector{}))
bootstrapClusterProxy = framework.NewClusterProxy("bootstrap", shared.kubeconfigPath, initScheme(), framework.WithMachineLogCollector(framework.DockerLogCollector{}))
})

// Using a SynchronizedAfterSuite for controlling how to delete resources shared across ParallelNodes (~ginkgo threads).
Expand Down Expand Up @@ -254,10 +296,15 @@ func setupSpecNamespace(ctx context.Context, specName string, clusterProxy frame
}

func dumpSpecResourcesAndCleanup(ctx context.Context, specName string, clusterProxy framework.ClusterProxy, artifactFolder string, namespace *corev1.Namespace, cancelWatches context.CancelFunc, cluster *clusterv1.Cluster, intervalsGetter func(spec, key string) []interface{}, skipCleanup bool) {
Byf("Dumping logs from the %q workload cluster", cluster.Name)

// Dump all the logs from the workload cluster before deleting them.
clusterProxy.CollectWorkloadClusterLogs(ctx, cluster.Namespace, cluster.Name, filepath.Join(artifactFolder, "clusters", cluster.Name, "machines"))
// cluster is nil when the spec failed before ApplyClusterTemplateAndWait ever ran (e.g. an
// early Fail() call) -- skip the cluster-specific log collection below rather than crash on
// a nil pointer, since there's nothing captured under cluster.Name to actually dump.
if cluster != nil {
Byf("Dumping logs from the %q workload cluster", cluster.Name)

// Dump all the logs from the workload cluster before deleting them.
clusterProxy.CollectWorkloadClusterLogs(ctx, cluster.Namespace, cluster.Name, filepath.Join(artifactFolder, "clusters", cluster.Name, "machines"))
}

Byf("Dumping all the Cluster API resources in the %q namespace", namespace.Name)

Expand All @@ -269,7 +316,7 @@ func dumpSpecResourcesAndCleanup(ctx context.Context, specName string, clusterPr
})

if !skipCleanup {
Byf("Deleting cluster %s/%s", cluster.Namespace, cluster.Name)
Byf("Deleting cluster resources in namespace %q", namespace.Name)
// While https://github.com/kubernetes-sigs/cluster-api/issues/2955 is addressed in future iterations, there is a chance
// that cluster variable is not set even if the cluster exists, so we are calling DeleteAllClustersAndWait
// instead of DeleteClusterAndWait
Expand Down Expand Up @@ -316,7 +363,7 @@ func generateBootstrapKubeconfig(ctx context.Context, clusterProxy framework.Clu
return nil
}
return createdBootstrapKubeconfig.Status.BootstrapKubeconfigData
}).ShouldNot(BeNil())
}, e2eConfig.GetIntervals("", "wait-controllers")...).ShouldNot(BeNil())

return *createdBootstrapKubeconfig.Status.BootstrapKubeconfigData
}
Loading