diff --git a/pkg/app/pipedv1/plugin/kubernetes_multicluster/main.go b/pkg/app/pipedv1/plugin/kubernetes_multicluster/main.go index 04e7d3fc5d..5cd8f8eb0e 100644 --- a/pkg/app/pipedv1/plugin/kubernetes_multicluster/main.go +++ b/pkg/app/pipedv1/plugin/kubernetes_multicluster/main.go @@ -21,6 +21,7 @@ import ( "github.com/pipe-cd/pipecd/pkg/app/pipedv1/plugin/kubernetes_multicluster/deployment" "github.com/pipe-cd/pipecd/pkg/app/pipedv1/plugin/kubernetes_multicluster/livestate" + "github.com/pipe-cd/pipecd/pkg/app/pipedv1/plugin/kubernetes_multicluster/planpreview" ) func main() { @@ -28,6 +29,7 @@ func main() { "0.0.1", sdk.WithDeploymentPlugin(&deployment.Plugin{}), sdk.WithLivestatePlugin(&livestate.Plugin{}), + sdk.WithPlanPreviewPlugin(&planpreview.Plugin{}), ) if err != nil { log.Fatalln(err) diff --git a/pkg/app/pipedv1/plugin/kubernetes_multicluster/planpreview/plugin.go b/pkg/app/pipedv1/plugin/kubernetes_multicluster/planpreview/plugin.go new file mode 100644 index 0000000000..1dfe936a03 --- /dev/null +++ b/pkg/app/pipedv1/plugin/kubernetes_multicluster/planpreview/plugin.go @@ -0,0 +1,203 @@ +// 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 planpreview + +import ( + "context" + "fmt" + + sdk "github.com/pipe-cd/piped-plugin-sdk-go" + "github.com/pipe-cd/piped-plugin-sdk-go/diff" + + 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" +) + +var ( + _ sdk.PlanPreviewPlugin[sdk.ConfigNone, kubeconfig.KubernetesDeployTargetConfig, kubeconfig.KubernetesApplicationSpec] = (*Plugin)(nil) +) + +// Plugin implements the sdk.PlanPreviewPlugin interface for the kubernetes_multicluster plugin. +type Plugin struct{} + +// GetPlanPreview returns the plan preview result showing what will change across all deploy targets. +func (p *Plugin) GetPlanPreview(ctx context.Context, _ *sdk.ConfigNone, dts []*sdk.DeployTarget[kubeconfig.KubernetesDeployTargetConfig], input *sdk.GetPlanPreviewInput[kubeconfig.KubernetesApplicationSpec]) (*sdk.GetPlanPreviewResponse, error) { + toolRegistry := toolregistry.NewRegistry(input.Client.ToolRegistry()) + loader := provider.NewLoader(toolRegistry) + + targetDS := input.Request.TargetDeploymentSource + targetAppCfg, err := targetDS.AppConfig() + if err != nil { + return nil, err + } + targetSpec := targetAppCfg.Spec + + runningDS := input.Request.RunningDeploymentSource + + multiTargets := targetSpec.Input.MultiTargets + + // Single-target fallback: no multiTargets configured — load manifests once and return one result. + if len(multiTargets) == 0 { + newManifests, err := loadManifests(ctx, loader, input, &targetDS, targetSpec, nil) + if err != nil { + return nil, err + } + + var oldManifests []provider.Manifest + if runningDS.CommitHash != "" { + runningAppCfg, err := runningDS.AppConfig() + if err != nil { + return nil, err + } + oldManifests, err = loadManifests(ctx, loader, input, &runningDS, runningAppCfg.Spec, nil) + if err != nil { + return nil, err + } + } + + result, err := provider.DiffList( + oldManifests, + newManifests, + input.Logger, + diff.WithEquateEmpty(), + diff.WithCompareNumberAndNumericString(), + ) + if err != nil { + return nil, err + } + + deployTargetName := "" + if len(dts) > 0 { + deployTargetName = dts[0].Name + } + return &sdk.GetPlanPreviewResponse{ + Results: []sdk.PlanPreviewResult{toResult(result, deployTargetName)}, + }, nil + } + + // Multi-target: produce one PlanPreviewResult per deploy target. + results := make([]sdk.PlanPreviewResult, 0, len(dts)) + for _, dt := range dts { + // Find the matching KubernetesMultiTarget config for this deploy target. + var mt *kubeconfig.KubernetesMultiTarget + for i := range multiTargets { + if multiTargets[i].Target.Name == dt.Name { + mt = &multiTargets[i] + break + } + } + + newManifests, err := loadManifests(ctx, loader, input, &targetDS, targetSpec, mt) + if err != nil { + results = append(results, sdk.PlanPreviewResult{ + DeployTarget: dt.Name, + NoChange: false, + Summary: fmt.Sprintf("Failed to load target manifests: %v", err), + DiffLanguage: "diff", + }) + continue + } + + var oldManifests []provider.Manifest + if runningDS.CommitHash != "" { + runningAppCfg, err := runningDS.AppConfig() + if err != nil { + return nil, err + } + oldManifests, err = loadManifests(ctx, loader, input, &runningDS, runningAppCfg.Spec, mt) + if err != nil { + results = append(results, sdk.PlanPreviewResult{ + DeployTarget: dt.Name, + NoChange: false, + Summary: fmt.Sprintf("Failed to load running manifests: %v", err), + DiffLanguage: "diff", + }) + continue + } + } + + result, err := provider.DiffList( + oldManifests, + newManifests, + input.Logger, + diff.WithEquateEmpty(), + diff.WithCompareNumberAndNumericString(), + ) + if err != nil { + results = append(results, sdk.PlanPreviewResult{ + DeployTarget: dt.Name, + NoChange: false, + Summary: fmt.Sprintf("Failed to diff manifests: %v", err), + DiffLanguage: "diff", + }) + continue + } + + results = append(results, toResult(result, dt.Name)) + } + + return &sdk.GetPlanPreviewResponse{Results: results}, nil +} + +// loadManifests loads manifests from the given deployment source, optionally overriding +// the manifest paths from the multiTarget config. +func loadManifests(ctx context.Context, loader *provider.Loader, input *sdk.GetPlanPreviewInput[kubeconfig.KubernetesApplicationSpec], ds *sdk.DeploymentSource[kubeconfig.KubernetesApplicationSpec], spec *kubeconfig.KubernetesApplicationSpec, mt *kubeconfig.KubernetesMultiTarget) ([]provider.Manifest, error) { + manifestPaths := spec.Input.Manifests + if mt != nil && len(mt.Manifests) > 0 { + manifestPaths = mt.Manifests + } + + return loader.LoadManifests(ctx, provider.LoaderInput{ + PipedID: input.Request.PipedID, + AppID: input.Request.ApplicationID, + CommitHash: ds.CommitHash, + AppName: input.Request.ApplicationName, + AppDir: ds.ApplicationDirectory, + ConfigFilename: ds.ApplicationConfigFilename, + Manifests: manifestPaths, + Namespace: spec.Input.Namespace, + KustomizeVersion: spec.Input.KustomizeVersion, + KustomizeOptions: spec.Input.KustomizeOptions, + HelmVersion: spec.Input.HelmVersion, + HelmChart: spec.Input.HelmChart, + HelmOptions: spec.Input.HelmOptions, + Logger: input.Logger, + }) +} + +// toResult converts a DiffListResult into a PlanPreviewResult for the given deploy target. +func toResult(result *provider.DiffListResult, deployTarget string) sdk.PlanPreviewResult { + if result.NoChanges() { + return sdk.PlanPreviewResult{ + DeployTarget: deployTarget, + NoChange: true, + Summary: "No changes were detected", + DiffLanguage: "diff", + } + } + + details := result.Render(provider.DiffRenderOptions{ + MaskSecret: true, + }) + + return sdk.PlanPreviewResult{ + DeployTarget: deployTarget, + NoChange: false, + Summary: fmt.Sprintf("%d added manifests, %d changed manifests, %d deleted manifests", len(result.Adds), len(result.Changes), len(result.Deletes)), + DiffLanguage: "diff", + Details: []byte(details), + } +} diff --git a/pkg/app/pipedv1/plugin/kubernetes_multicluster/planpreview/plugin_test.go b/pkg/app/pipedv1/plugin/kubernetes_multicluster/planpreview/plugin_test.go new file mode 100644 index 0000000000..9372df6fc2 --- /dev/null +++ b/pkg/app/pipedv1/plugin/kubernetes_multicluster/planpreview/plugin_test.go @@ -0,0 +1,230 @@ +// 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 planpreview + +import ( + "context" + "path/filepath" + "testing" + + 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" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/zap/zaptest" + + kubeconfig "github.com/pipe-cd/pipecd/pkg/app/pipedv1/plugin/kubernetes_multicluster/config" +) + +const ( + pluginName = "kubernetes_multicluster" + runningCommit = "abc000" + targetCommit = "abc123" +) + +func makeDeployTargets(names ...string) []*sdk.DeployTarget[kubeconfig.KubernetesDeployTargetConfig] { + dts := make([]*sdk.DeployTarget[kubeconfig.KubernetesDeployTargetConfig], 0, len(names)) + for _, name := range names { + dts = append(dts, &sdk.DeployTarget[kubeconfig.KubernetesDeployTargetConfig]{Name: name}) + } + return dts +} + +func makeInput( + t *testing.T, + appConfigFile string, + targetAppDir string, + runningAppDir string, +) *sdk.GetPlanPreviewInput[kubeconfig.KubernetesApplicationSpec] { + t.Helper() + + appCfg := sdk.LoadApplicationConfigForTest[kubeconfig.KubernetesApplicationSpec](t, appConfigFile, pluginName) + testRegistry := toolregistrytest.NewTestToolRegistry(t) + + targetDS := sdk.DeploymentSource[kubeconfig.KubernetesApplicationSpec]{ + ApplicationDirectory: targetAppDir, + CommitHash: targetCommit, + ApplicationConfig: appCfg, + ApplicationConfigFilename: "app.pipecd.yaml", + } + + input := &sdk.GetPlanPreviewInput[kubeconfig.KubernetesApplicationSpec]{ + Request: sdk.GetPlanPreviewRequest[kubeconfig.KubernetesApplicationSpec]{ + ApplicationID: "app-id", + ApplicationName: "simple", + TargetDeploymentSource: targetDS, + }, + Client: sdk.NewClient(nil, pluginName, "app-id", "", logpersistertest.NewTestLogPersister(t), testRegistry), + Logger: zaptest.NewLogger(t), + } + + if runningAppDir != "" { + input.Request.RunningDeploymentSource = sdk.DeploymentSource[kubeconfig.KubernetesApplicationSpec]{ + ApplicationDirectory: runningAppDir, + CommitHash: runningCommit, + ApplicationConfig: appCfg, + ApplicationConfigFilename: "app.pipecd.yaml", + } + } + + return input +} + +func TestPlugin_GetPlanPreview_SingleTarget(t *testing.T) { + t.Parallel() + + appCfgFile := filepath.Join("testdata", "single", "app.pipecd.yaml") + runningDir := filepath.Join("testdata", "single", "running") + targetDir := filepath.Join("testdata", "single", "target") + p := &Plugin{} + + tests := []struct { + name string + targetDir string + runningDir string + wantNoChange bool + wantSummary string + wantInDetails []string + }{ + { + name: "first deployment (no running source)", + targetDir: targetDir, + runningDir: "", + wantNoChange: false, + wantSummary: "1 added manifests, 0 changed manifests, 0 deleted manifests", + wantInDetails: []string{ + "simple", + "Deployment", + }, + }, + { + name: "no change (same files)", + targetDir: runningDir, + runningDir: runningDir, + wantNoChange: true, + wantSummary: "No changes were detected", + }, + { + name: "image tag changed", + targetDir: targetDir, + runningDir: runningDir, + wantNoChange: false, + wantSummary: "0 added manifests, 1 changed manifests, 0 deleted manifests", + wantInDetails: []string{ + "v0.1.0", + "v0.2.0", + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + input := makeInput(t, appCfgFile, tc.targetDir, tc.runningDir) + + resp, err := p.GetPlanPreview(context.Background(), nil, makeDeployTargets("default"), input) + require.NoError(t, err) + require.Len(t, resp.Results, 1) + + result := resp.Results[0] + assert.Equal(t, tc.wantNoChange, result.NoChange) + assert.Equal(t, tc.wantSummary, result.Summary) + assert.Equal(t, "diff", result.DiffLanguage) + + for _, s := range tc.wantInDetails { + assert.Contains(t, string(result.Details), s) + } + if tc.wantNoChange { + assert.Nil(t, result.Details) + } + }) + } +} + +func TestPlugin_GetPlanPreview_MultiTarget(t *testing.T) { + t.Parallel() + + appCfgFile := filepath.Join("testdata", "multi", "app.pipecd.yaml") + runningDir := filepath.Join("testdata", "multi", "running") + targetDir := filepath.Join("testdata", "multi", "target") + p := &Plugin{} + dts := makeDeployTargets("cluster1", "cluster2") + + tests := []struct { + name string + checks []struct { + deployTarget string + wantNoChange bool + wantSummary string + wantInDetails []string + } + }{ + { + name: "cluster1 changed, cluster2 unchanged", + checks: []struct { + deployTarget string + wantNoChange bool + wantSummary string + wantInDetails []string + }{ + { + deployTarget: "cluster1", + wantNoChange: false, + wantSummary: "0 added manifests, 1 changed manifests, 0 deleted manifests", + wantInDetails: []string{"v0.1.0", "v0.2.0"}, + }, + { + deployTarget: "cluster2", + wantNoChange: true, + wantSummary: "No changes were detected", + }, + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + input := makeInput(t, appCfgFile, targetDir, runningDir) + + resp, err := p.GetPlanPreview(context.Background(), nil, dts, input) + require.NoError(t, err) + require.Len(t, resp.Results, 2) + + for _, check := range tc.checks { + var result *sdk.PlanPreviewResult + for i := range resp.Results { + if resp.Results[i].DeployTarget == check.deployTarget { + result = &resp.Results[i] + break + } + } + require.NotNil(t, result, "result for deploy target %q not found", check.deployTarget) + + assert.Equal(t, check.wantNoChange, result.NoChange) + assert.Equal(t, check.wantSummary, result.Summary) + assert.Equal(t, "diff", result.DiffLanguage) + + for _, s := range check.wantInDetails { + assert.Contains(t, string(result.Details), s) + } + if check.wantNoChange { + assert.Nil(t, result.Details) + } + } + }) + } +} diff --git a/pkg/app/pipedv1/plugin/kubernetes_multicluster/planpreview/testdata/multi/app.pipecd.yaml b/pkg/app/pipedv1/plugin/kubernetes_multicluster/planpreview/testdata/multi/app.pipecd.yaml new file mode 100644 index 0000000000..1c276ff724 --- /dev/null +++ b/pkg/app/pipedv1/plugin/kubernetes_multicluster/planpreview/testdata/multi/app.pipecd.yaml @@ -0,0 +1,16 @@ +apiVersion: pipecd.dev/v1beta1 +kind: KubernetesApp +spec: + name: simple + plugins: + kubernetes_multicluster: + input: + multiTargets: + - target: + name: cluster1 + manifests: + - cluster1/deployment.yaml + - target: + name: cluster2 + manifests: + - cluster2/deployment.yaml diff --git a/pkg/app/pipedv1/plugin/kubernetes_multicluster/planpreview/testdata/multi/running/cluster1/deployment.yaml b/pkg/app/pipedv1/plugin/kubernetes_multicluster/planpreview/testdata/multi/running/cluster1/deployment.yaml new file mode 100644 index 0000000000..948615b320 --- /dev/null +++ b/pkg/app/pipedv1/plugin/kubernetes_multicluster/planpreview/testdata/multi/running/cluster1/deployment.yaml @@ -0,0 +1,21 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: simple-cluster1 + labels: + app: simple-cluster1 +spec: + replicas: 2 + selector: + matchLabels: + app: simple-cluster1 + template: + metadata: + labels: + app: simple-cluster1 + spec: + containers: + - name: helloworld + image: ghcr.io/pipe-cd/helloworld:v0.1.0 + ports: + - containerPort: 9085 diff --git a/pkg/app/pipedv1/plugin/kubernetes_multicluster/planpreview/testdata/multi/running/cluster2/deployment.yaml b/pkg/app/pipedv1/plugin/kubernetes_multicluster/planpreview/testdata/multi/running/cluster2/deployment.yaml new file mode 100644 index 0000000000..7c4bf2b98a --- /dev/null +++ b/pkg/app/pipedv1/plugin/kubernetes_multicluster/planpreview/testdata/multi/running/cluster2/deployment.yaml @@ -0,0 +1,21 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: simple-cluster2 + labels: + app: simple-cluster2 +spec: + replicas: 2 + selector: + matchLabels: + app: simple-cluster2 + template: + metadata: + labels: + app: simple-cluster2 + spec: + containers: + - name: helloworld + image: ghcr.io/pipe-cd/helloworld:v0.1.0 + ports: + - containerPort: 9085 diff --git a/pkg/app/pipedv1/plugin/kubernetes_multicluster/planpreview/testdata/multi/target/cluster1/deployment.yaml b/pkg/app/pipedv1/plugin/kubernetes_multicluster/planpreview/testdata/multi/target/cluster1/deployment.yaml new file mode 100644 index 0000000000..aec2241e56 --- /dev/null +++ b/pkg/app/pipedv1/plugin/kubernetes_multicluster/planpreview/testdata/multi/target/cluster1/deployment.yaml @@ -0,0 +1,21 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: simple-cluster1 + labels: + app: simple-cluster1 +spec: + replicas: 2 + selector: + matchLabels: + app: simple-cluster1 + template: + metadata: + labels: + app: simple-cluster1 + spec: + containers: + - name: helloworld + image: ghcr.io/pipe-cd/helloworld:v0.2.0 + ports: + - containerPort: 9085 diff --git a/pkg/app/pipedv1/plugin/kubernetes_multicluster/planpreview/testdata/multi/target/cluster2/deployment.yaml b/pkg/app/pipedv1/plugin/kubernetes_multicluster/planpreview/testdata/multi/target/cluster2/deployment.yaml new file mode 100644 index 0000000000..7c4bf2b98a --- /dev/null +++ b/pkg/app/pipedv1/plugin/kubernetes_multicluster/planpreview/testdata/multi/target/cluster2/deployment.yaml @@ -0,0 +1,21 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: simple-cluster2 + labels: + app: simple-cluster2 +spec: + replicas: 2 + selector: + matchLabels: + app: simple-cluster2 + template: + metadata: + labels: + app: simple-cluster2 + spec: + containers: + - name: helloworld + image: ghcr.io/pipe-cd/helloworld:v0.1.0 + ports: + - containerPort: 9085 diff --git a/pkg/app/pipedv1/plugin/kubernetes_multicluster/planpreview/testdata/single/app.pipecd.yaml b/pkg/app/pipedv1/plugin/kubernetes_multicluster/planpreview/testdata/single/app.pipecd.yaml new file mode 100644 index 0000000000..b17a64f39f --- /dev/null +++ b/pkg/app/pipedv1/plugin/kubernetes_multicluster/planpreview/testdata/single/app.pipecd.yaml @@ -0,0 +1,9 @@ +apiVersion: pipecd.dev/v1beta1 +kind: KubernetesApp +spec: + name: simple + plugins: + kubernetes_multicluster: + input: + manifests: + - deployment.yaml diff --git a/pkg/app/pipedv1/plugin/kubernetes_multicluster/planpreview/testdata/single/running/deployment.yaml b/pkg/app/pipedv1/plugin/kubernetes_multicluster/planpreview/testdata/single/running/deployment.yaml new file mode 100644 index 0000000000..6c13dc1cd7 --- /dev/null +++ b/pkg/app/pipedv1/plugin/kubernetes_multicluster/planpreview/testdata/single/running/deployment.yaml @@ -0,0 +1,21 @@ +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.1.0 + ports: + - containerPort: 9085 diff --git a/pkg/app/pipedv1/plugin/kubernetes_multicluster/planpreview/testdata/single/target/deployment.yaml b/pkg/app/pipedv1/plugin/kubernetes_multicluster/planpreview/testdata/single/target/deployment.yaml new file mode 100644 index 0000000000..a55df1dfbb --- /dev/null +++ b/pkg/app/pipedv1/plugin/kubernetes_multicluster/planpreview/testdata/single/target/deployment.yaml @@ -0,0 +1,21 @@ +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.2.0 + ports: + - containerPort: 9085