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
4 changes: 2 additions & 2 deletions pkg/app/pipedv1/plugin/ecs/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@ require (
github.com/go-playground/assert/v2 v2.2.0
github.com/pipe-cd/pipecd v0.54.0-rc1.0.20250912082650-0b949bb7aac9
github.com/pipe-cd/piped-plugin-sdk-go v0.3.0
github.com/pmezard/go-difflib v1.0.0
github.com/stretchr/testify v1.10.0
go.uber.org/zap v1.19.1
golang.org/x/sync v0.18.0
sigs.k8s.io/yaml v1.5.0
)
Expand Down Expand Up @@ -50,7 +52,6 @@ require (
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jmespath/go-jmespath v0.4.0 // indirect
github.com/matttproud/golang_protobuf_extensions v1.0.2-0.20181231171920-c182affec369 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/prometheus/client_golang v1.12.1 // indirect
github.com/prometheus/client_model v0.5.0 // indirect
github.com/prometheus/common v0.32.1 // indirect
Expand All @@ -64,7 +65,6 @@ require (
go.opentelemetry.io/otel/trace v1.28.0 // indirect
go.uber.org/atomic v1.11.0 // indirect
go.uber.org/multierr v1.6.0 // indirect
go.uber.org/zap v1.19.1 // indirect
go.yaml.in/yaml/v2 v2.4.2 // indirect
golang.org/x/crypto v0.45.0 // indirect
golang.org/x/net v0.47.0 // indirect
Expand Down
2 changes: 2 additions & 0 deletions pkg/app/pipedv1/plugin/ecs/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,14 @@ import (
sdk "github.com/pipe-cd/piped-plugin-sdk-go"

"github.com/pipe-cd/pipecd/pkg/app/pipedv1/plugin/ecs/deployment"
"github.com/pipe-cd/pipecd/pkg/app/pipedv1/plugin/ecs/planpreview"
)

func main() {
plugin, err := sdk.NewPlugin(
"0.0.1",
sdk.WithDeploymentPlugin(&deployment.ECSPlugin{}),
sdk.WithPlanPreviewPlugin(&planpreview.Plugin{}),
)
if err != nil {
log.Fatalf("failed to create plugin: %v", err)
Expand Down
102 changes: 102 additions & 0 deletions pkg/app/pipedv1/plugin/ecs/planpreview/diff.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
// Copyright 2026 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 (
"fmt"
"strings"

sdk "github.com/pipe-cd/piped-plugin-sdk-go"
"github.com/pmezard/go-difflib/difflib"
"sigs.k8s.io/yaml"
)

// diffDefinitions marshals old and new to YAML and returns a unified diff string
func diffDefinitions[T any](old, new *T, name string) (string, error) {
var oldYAML string
if old != nil {
data, err := yaml.Marshal(old)
if err != nil {
return "", fmt.Errorf("failed to marshal old %s: %w", name, err)
}
oldYAML = string(data)
}

newData, err := yaml.Marshal(new)
if err != nil {
return "", fmt.Errorf("failed to marshal new %s: %w", name, err)
}

ud := difflib.UnifiedDiff{
A: difflib.SplitLines(oldYAML),
B: difflib.SplitLines(string(newData)),
FromFile: fmt.Sprintf("%s (running)", name),
ToFile: fmt.Sprintf("%s (target)", name),
Context: 3,
}
return difflib.GetUnifiedDiffString(ud)
}

func toResponse(deployTarget, taskDefDiff, serviceDiff string) *sdk.GetPlanPreviewResponse {
details := buildDetails(taskDefDiff, serviceDiff)
noChange := taskDefDiff == "" && serviceDiff == ""

var summary string
if noChange {
summary = "No changes were detected"
} else {
summary = buildSummary(taskDefDiff, serviceDiff)
}

return &sdk.GetPlanPreviewResponse{
Results: []sdk.PlanPreviewResult{
{
DeployTarget: deployTarget,
NoChange: noChange,
Summary: summary,
Details: details,
DiffLanguage: "diff",
},
},
}
}

func buildSummary(taskDefDiff, serviceDiff string) string {
var parts []string
if taskDefDiff != "" {
parts = append(parts, "task definition changed")
}
if serviceDiff != "" {
parts = append(parts, "service definition changed")
}
return strings.Join(parts, ", ")
}

func buildDetails(taskDefDiff, serviceDiff string) []byte {
var sb strings.Builder
if taskDefDiff != "" {
sb.WriteString(taskDefDiff)
}
if serviceDiff != "" {
if sb.Len() > 0 {
sb.WriteString("\n")
}
sb.WriteString(serviceDiff)
}
if sb.Len() == 0 {
return nil
}
return []byte(sb.String())
}
231 changes: 231 additions & 0 deletions pkg/app/pipedv1/plugin/ecs/planpreview/diff_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,231 @@
// Copyright 2026 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 (
"strings"
"testing"

"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/service/ecs/types"
sdk "github.com/pipe-cd/piped-plugin-sdk-go"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestDiffDefinitions(t *testing.T) {
taskWithImage := func(image string) *types.TaskDefinition {
return &types.TaskDefinition{
Family: aws.String("my-task"),
ContainerDefinitions: []types.ContainerDefinition{
{Name: aws.String("app"), Image: aws.String(image)},
},
}
}

tests := []struct {
name string
old *types.TaskDefinition
new *types.TaskDefinition
wantEmpty bool
wantContains []string
wantNoRemovals bool
}{
{
name: "no change",
old: taskWithImage("nginx:1.0"),
new: taskWithImage("nginx:1.0"),
wantEmpty: true,
},
{
name: "image tag changed",
old: taskWithImage("nginx:1.0"),
new: taskWithImage("nginx:2.0"),
wantContains: []string{
"nginx:1.0",
"nginx:2.0",
"taskdef (running)",
"taskdef (target)",
},
},
{
name: "nil old (first deployment)",
old: nil,
new: &types.TaskDefinition{Family: aws.String("my-task")},
wantNoRemovals: true,
},
}

for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
diff, err := diffDefinitions(tc.old, tc.new, "taskdef")
require.NoError(t, err)

if tc.wantEmpty {
assert.Empty(t, diff)
return
}

assert.NotEmpty(t, diff)

for _, s := range tc.wantContains {
assert.Contains(t, diff, s)
}

if tc.wantNoRemovals {
for _, line := range strings.Split(diff, "\n") {
if strings.HasPrefix(line, "---") {
continue
}
assert.False(t, strings.HasPrefix(line, "-"), "unexpected removed line: %q", line)
}
}

t.Logf("diff: %+v", diff)
})
}
}

