From 5896136ca79ec49f5bfca9fa231b34f10ff4e62b Mon Sep 17 00:00:00 2001 From: Mohammed Firdous <124298708+mohammedfirdouss@users.noreply.github.com> Date: Sat, 21 Mar 2026 10:39:41 +0000 Subject: [PATCH 1/4] feat: add K8S_BASELINE_ROLLOUT stage for baseline deployments Signed-off-by: Mohammed Firdous <124298708+mohammedfirdouss@users.noreply.github.com> --- .../plugin/kubernetes_multicluster/deployment/pipeline.go | 5 +++++ .../plugin/kubernetes_multicluster/deployment/plugin.go | 2 ++ 2 files changed, 7 insertions(+) diff --git a/pkg/app/pipedv1/plugin/kubernetes_multicluster/deployment/pipeline.go b/pkg/app/pipedv1/plugin/kubernetes_multicluster/deployment/pipeline.go index 63894ae1b7..60ae964d08 100644 --- a/pkg/app/pipedv1/plugin/kubernetes_multicluster/deployment/pipeline.go +++ b/pkg/app/pipedv1/plugin/kubernetes_multicluster/deployment/pipeline.go @@ -30,6 +30,8 @@ const ( StageK8sMultiCanaryRollout = "K8S_CANARY_ROLLOUT" // StageK8sMultiCanaryClean represents the state where all canary resources should be removed. StageK8sMultiCanaryClean = "K8S_CANARY_CLEAN" + // StageK8sMultiBaselineRollout represents the state where the current version is deployed as BASELINE to all targets. + StageK8sMultiBaselineRollout = "K8S_BASELINE_ROLLOUT" ) var allStages = []string{ @@ -37,6 +39,7 @@ var allStages = []string{ StageK8sMultiRollback, StageK8sMultiCanaryRollout, StageK8sMultiCanaryClean, + StageK8sMultiBaselineRollout, } const ( @@ -48,6 +51,8 @@ const ( StageDescriptionK8sMultiCanaryRollout = "Rollout the new version as CANARY to all targets" // StageDescriptionK8sMultiCanaryClean represents the description of the K8sCanaryClean stage. StageDescriptionK8sMultiCanaryClean = "Remove all canary resources" + // StageDescriptionK8sMultiBaselineRollout represents the description of the K8sBaselineRollout stage. + StageDescriptionK8sMultiBaselineRollout = "Rollout the current version as BASELINE to all targets" ) func buildQuickSyncPipeline(autoRollback bool) []sdk.QuickSyncStage { diff --git a/pkg/app/pipedv1/plugin/kubernetes_multicluster/deployment/plugin.go b/pkg/app/pipedv1/plugin/kubernetes_multicluster/deployment/plugin.go index f1e56b3f64..ae97c7e4da 100644 --- a/pkg/app/pipedv1/plugin/kubernetes_multicluster/deployment/plugin.go +++ b/pkg/app/pipedv1/plugin/kubernetes_multicluster/deployment/plugin.go @@ -73,6 +73,8 @@ func (p *Plugin) ExecuteStage(ctx context.Context, _ *sdk.ConfigNone, dts []*sdk return &sdk.ExecuteStageResponse{ Status: p.executeK8sMultiCanaryCleanStage(ctx, input, dts), }, nil + case StageK8sMultiBaselineRollout: + return &sdk.ExecuteStageResponse{Status: p.executeK8sMultiBaselineRolloutStage(ctx, input, dts)}, nil default: return nil, errors.New("unimplemented or unsupported stage") } From fa7dcf0a6aead867f599efd7803909a527c25fd0 Mon Sep 17 00:00:00 2001 From: Mohammed Firdous <124298708+mohammedfirdouss@users.noreply.github.com> Date: Sat, 21 Mar 2026 10:41:48 +0000 Subject: [PATCH 2/4] feat: add K8sBaselineRolloutStageOptions for baseline rollout configuration Signed-off-by: Mohammed Firdous <124298708+mohammedfirdouss@users.noreply.github.com> --- .../config/application.go | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/pkg/app/pipedv1/plugin/kubernetes_multicluster/config/application.go b/pkg/app/pipedv1/plugin/kubernetes_multicluster/config/application.go index 034d849011..7f83edcbe3 100644 --- a/pkg/app/pipedv1/plugin/kubernetes_multicluster/config/application.go +++ b/pkg/app/pipedv1/plugin/kubernetes_multicluster/config/application.go @@ -201,6 +201,34 @@ type K8sCanaryRolloutStageOptions struct { // K8sCanaryCleanStageOptions contains all configurable values for a K8S_CANARY_CLEAN stage. type K8sCanaryCleanStageOptions struct{} +// K8sBaselineRolloutStageOptions contains all configurable values for a K8S_BASELINE_ROLLOUT stage. +type K8sBaselineRolloutStageOptions struct { + // How many pods for BASELINE workloads. + // An integer value can be specified to indicate an absolute value of pod number. + // Or a string suffixed by "%" to indicate a percentage value compared to the pod number of PRIMARY. + // Default is 1 pod. + Replicas unit.Replicas `json:"replicas"` + // Suffix that should be used when naming the BASELINE variant's resources. + // Default is "baseline". + Suffix string `json:"suffix" default:"baseline"` + // Whether the BASELINE service should be created. + CreateService bool `json:"createService"` +} + +func (o *K8sBaselineRolloutStageOptions) UnmarshalJSON(data []byte) error { + type alias K8sBaselineRolloutStageOptions + var a alias + if err := json.Unmarshal(data, &a); err != nil { + return err + } + *o = K8sBaselineRolloutStageOptions(a) + if err := defaults.Set(o); err != nil { + return err + } + return nil +} + + // K8sResourcePatch represents a patch operation for a Kubernetes resource. type K8sResourcePatch struct { // The target of the patch operation. From 7ff416af2174fef55f42c3b23b1acadff81644a6 Mon Sep 17 00:00:00 2001 From: Mohammed Firdous <124298708+mohammedfirdouss@users.noreply.github.com> Date: Sat, 21 Mar 2026 10:42:21 +0000 Subject: [PATCH 3/4] feat: implement K8s multi-cluster baseline rollout stage and corresponding tests Signed-off-by: Mohammed Firdous <124298708+mohammedfirdouss@users.noreply.github.com> --- .../deployment/baseline.go | 237 ++++++++++++ .../deployment/baseline_test.go | 343 ++++++++++++++++++ 2 files changed, 580 insertions(+) create mode 100644 pkg/app/pipedv1/plugin/kubernetes_multicluster/deployment/baseline.go create mode 100644 pkg/app/pipedv1/plugin/kubernetes_multicluster/deployment/baseline_test.go diff --git a/pkg/app/pipedv1/plugin/kubernetes_multicluster/deployment/baseline.go b/pkg/app/pipedv1/plugin/kubernetes_multicluster/deployment/baseline.go new file mode 100644 index 0000000000..d762581d74 --- /dev/null +++ b/pkg/app/pipedv1/plugin/kubernetes_multicluster/deployment/baseline.go @@ -0,0 +1,237 @@ +// Copyright 2025 The PipeCD 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 deployment + +import ( + "cmp" + "context" + "encoding/json" + "fmt" + + "golang.org/x/sync/errgroup" + + sdk "github.com/pipe-cd/piped-plugin-sdk-go" + + kubeconfig "github.com/pipe-cd/pipecd/pkg/app/pipedv1/plugin/kubernetes_multicluster/config" + "github.com/pipe-cd/pipecd/pkg/app/pipedv1/plugin/kubernetes_multicluster/provider" + "github.com/pipe-cd/pipecd/pkg/app/pipedv1/plugin/kubernetes_multicluster/toolregistry" +) + +func (p *Plugin) executeK8sMultiBaselineRolloutStage(ctx context.Context, input *sdk.ExecuteStageInput[kubeconfig.KubernetesApplicationSpec], dts []*sdk.DeployTarget[kubeconfig.KubernetesDeployTargetConfig]) sdk.StageStatus { + lp := input.Client.LogPersister() + + cfg, err := input.Request.TargetDeploymentSource.AppConfig() + if err != nil { + lp.Errorf("Failed while decoding application config (%v)", err.Error()) + return sdk.StageStatusFailure + } + + var stageCfg kubeconfig.K8sBaselineRolloutStageOptions + if len(input.Request.StageConfig) > 0 { + if err := json.Unmarshal(input.Request.StageConfig, &stageCfg); err != nil { + lp.Errorf("Failed while unmarshalling stage config (%v)", err) + return sdk.StageStatusFailure + } + } + + type targetConfig struct { + deployTarget *sdk.DeployTarget[kubeconfig.KubernetesDeployTargetConfig] + multiTarget *kubeconfig.KubernetesMultiTarget + } + + deployTargetMap := make(map[string]*sdk.DeployTarget[kubeconfig.KubernetesDeployTargetConfig], 0) + targetConfigs := make([]targetConfig, 0, len(dts)) + + for _, target := range dts { + deployTargetMap[target.Name] = target + } + + // If no multi-targets are specified, roll out baseline to all deploy targets. + if len(cfg.Spec.Input.MultiTargets) == 0 { + for _, dt := range dts { + targetConfigs = append(targetConfigs, targetConfig{ + deployTarget: dt, + multiTarget: nil, + }) + } + } else { + for _, multiTarget := range cfg.Spec.Input.MultiTargets { + dt, ok := deployTargetMap[multiTarget.Target.Name] + if !ok { + lp.Infof("Ignore multi target '%s': not matched any deployTarget", multiTarget.Target.Name) + continue + } + + targetConfigs = append(targetConfigs, targetConfig{ + deployTarget: dt, + multiTarget: &multiTarget, + }) + } + } + + eg, ctx := errgroup.WithContext(ctx) + for _, tc := range targetConfigs { + eg.Go(func() error { + lp.Infof("Start baseline rollout for target %s", tc.deployTarget.Name) + status := p.baselineRollout(ctx, input, tc.deployTarget, tc.multiTarget, stageCfg) + if status == sdk.StageStatusFailure { + return fmt.Errorf("failed to baseline rollout for target %s", tc.deployTarget.Name) + } + return nil + }) + } + + if err := eg.Wait(); err != nil { + lp.Errorf("Failed while rolling out baseline (%v)", err) + return sdk.StageStatusFailure + } + + return sdk.StageStatusSuccess +} + +func (p *Plugin) baselineRollout( + ctx context.Context, + input *sdk.ExecuteStageInput[kubeconfig.KubernetesApplicationSpec], + dt *sdk.DeployTarget[kubeconfig.KubernetesDeployTargetConfig], + multiTarget *kubeconfig.KubernetesMultiTarget, + stageCfg kubeconfig.K8sBaselineRolloutStageOptions, +) sdk.StageStatus { + lp := input.Client.LogPersister() + + cfg, err := input.Request.TargetDeploymentSource.AppConfig() + if err != nil { + lp.Errorf("Failed while loading application config (%v)", err) + return sdk.StageStatusFailure + } + + var ( + appCfg = cfg.Spec + variantLabel = appCfg.VariantLabel.Key + baselineVariant = appCfg.VariantLabel.BaselineValue + ) + + toolRegistry := toolregistry.NewRegistry(input.Client.ToolRegistry()) + loader := provider.NewLoader(toolRegistry) + + // Baseline uses the RUNNING deployment source (current live version), not the target. + lp.Infof("Loading manifests at commit %s for handling", input.Request.RunningDeploymentSource.CommitHash) + manifests, err := p.loadManifests(ctx, &input.Request.Deployment, cfg.Spec, &input.Request.RunningDeploymentSource, loader, input.Logger, multiTarget) + if err != nil { + lp.Errorf("Failed while loading manifests (%v)", err) + return sdk.StageStatusFailure + } + lp.Successf("Successfully loaded %d manifests", len(manifests)) + + if len(manifests) == 0 { + lp.Error("This application has no Kubernetes manifests to handle") + return sdk.StageStatusFailure + } + + // Because the loaded manifests are read-only + // we duplicate them to avoid updating the shared manifests data in cache. + manifests = provider.DeepCopyManifests(manifests) + + // Find and generate workload & service manifests for BASELINE variant. + baselineManifests, err := generateBaselineManifests(appCfg, manifests, stageCfg, variantLabel, baselineVariant) + if err != nil { + lp.Errorf("Unable to generate manifests for BASELINE variant (%v)", err) + return sdk.StageStatusFailure + } + + addVariantLabelsAndAnnotations(baselineManifests, variantLabel, baselineVariant) + + deployTargetConfig := dt.Config + + // Resolve kubectl version: multiTarget > spec > deployTarget + kubectlVersion := cmp.Or(appCfg.Input.KubectlVersion, deployTargetConfig.KubectlVersion) + if multiTarget != nil { + kubectlVersion = cmp.Or(multiTarget.KubectlVersion, kubectlVersion) + } + + kubectlPath, err := toolRegistry.Kubectl(ctx, kubectlVersion) + if err != nil { + lp.Errorf("Failed while getting kubectl tool (%v)", err) + return sdk.StageStatusFailure + } + + kubectl := provider.NewKubectl(kubectlPath) + applier := provider.NewApplier(kubectl, appCfg.Input, deployTargetConfig, input.Logger) + + lp.Info("Start rolling out BASELINE variant...") + if err := applyManifests(ctx, applier, baselineManifests, appCfg.Input.Namespace, lp); err != nil { + lp.Errorf("Failed while applying baseline manifests (%v)", err) + return sdk.StageStatusFailure + } + + lp.Success("Successfully rolled out BASELINE variant") + return sdk.StageStatusSuccess +} + +func generateBaselineManifests(appCfg *kubeconfig.KubernetesApplicationSpec, manifests []provider.Manifest, opts kubeconfig.K8sBaselineRolloutStageOptions, variantLabel, variant string) ([]provider.Manifest, error) { + suffix := variant + if opts.Suffix != "" { + suffix = opts.Suffix + } + + workloads := findWorkloadManifests(manifests, appCfg.Workloads) + if len(workloads) == 0 { + return nil, fmt.Errorf("unable to find any workload manifests for BASELINE variant") + } + + var baselineManifests []provider.Manifest + + // Find service manifests and duplicate them for BASELINE variant. + if opts.CreateService { + serviceName := appCfg.Service.Name + services := findManifests(provider.KindService, serviceName, manifests) + if len(services) == 0 { + return nil, fmt.Errorf("unable to find any service for name=%q", serviceName) + } + // Duplicate them to avoid updating the shared manifests data in cache. + services = duplicateManifests(services, "") + + generatedServices, err := generateVariantServiceManifests(services, variantLabel, variant, suffix) + if err != nil { + return nil, err + } + baselineManifests = append(baselineManifests, generatedServices...) + } + + // Find config map manifests and duplicate them for BASELINE variant. + configMaps := findConfigMapManifests(manifests) + baselineConfigMaps := duplicateManifests(configMaps, suffix) + baselineManifests = append(baselineManifests, baselineConfigMaps...) + + // Find secret manifests and duplicate them for BASELINE variant. + secrets := findSecretManifests(manifests) + baselineSecrets := duplicateManifests(secrets, suffix) + baselineManifests = append(baselineManifests, baselineSecrets...) + + // Generate new workload manifests for BASELINE variant. + replicasCalculator := func(cur *int32) int32 { + if cur == nil { + return 1 + } + num := opts.Replicas.Calculate(int(*cur), 1) + return int32(num) + } + generatedWorkloads, err := generateVariantWorkloadManifests(workloads, configMaps, secrets, variantLabel, variant, suffix, replicasCalculator) + if err != nil { + return nil, err + } + baselineManifests = append(baselineManifests, generatedWorkloads...) + + return baselineManifests, nil +} diff --git a/pkg/app/pipedv1/plugin/kubernetes_multicluster/deployment/baseline_test.go b/pkg/app/pipedv1/plugin/kubernetes_multicluster/deployment/baseline_test.go new file mode 100644 index 0000000000..5e1f04b86f --- /dev/null +++ b/pkg/app/pipedv1/plugin/kubernetes_multicluster/deployment/baseline_test.go @@ -0,0 +1,343 @@ +// Copyright 2025 The PipeCD 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 deployment + +import ( + "context" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/zap/zaptest" + k8serrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + + sdk "github.com/pipe-cd/piped-plugin-sdk-go" + "github.com/pipe-cd/piped-plugin-sdk-go/logpersister/logpersistertest" + "github.com/pipe-cd/piped-plugin-sdk-go/toolregistry/toolregistrytest" + + kubeconfig "github.com/pipe-cd/pipecd/pkg/app/pipedv1/plugin/kubernetes_multicluster/config" +) + +func TestPlugin_executeK8sMultiBaselineRolloutStage_SingleCluster(t *testing.T) { + t.Parallel() + + ctx := context.Background() + + configDir := filepath.Join("testdata", "baseline_rollout") + appCfg := sdk.LoadApplicationConfigForTest[kubeconfig.KubernetesApplicationSpec](t, filepath.Join(configDir, "app.pipecd.yaml"), "kubernetes_multicluster") + + testRegistry := toolregistrytest.NewTestToolRegistry(t) + + input := &sdk.ExecuteStageInput[kubeconfig.KubernetesApplicationSpec]{ + Request: sdk.ExecuteStageRequest[kubeconfig.KubernetesApplicationSpec]{ + StageName: StageK8sMultiBaselineRollout, + StageConfig: []byte(`{"replicas": "50%", "suffix": "baseline"}`), + RunningDeploymentSource: sdk.DeploymentSource[kubeconfig.KubernetesApplicationSpec]{ + ApplicationDirectory: configDir, + CommitHash: "0123456789", + ApplicationConfig: appCfg, + ApplicationConfigFilename: "app.pipecd.yaml", + }, + TargetDeploymentSource: sdk.DeploymentSource[kubeconfig.KubernetesApplicationSpec]{ + ApplicationDirectory: configDir, + CommitHash: "0123456789", + ApplicationConfig: appCfg, + ApplicationConfigFilename: "app.pipecd.yaml", + }, + Deployment: sdk.Deployment{ + PipedID: "piped-id", + ApplicationID: "app-id", + }, + }, + Client: sdk.NewClient(nil, "kubernetes_multicluster", "app-id", "stage-id", logpersistertest.NewTestLogPersister(t), testRegistry), + Logger: zaptest.NewLogger(t), + } + + dtConfig, dynamicClient := setupTestDeployTargetConfigAndDynamicClient(t) + + plugin := &Plugin{} + status := plugin.executeK8sMultiBaselineRolloutStage(ctx, input, []*sdk.DeployTarget[kubeconfig.KubernetesDeployTargetConfig]{ + {Name: "default", Config: *dtConfig}, + }) + + assert.Equal(t, sdk.StageStatusSuccess, status) + + // The baseline deployment should be created with "-baseline" suffix. + deploymentRes := schema.GroupVersionResource{Group: "apps", Version: "v1", Resource: "deployments"} + deployment, err := dynamicClient.Resource(deploymentRes).Namespace("default").Get(ctx, "simple-baseline", metav1.GetOptions{}) + require.NoError(t, err) + + assert.Equal(t, "simple-baseline", deployment.GetName()) + + // Verify variant label is set to "baseline". + assert.Equal(t, "baseline", deployment.GetLabels()["pipecd.dev/variant"]) + assert.Equal(t, "baseline", deployment.GetAnnotations()["pipecd.dev/variant"]) + + // Verify replica count is 1 (50% of 2 = 1). + spec, ok := deployment.Object["spec"].(map[string]interface{}) + require.True(t, ok) + replicas, ok := spec["replicas"].(int64) + require.True(t, ok) + assert.Equal(t, int64(1), replicas) +} + +func TestPlugin_executeK8sMultiBaselineRolloutStage_MultiCluster(t *testing.T) { + t.Parallel() + + ctx := context.Background() + + configDir := filepath.Join("testdata", "baseline_rollout") + appCfg := sdk.LoadApplicationConfigForTest[kubeconfig.KubernetesApplicationSpec](t, filepath.Join(configDir, "app.pipecd.yaml"), "kubernetes_multicluster") + + testRegistry := toolregistrytest.NewTestToolRegistry(t) + + input := &sdk.ExecuteStageInput[kubeconfig.KubernetesApplicationSpec]{ + Request: sdk.ExecuteStageRequest[kubeconfig.KubernetesApplicationSpec]{ + StageName: StageK8sMultiBaselineRollout, + StageConfig: []byte(`{"replicas": 1, "suffix": "baseline"}`), + RunningDeploymentSource: sdk.DeploymentSource[kubeconfig.KubernetesApplicationSpec]{ + ApplicationDirectory: configDir, + CommitHash: "0123456789", + ApplicationConfig: appCfg, + ApplicationConfigFilename: "app.pipecd.yaml", + }, + TargetDeploymentSource: sdk.DeploymentSource[kubeconfig.KubernetesApplicationSpec]{ + ApplicationDirectory: configDir, + CommitHash: "0123456789", + ApplicationConfig: appCfg, + ApplicationConfigFilename: "app.pipecd.yaml", + }, + Deployment: sdk.Deployment{ + PipedID: "piped-id", + ApplicationID: "app-id", + }, + }, + Client: sdk.NewClient(nil, "kubernetes_multicluster", "app-id", "stage-id", logpersistertest.NewTestLogPersister(t), testRegistry), + Logger: zaptest.NewLogger(t), + } + + cluster1 := setupCluster(t, "cluster1") + cluster2 := setupCluster(t, "cluster2") + + dts := []*sdk.DeployTarget[kubeconfig.KubernetesDeployTargetConfig]{ + {Name: "cluster1", Config: *cluster1.dtc}, + {Name: "cluster2", Config: *cluster2.dtc}, + } + + plugin := &Plugin{} + status := plugin.executeK8sMultiBaselineRolloutStage(ctx, input, dts) + + require.Equal(t, sdk.StageStatusSuccess, status) + + deploymentRes := schema.GroupVersionResource{Group: "apps", Version: "v1", Resource: "deployments"} + + // Both clusters should have a baseline deployment. + for _, cl := range []*cluster{cluster1, cluster2} { + deployment, err := cl.cli.Resource(deploymentRes).Namespace("default").Get(ctx, "simple-baseline", metav1.GetOptions{}) + require.NoError(t, err) + + assert.Equal(t, "simple-baseline", deployment.GetName()) + assert.Equal(t, "baseline", deployment.GetLabels()["pipecd.dev/variant"]) + assert.Equal(t, "piped-id", deployment.GetLabels()["pipecd.dev/piped"]) + assert.Equal(t, "app-id", deployment.GetLabels()["pipecd.dev/application"]) + } +} + +func TestPlugin_executeK8sMultiBaselineRolloutStage_WithCreateService(t *testing.T) { + t.Parallel() + + ctx := context.Background() + + configDir := filepath.Join("testdata", "baseline_rollout_with_create_service") + appCfg := sdk.LoadApplicationConfigForTest[kubeconfig.KubernetesApplicationSpec](t, filepath.Join(configDir, "app.pipecd.yaml"), "kubernetes_multicluster") + + testRegistry := toolregistrytest.NewTestToolRegistry(t) + + input := &sdk.ExecuteStageInput[kubeconfig.KubernetesApplicationSpec]{ + Request: sdk.ExecuteStageRequest[kubeconfig.KubernetesApplicationSpec]{ + StageName: StageK8sMultiBaselineRollout, + StageConfig: []byte(`{"replicas": "50%", "createService": true}`), + RunningDeploymentSource: sdk.DeploymentSource[kubeconfig.KubernetesApplicationSpec]{ + ApplicationDirectory: configDir, + CommitHash: "0123456789", + ApplicationConfig: appCfg, + ApplicationConfigFilename: "app.pipecd.yaml", + }, + TargetDeploymentSource: sdk.DeploymentSource[kubeconfig.KubernetesApplicationSpec]{ + ApplicationDirectory: configDir, + CommitHash: "0123456789", + ApplicationConfig: appCfg, + ApplicationConfigFilename: "app.pipecd.yaml", + }, + Deployment: sdk.Deployment{ + PipedID: "piped-id", + ApplicationID: "app-id", + }, + }, + Client: sdk.NewClient(nil, "kubernetes_multicluster", "app-id", "stage-id", logpersistertest.NewTestLogPersister(t), testRegistry), + Logger: zaptest.NewLogger(t), + } + + dtConfig, dynamicClient := setupTestDeployTargetConfigAndDynamicClient(t) + + plugin := &Plugin{} + status := plugin.executeK8sMultiBaselineRolloutStage(ctx, input, []*sdk.DeployTarget[kubeconfig.KubernetesDeployTargetConfig]{ + {Name: "default", Config: *dtConfig}, + }) + + assert.Equal(t, sdk.StageStatusSuccess, status) + + // Baseline deployment should be created with variant labels. + deploymentRes := schema.GroupVersionResource{Group: "apps", Version: "v1", Resource: "deployments"} + deployment, err := dynamicClient.Resource(deploymentRes).Namespace("default").Get(ctx, "simple-baseline", metav1.GetOptions{}) + require.NoError(t, err) + assert.Equal(t, "simple-baseline", deployment.GetName()) + assert.Equal(t, "simple", deployment.GetLabels()["app"]) + assert.Equal(t, "baseline", deployment.GetLabels()["pipecd.dev/variant"]) + assert.Equal(t, "baseline", deployment.GetAnnotations()["pipecd.dev/variant"]) + assert.Equal(t, "piped-id", deployment.GetLabels()["pipecd.dev/piped"]) + assert.Equal(t, "app-id", deployment.GetLabels()["pipecd.dev/application"]) + + // Baseline service should be created with variant selector added. + serviceRes := schema.GroupVersionResource{Group: "", Version: "v1", Resource: "services"} + service, err := dynamicClient.Resource(serviceRes).Namespace("default").Get(ctx, "simple-baseline", metav1.GetOptions{}) + require.NoError(t, err) + assert.Equal(t, "simple-baseline", service.GetName()) + + selector, found, err := unstructured.NestedStringMap(service.Object, "spec", "selector") + require.NoError(t, err) + require.True(t, found) + assert.Equal(t, map[string]string{"app": "simple", "pipecd.dev/variant": "baseline"}, selector) + + ports, found, err := unstructured.NestedSlice(service.Object, "spec", "ports") + require.NoError(t, err) + require.True(t, found) + require.Len(t, ports, 1) + port := ports[0].(map[string]any) + assert.Equal(t, int64(9085), port["port"]) + assert.Equal(t, int64(9085), port["targetPort"]) +} + +func TestPlugin_executeK8sMultiBaselineRolloutStage_WithoutCreateService(t *testing.T) { + t.Parallel() + + ctx := context.Background() + + configDir := filepath.Join("testdata", "baseline_rollout_without_create_service") + appCfg := sdk.LoadApplicationConfigForTest[kubeconfig.KubernetesApplicationSpec](t, filepath.Join(configDir, "app.pipecd.yaml"), "kubernetes_multicluster") + + testRegistry := toolregistrytest.NewTestToolRegistry(t) + + input := &sdk.ExecuteStageInput[kubeconfig.KubernetesApplicationSpec]{ + Request: sdk.ExecuteStageRequest[kubeconfig.KubernetesApplicationSpec]{ + StageName: StageK8sMultiBaselineRollout, + StageConfig: []byte(`{"replicas": "50%"}`), + RunningDeploymentSource: sdk.DeploymentSource[kubeconfig.KubernetesApplicationSpec]{ + ApplicationDirectory: configDir, + CommitHash: "0123456789", + ApplicationConfig: appCfg, + ApplicationConfigFilename: "app.pipecd.yaml", + }, + TargetDeploymentSource: sdk.DeploymentSource[kubeconfig.KubernetesApplicationSpec]{ + ApplicationDirectory: configDir, + CommitHash: "0123456789", + ApplicationConfig: appCfg, + ApplicationConfigFilename: "app.pipecd.yaml", + }, + Deployment: sdk.Deployment{ + PipedID: "piped-id", + ApplicationID: "app-id", + }, + }, + Client: sdk.NewClient(nil, "kubernetes_multicluster", "app-id", "stage-id", logpersistertest.NewTestLogPersister(t), testRegistry), + Logger: zaptest.NewLogger(t), + } + + dtConfig, dynamicClient := setupTestDeployTargetConfigAndDynamicClient(t) + + plugin := &Plugin{} + status := plugin.executeK8sMultiBaselineRolloutStage(ctx, input, []*sdk.DeployTarget[kubeconfig.KubernetesDeployTargetConfig]{ + {Name: "default", Config: *dtConfig}, + }) + + assert.Equal(t, sdk.StageStatusSuccess, status) + + // Baseline deployment should be created. + deploymentRes := schema.GroupVersionResource{Group: "apps", Version: "v1", Resource: "deployments"} + deployment, err := dynamicClient.Resource(deploymentRes).Namespace("default").Get(ctx, "simple-baseline", metav1.GetOptions{}) + require.NoError(t, err) + assert.Equal(t, "simple-baseline", deployment.GetName()) + assert.Equal(t, "simple", deployment.GetLabels()["app"]) + assert.Equal(t, "baseline", deployment.GetLabels()["pipecd.dev/variant"]) + + // No baseline service should be created when createService is false. + serviceRes := schema.GroupVersionResource{Group: "", Version: "v1", Resource: "services"} + _, err = dynamicClient.Resource(serviceRes).Namespace("default").Get(ctx, "simple-baseline", metav1.GetOptions{}) + require.Error(t, err) + assert.True(t, k8serrors.IsNotFound(err)) +} + +func TestPlugin_executeK8sMultiBaselineRolloutStage_Failure(t *testing.T) { + t.Parallel() + + configDir := filepath.Join("testdata", "baseline_rollout") + appCfg := sdk.LoadApplicationConfigForTest[kubeconfig.KubernetesApplicationSpec](t, filepath.Join(configDir, "app.pipecd.yaml"), "kubernetes_multicluster") + + testRegistry := toolregistrytest.NewTestToolRegistry(t) + + input := &sdk.ExecuteStageInput[kubeconfig.KubernetesApplicationSpec]{ + Request: sdk.ExecuteStageRequest[kubeconfig.KubernetesApplicationSpec]{ + StageName: StageK8sMultiBaselineRollout, + StageConfig: []byte(`{"replicas": 1}`), + RunningDeploymentSource: sdk.DeploymentSource[kubeconfig.KubernetesApplicationSpec]{ + ApplicationDirectory: configDir, + CommitHash: "0123456789", + ApplicationConfig: appCfg, + ApplicationConfigFilename: "app.pipecd.yaml", + }, + TargetDeploymentSource: sdk.DeploymentSource[kubeconfig.KubernetesApplicationSpec]{ + ApplicationDirectory: configDir, + CommitHash: "0123456789", + ApplicationConfig: appCfg, + ApplicationConfigFilename: "app.pipecd.yaml", + }, + Deployment: sdk.Deployment{ + PipedID: "piped-id", + ApplicationID: "app-id", + }, + }, + Client: sdk.NewClient(nil, "kubernetes_multicluster", "app-id", "stage-id", logpersistertest.NewTestLogPersister(t), testRegistry), + Logger: zaptest.NewLogger(t), + } + + // Provide a bad kubeconfig path. + dts := []*sdk.DeployTarget[kubeconfig.KubernetesDeployTargetConfig]{ + { + Name: "bad-cluster", + Config: kubeconfig.KubernetesDeployTargetConfig{ + KubeConfigPath: "/nonexistent/kubeconfig", + }, + }, + } + + plugin := &Plugin{} + status := plugin.executeK8sMultiBaselineRolloutStage(t.Context(), input, dts) + + assert.Equal(t, sdk.StageStatusFailure, status) +} From f472ff9d4d51fe5cdfbe5ef271b32bd11b2647d9 Mon Sep 17 00:00:00 2001 From: Mohammed Firdous <124298708+mohammedfirdouss@users.noreply.github.com> Date: Sat, 21 Mar 2026 10:43:34 +0000 Subject: [PATCH 4/4] feat: add test data for baseline rollout with and without service creation Signed-off-by: Mohammed Firdous <124298708+mohammedfirdouss@users.noreply.github.com> --- .../testdata/baseline_rollout/app.pipecd.yaml | 13 ++++++++++ .../testdata/baseline_rollout/deployment.yaml | 23 ++++++++++++++++++ .../app.pipecd.yaml | 24 +++++++++++++++++++ .../deployment.yaml | 23 ++++++++++++++++++ .../service.yaml | 11 +++++++++ .../app.pipecd.yaml | 21 ++++++++++++++++ .../deployment.yaml | 23 ++++++++++++++++++ .../service.yaml | 11 +++++++++ 8 files changed, 149 insertions(+) create mode 100644 pkg/app/pipedv1/plugin/kubernetes_multicluster/deployment/testdata/baseline_rollout/app.pipecd.yaml create mode 100644 pkg/app/pipedv1/plugin/kubernetes_multicluster/deployment/testdata/baseline_rollout/deployment.yaml create mode 100644 pkg/app/pipedv1/plugin/kubernetes_multicluster/deployment/testdata/baseline_rollout_with_create_service/app.pipecd.yaml create mode 100644 pkg/app/pipedv1/plugin/kubernetes_multicluster/deployment/testdata/baseline_rollout_with_create_service/deployment.yaml create mode 100644 pkg/app/pipedv1/plugin/kubernetes_multicluster/deployment/testdata/baseline_rollout_with_create_service/service.yaml create mode 100644 pkg/app/pipedv1/plugin/kubernetes_multicluster/deployment/testdata/baseline_rollout_without_create_service/app.pipecd.yaml create mode 100644 pkg/app/pipedv1/plugin/kubernetes_multicluster/deployment/testdata/baseline_rollout_without_create_service/deployment.yaml create mode 100644 pkg/app/pipedv1/plugin/kubernetes_multicluster/deployment/testdata/baseline_rollout_without_create_service/service.yaml diff --git a/pkg/app/pipedv1/plugin/kubernetes_multicluster/deployment/testdata/baseline_rollout/app.pipecd.yaml b/pkg/app/pipedv1/plugin/kubernetes_multicluster/deployment/testdata/baseline_rollout/app.pipecd.yaml new file mode 100644 index 0000000000..55b01e7b45 --- /dev/null +++ b/pkg/app/pipedv1/plugin/kubernetes_multicluster/deployment/testdata/baseline_rollout/app.pipecd.yaml @@ -0,0 +1,13 @@ +apiVersion: pipecd.dev/v1beta1 +kind: KubernetesApp +spec: + name: simple + labels: + env: example + team: product + plugins: + kubernetes_multicluster: + input: + manifests: + - deployment.yaml + kubectlVersion: 1.32.2 diff --git a/pkg/app/pipedv1/plugin/kubernetes_multicluster/deployment/testdata/baseline_rollout/deployment.yaml b/pkg/app/pipedv1/plugin/kubernetes_multicluster/deployment/testdata/baseline_rollout/deployment.yaml new file mode 100644 index 0000000000..56f230a95e --- /dev/null +++ b/pkg/app/pipedv1/plugin/kubernetes_multicluster/deployment/testdata/baseline_rollout/deployment.yaml @@ -0,0 +1,23 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: simple + labels: + app: simple +spec: + replicas: 2 + selector: + matchLabels: + app: simple + pipecd.dev/variant: primary + template: + metadata: + labels: + app: simple + pipecd.dev/variant: primary + spec: + containers: + - name: helloworld + image: ghcr.io/pipe-cd/helloworld:v0.32.0 + ports: + - containerPort: 9085 diff --git a/pkg/app/pipedv1/plugin/kubernetes_multicluster/deployment/testdata/baseline_rollout_with_create_service/app.pipecd.yaml b/pkg/app/pipedv1/plugin/kubernetes_multicluster/deployment/testdata/baseline_rollout_with_create_service/app.pipecd.yaml new file mode 100644 index 0000000000..6137ca8d4c --- /dev/null +++ b/pkg/app/pipedv1/plugin/kubernetes_multicluster/deployment/testdata/baseline_rollout_with_create_service/app.pipecd.yaml @@ -0,0 +1,24 @@ +apiVersion: pipecd.dev/v1beta1 +kind: KubernetesApp +spec: + name: baseline-rollout + labels: + env: example + team: product + description: | + This app is test data for baseline rollout with create service. + pipeline: + stages: + - name: K8S_BASELINE_ROLLOUT + with: + replicas: 50% + createService: true + plugins: + kubernetes_multicluster: + input: + manifests: + - deployment.yaml + - service.yaml + kubectlVersion: 1.32.2 + service: + name: simple diff --git a/pkg/app/pipedv1/plugin/kubernetes_multicluster/deployment/testdata/baseline_rollout_with_create_service/deployment.yaml b/pkg/app/pipedv1/plugin/kubernetes_multicluster/deployment/testdata/baseline_rollout_with_create_service/deployment.yaml new file mode 100644 index 0000000000..eb0a683f5c --- /dev/null +++ b/pkg/app/pipedv1/plugin/kubernetes_multicluster/deployment/testdata/baseline_rollout_with_create_service/deployment.yaml @@ -0,0 +1,23 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: simple + labels: + app: simple +spec: + replicas: 2 + selector: + matchLabels: + app: simple + template: + metadata: + labels: + app: simple + spec: + containers: + - name: helloworld + image: ghcr.io/pipe-cd/helloworld:v0.32.0 + args: + - server + ports: + - containerPort: 9085 diff --git a/pkg/app/pipedv1/plugin/kubernetes_multicluster/deployment/testdata/baseline_rollout_with_create_service/service.yaml b/pkg/app/pipedv1/plugin/kubernetes_multicluster/deployment/testdata/baseline_rollout_with_create_service/service.yaml new file mode 100644 index 0000000000..52ca9d1f59 --- /dev/null +++ b/pkg/app/pipedv1/plugin/kubernetes_multicluster/deployment/testdata/baseline_rollout_with_create_service/service.yaml @@ -0,0 +1,11 @@ +apiVersion: v1 +kind: Service +metadata: + name: simple +spec: + selector: + app: simple + ports: + - protocol: TCP + port: 9085 + targetPort: 9085 diff --git a/pkg/app/pipedv1/plugin/kubernetes_multicluster/deployment/testdata/baseline_rollout_without_create_service/app.pipecd.yaml b/pkg/app/pipedv1/plugin/kubernetes_multicluster/deployment/testdata/baseline_rollout_without_create_service/app.pipecd.yaml new file mode 100644 index 0000000000..4a434b5c9b --- /dev/null +++ b/pkg/app/pipedv1/plugin/kubernetes_multicluster/deployment/testdata/baseline_rollout_without_create_service/app.pipecd.yaml @@ -0,0 +1,21 @@ +apiVersion: pipecd.dev/v1beta1 +kind: KubernetesApp +spec: + name: baseline-rollout + labels: + env: example + team: product + description: | + This app is test data for baseline rollout without create service. + pipeline: + stages: + - name: K8S_BASELINE_ROLLOUT + with: + replicas: 50% + plugins: + kubernetes_multicluster: + input: + manifests: + - deployment.yaml + - service.yaml + kubectlVersion: 1.32.2 diff --git a/pkg/app/pipedv1/plugin/kubernetes_multicluster/deployment/testdata/baseline_rollout_without_create_service/deployment.yaml b/pkg/app/pipedv1/plugin/kubernetes_multicluster/deployment/testdata/baseline_rollout_without_create_service/deployment.yaml new file mode 100644 index 0000000000..eb0a683f5c --- /dev/null +++ b/pkg/app/pipedv1/plugin/kubernetes_multicluster/deployment/testdata/baseline_rollout_without_create_service/deployment.yaml @@ -0,0 +1,23 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: simple + labels: + app: simple +spec: + replicas: 2 + selector: + matchLabels: + app: simple + template: + metadata: + labels: + app: simple + spec: + containers: + - name: helloworld + image: ghcr.io/pipe-cd/helloworld:v0.32.0 + args: + - server + ports: + - containerPort: 9085 diff --git a/pkg/app/pipedv1/plugin/kubernetes_multicluster/deployment/testdata/baseline_rollout_without_create_service/service.yaml b/pkg/app/pipedv1/plugin/kubernetes_multicluster/deployment/testdata/baseline_rollout_without_create_service/service.yaml new file mode 100644 index 0000000000..52ca9d1f59 --- /dev/null +++ b/pkg/app/pipedv1/plugin/kubernetes_multicluster/deployment/testdata/baseline_rollout_without_create_service/service.yaml @@ -0,0 +1,11 @@ +apiVersion: v1 +kind: Service +metadata: + name: simple +spec: + selector: + app: simple + ports: + - protocol: TCP + port: 9085 + targetPort: 9085