Skip to content

Commit 120c438

Browse files
authored
[ECS-Plugin]: Implement Plan Preview (#6614)
* feat: implemenet diff calc to compare old and new task definition or service Signed-off-by: Hoang Ngo <adlehoang118@gmail.com> * feat: implement plan preview plugin for ECS Signed-off-by: Hoang Ngo <adlehoang118@gmail.com> --------- Signed-off-by: Hoang Ngo <adlehoang118@gmail.com>
1 parent ff5a1fe commit 120c438

11 files changed

Lines changed: 670 additions & 2 deletions

File tree

pkg/app/pipedv1/plugin/ecs/go.mod

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,9 @@ require (
1212
github.com/go-playground/assert/v2 v2.2.0
1313
github.com/pipe-cd/pipecd v0.54.0-rc1.0.20250912082650-0b949bb7aac9
1414
github.com/pipe-cd/piped-plugin-sdk-go v0.3.0
15+
github.com/pmezard/go-difflib v1.0.0
1516
github.com/stretchr/testify v1.10.0
17+
go.uber.org/zap v1.19.1
1618
golang.org/x/sync v0.18.0
1719
sigs.k8s.io/yaml v1.5.0
1820
)
@@ -50,7 +52,6 @@ require (
5052
github.com/inconshreveable/mousetrap v1.1.0 // indirect
5153
github.com/jmespath/go-jmespath v0.4.0 // indirect
5254
github.com/matttproud/golang_protobuf_extensions v1.0.2-0.20181231171920-c182affec369 // indirect
53-
github.com/pmezard/go-difflib v1.0.0 // indirect
5455
github.com/prometheus/client_golang v1.12.1 // indirect
5556
github.com/prometheus/client_model v0.5.0 // indirect
5657
github.com/prometheus/common v0.32.1 // indirect
@@ -64,7 +65,6 @@ require (
6465
go.opentelemetry.io/otel/trace v1.28.0 // indirect
6566
go.uber.org/atomic v1.11.0 // indirect
6667
go.uber.org/multierr v1.6.0 // indirect
67-
go.uber.org/zap v1.19.1 // indirect
6868
go.yaml.in/yaml/v2 v2.4.2 // indirect
6969
golang.org/x/crypto v0.45.0 // indirect
7070
golang.org/x/net v0.47.0 // indirect

pkg/app/pipedv1/plugin/ecs/main.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,12 +20,14 @@ import (
2020
sdk "github.com/pipe-cd/piped-plugin-sdk-go"
2121

2222
"github.com/pipe-cd/pipecd/pkg/app/pipedv1/plugin/ecs/deployment"
23+
"github.com/pipe-cd/pipecd/pkg/app/pipedv1/plugin/ecs/planpreview"
2324
)
2425

2526
func main() {
2627
plugin, err := sdk.NewPlugin(
2728
"0.0.1",
2829
sdk.WithDeploymentPlugin(&deployment.ECSPlugin{}),
30+
sdk.WithPlanPreviewPlugin(&planpreview.Plugin{}),
2931
)
3032
if err != nil {
3133
log.Fatalf("failed to create plugin: %v", err)
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
// Copyright 2026 The PipeCD Authors.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package planpreview
16+
17+
import (
18+
"fmt"
19+
"strings"
20+
21+
sdk "github.com/pipe-cd/piped-plugin-sdk-go"
22+
"github.com/pmezard/go-difflib/difflib"
23+
"sigs.k8s.io/yaml"
24+
)
25+
26+
// diffDefinitions marshals old and new to YAML and returns a unified diff string
27+
func diffDefinitions[T any](old, new *T, name string) (string, error) {
28+
var oldYAML string
29+
if old != nil {
30+
data, err := yaml.Marshal(old)
31+
if err != nil {
32+
return "", fmt.Errorf("failed to marshal old %s: %w", name, err)
33+
}
34+
oldYAML = string(data)
35+
}
36+
37+
newData, err := yaml.Marshal(new)
38+
if err != nil {
39+
return "", fmt.Errorf("failed to marshal new %s: %w", name, err)
40+
}
41+
42+
ud := difflib.UnifiedDiff{
43+
A: difflib.SplitLines(oldYAML),
44+
B: difflib.SplitLines(string(newData)),
45+
FromFile: fmt.Sprintf("%s (running)", name),
46+
ToFile: fmt.Sprintf("%s (target)", name),
47+
Context: 3,
48+
}
49+
return difflib.GetUnifiedDiffString(ud)
50+
}
51+
52+
func toResponse(deployTarget, taskDefDiff, serviceDiff string) *sdk.GetPlanPreviewResponse {
53+
details := buildDetails(taskDefDiff, serviceDiff)
54+
noChange := taskDefDiff == "" && serviceDiff == ""
55+
56+
var summary string
57+
if noChange {
58+
summary = "No changes were detected"
59+
} else {
60+
summary = buildSummary(taskDefDiff, serviceDiff)
61+
}
62+
63+
return &sdk.GetPlanPreviewResponse{
64+
Results: []sdk.PlanPreviewResult{
65+
{
66+
DeployTarget: deployTarget,
67+
NoChange: noChange,
68+
Summary: summary,
69+
Details: details,
70+
DiffLanguage: "diff",
71+
},
72+
},
73+
}
74+
}
75+
76+
func buildSummary(taskDefDiff, serviceDiff string) string {
77+
var parts []string
78+
if taskDefDiff != "" {
79+
parts = append(parts, "task definition changed")
80+
}
81+
if serviceDiff != "" {
82+
parts = append(parts, "service definition changed")
83+
}
84+
return strings.Join(parts, ", ")
85+
}
86+
87+
func buildDetails(taskDefDiff, serviceDiff string) []byte {
88+
var sb strings.Builder
89+
if taskDefDiff != "" {
90+
sb.WriteString(taskDefDiff)
91+
}
92+
if serviceDiff != "" {
93+
if sb.Len() > 0 {
94+
sb.WriteString("\n")
95+
}
96+
sb.WriteString(serviceDiff)
97+
}
98+
if sb.Len() == 0 {
99+
return nil
100+
}
101+
return []byte(sb.String())
102+
}
Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,231 @@
1+
// Copyright 2026 The PipeCD Authors.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package planpreview
16+
17+
import (
18+
"strings"
19+
"testing"
20+
21+
"github.com/aws/aws-sdk-go-v2/aws"
22+
"github.com/aws/aws-sdk-go-v2/service/ecs/types"
23+
sdk "github.com/pipe-cd/piped-plugin-sdk-go"
24+
"github.com/stretchr/testify/assert"
25+
"github.com/stretchr/testify/require"
26+
)
27+
28+
func TestDiffDefinitions(t *testing.T) {
29+
taskWithImage := func(image string) *types.TaskDefinition {
30+
return &types.TaskDefinition{
31+
Family: aws.String("my-task"),
32+
ContainerDefinitions: []types.ContainerDefinition{
33+
{Name: aws.String("app"), Image: aws.String(image)},
34+
},
35+
}
36+
}
37+
38+
tests := []struct {
39+
name string
40+
old *types.TaskDefinition
41+
new *types.TaskDefinition
42+
wantEmpty bool
43+
wantContains []string
44+
wantNoRemovals bool
45+
}{
46+
{
47+
name: "no change",
48+
old: taskWithImage("nginx:1.0"),
49+
new: taskWithImage("nginx:1.0"),
50+
wantEmpty: true,
51+
},
52+
{
53+
name: "image tag changed",
54+
old: taskWithImage("nginx:1.0"),
55+
new: taskWithImage("nginx:2.0"),
56+
wantContains: []string{
57+
"nginx:1.0",
58+
"nginx:2.0",
59+
"taskdef (running)",
60+
"taskdef (target)",
61+
},
62+
},
63+
{
64+
name: "nil old (first deployment)",
65+
old: nil,
66+
new: &types.TaskDefinition{Family: aws.String("my-task")},
67+
wantNoRemovals: true,
68+
},
69+
}
70+
71+
for _, tc := range tests {
72+
t.Run(tc.name, func(t *testing.T) {
73+
diff, err := diffDefinitions(tc.old, tc.new, "taskdef")
74+
require.NoError(t, err)
75+
76+
if tc.wantEmpty {
77+
assert.Empty(t, diff)
78+
return
79+
}
80+
81+
assert.NotEmpty(t, diff)
82+
83+
for _, s := range tc.wantContains {
84+
assert.Contains(t, diff, s)
85+
}
86+
87+
if tc.wantNoRemovals {
88+
for _, line := range strings.Split(diff, "\n") {
89+
if strings.HasPrefix(line, "---") {
90+
continue
91+
}
92+
assert.False(t, strings.HasPrefix(line, "-"), "unexpected removed line: %q", line)
93+
}
94+
}
95+
96+
t.Logf("diff: %+v", diff)
97+
})
98+
}
99+
}
100+
101+
func TestBuildSummary(t *testing.T) {
102+
tests := []struct {
103+
name string
104+
taskDiff string
105+
serviceDiff string
106+
want string
107+
}{
108+
{
109+
name: "only task def changed",
110+
taskDiff: "some diff",
111+
serviceDiff: "",
112+
want: "task definition changed",
113+
},
114+
{
115+
name: "only service def changed",
116+
taskDiff: "",
117+
serviceDiff: "some diff",
118+
want: "service definition changed",
119+
},
120+
{
121+
name: "both changed",
122+
taskDiff: "some diff",
123+
serviceDiff: "some diff",
124+
want: "task definition changed, service definition changed",
125+
},
126+
}
127+
128+
for _, tc := range tests {
129+
t.Run(tc.name, func(t *testing.T) {
130+
assert.Equal(t, tc.want, buildSummary(tc.taskDiff, tc.serviceDiff))
131+
})
132+
}
133+
}
134+
135+
func TestBuildDetails(t *testing.T) {
136+
tests := []struct {
137+
name string
138+
taskDiff string
139+
serviceDiff string
140+
want []byte
141+
}{
142+
{
143+
name: "no changes",
144+
taskDiff: "",
145+
serviceDiff: "",
146+
want: nil,
147+
},
148+
{
149+
name: "task diff only",
150+
taskDiff: "task-diff\n",
151+
serviceDiff: "",
152+
want: []byte("task-diff\n"),
153+
},
154+
{
155+
name: "service diff only",
156+
taskDiff: "",
157+
serviceDiff: "service-diff\n",
158+
want: []byte("service-diff\n"),
159+
},
160+
{
161+
name: "both diffs combined with separator",
162+
taskDiff: "task-diff\n",
163+
serviceDiff: "service-diff\n",
164+
want: []byte("task-diff\n\nservice-diff\n"),
165+
},
166+
}
167+
168+
for _, tc := range tests {
169+
t.Run(tc.name, func(t *testing.T) {
170+
assert.Equal(t, tc.want, buildDetails(tc.taskDiff, tc.serviceDiff))
171+
})
172+
}
173+
}
174+
175+
func TestToResponse(t *testing.T) {
176+
tests := []struct {
177+
name string
178+
deployTarget string
179+
taskDiff string
180+
serviceDiff string
181+
want sdk.PlanPreviewResult
182+
}{
183+
{
184+
name: "no changes",
185+
deployTarget: "prod",
186+
taskDiff: "",
187+
serviceDiff: "",
188+
want: sdk.PlanPreviewResult{
189+
DeployTarget: "prod",
190+
NoChange: true,
191+
Summary: "No changes were detected",
192+
Details: nil,
193+
DiffLanguage: "diff",
194+
},
195+
},
196+
{
197+
name: "task def changed",
198+
deployTarget: "prod",
199+
taskDiff: "task-diff\n",
200+
serviceDiff: "",
201+
want: sdk.PlanPreviewResult{
202+
DeployTarget: "prod",
203+
NoChange: false,
204+
Summary: "task definition changed",
205+
Details: []byte("task-diff\n"),
206+
DiffLanguage: "diff",
207+
},
208+
},
209+
{
210+
name: "both changed",
211+
deployTarget: "prod",
212+
taskDiff: "task-diff\n",
213+
serviceDiff: "service-diff\n",
214+
want: sdk.PlanPreviewResult{
215+
DeployTarget: "prod",
216+
NoChange: false,
217+
Summary: "task definition changed, service definition changed",
218+
Details: []byte("task-diff\n\nservice-diff\n"),
219+
DiffLanguage: "diff",
220+
},
221+
},
222+
}
223+
224+
for _, tc := range tests {
225+
t.Run(tc.name, func(t *testing.T) {
226+
resp := toResponse(tc.deployTarget, tc.taskDiff, tc.serviceDiff)
227+
require.Len(t, resp.Results, 1)
228+
assert.Equal(t, tc.want, resp.Results[0])
229+
})
230+
}
231+
}

0 commit comments

Comments
 (0)