func TestBuildSummary(t *testing.T) {
tests := []struct {
name string
taskDiff string
serviceDiff string
want string
}{
{
name: "only task def changed",
taskDiff: "some diff",
serviceDiff: "",
want: "task definition changed",
},
{
name: "only service def changed",
taskDiff: "",
serviceDiff: "some diff",
want: "service definition changed",
},
{
name: "both changed",
taskDiff: "some diff",
serviceDiff: "some diff",
want: "task definition changed, service definition changed",
},
}

for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
assert.Equal(t, tc.want, buildSummary(tc.taskDiff, tc.serviceDiff))
})
}
}

func TestBuildDetails(t *testing.T) {
tests := []struct {
name string
taskDiff string
serviceDiff string
want []byte
}{
{
name: "no changes",
taskDiff: "",
serviceDiff: "",
want: nil,
},
{
name: "task diff only",
taskDiff: "task-diff\n",
serviceDiff: "",
want: []byte("task-diff\n"),
},
{
name: "service diff only",
taskDiff: "",
serviceDiff: "service-diff\n",
want: []byte("service-diff\n"),
},
{
name: "both diffs combined with separator",
taskDiff: "task-diff\n",
serviceDiff: "service-diff\n",
want: []byte("task-diff\n\nservice-diff\n"),
},
}

for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
assert.Equal(t, tc.want, buildDetails(tc.taskDiff, tc.serviceDiff))
})
}
}

func TestToResponse(t *testing.T) {
tests := []struct {
name string
deployTarget string
taskDiff string
serviceDiff string
want sdk.PlanPreviewResult
}{
{
name: "no changes",
deployTarget: "prod",
taskDiff: "",
serviceDiff: "",
want: sdk.PlanPreviewResult{
DeployTarget: "prod",
NoChange: true,
Summary: "No changes were detected",
Details: nil,
DiffLanguage: "diff",
},
},
{
name: "task def changed",
deployTarget: "prod",
taskDiff: "task-diff\n",
serviceDiff: "",
want: sdk.PlanPreviewResult{
DeployTarget: "prod",
NoChange: false,
Summary: "task definition changed",
Details: []byte("task-diff\n"),
DiffLanguage: "diff",
},
},
{
name: "both changed",
deployTarget: "prod",
taskDiff: "task-diff\n",
serviceDiff: "service-diff\n",
want: sdk.PlanPreviewResult{
DeployTarget: "prod",
NoChange: false,
Summary: "task definition changed, service definition changed",
Details: []byte("task-diff\n\nservice-diff\n"),
DiffLanguage: "diff",
},
},
}

for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
resp := toResponse(tc.deployTarget, tc.taskDiff, tc.serviceDiff)
require.Len(t, resp.Results, 1)
assert.Equal(t, tc.want, resp.Results[0])
})
}
}
Loading
Loading