Skip to content

Commit 2820bd7

Browse files
committed
feat: implement plan preview plugin for ECS
Signed-off-by: Hoang Ngo <adlehoang118@gmail.com>
1 parent 3553850 commit 2820bd7

9 files changed

Lines changed: 339 additions & 3 deletions

File tree

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,13 @@ require (
77
github.com/aws/aws-sdk-go-v2/config v1.27.38
88
github.com/aws/aws-sdk-go-v2/credentials v1.17.36
99
github.com/aws/aws-sdk-go-v2/service/ecs v1.46.2
10+
github.com/creasty/defaults v1.6.0
1011
github.com/go-playground/assert/v2 v2.2.0
1112
github.com/pipe-cd/pipecd v0.54.0-rc1.0.20250912082650-0b949bb7aac9
1213
github.com/pipe-cd/piped-plugin-sdk-go v0.3.0
14+
github.com/pmezard/go-difflib v1.0.0
1315
github.com/stretchr/testify v1.10.0
16+
go.uber.org/zap v1.19.1
1417
golang.org/x/sync v0.18.0
1518
sigs.k8s.io/yaml v1.5.0
1619
)
@@ -32,7 +35,6 @@ require (
3235
github.com/beorn7/perks v1.0.1 // indirect
3336
github.com/cespare/xxhash/v2 v2.2.0 // indirect
3437
github.com/coreos/go-oidc/v3 v3.11.0 // indirect
35-
github.com/creasty/defaults v1.6.0 // indirect
3638
github.com/davecgh/go-spew v1.1.1 // indirect
3739
github.com/envoyproxy/protoc-gen-validate v1.0.4 // indirect
3840
github.com/go-jose/go-jose/v4 v4.0.5 // indirect
@@ -49,7 +51,6 @@ require (
4951
github.com/inconshreveable/mousetrap v1.1.0 // indirect
5052
github.com/jmespath/go-jmespath v0.4.0 // indirect
5153
github.com/matttproud/golang_protobuf_extensions v1.0.2-0.20181231171920-c182affec369 // indirect
52-
github.com/pmezard/go-difflib v1.0.0 // indirect
5354
github.com/prometheus/client_golang v1.12.1 // indirect
5455
github.com/prometheus/client_model v0.5.0 // indirect
5556
github.com/prometheus/common v0.32.1 // indirect
@@ -63,7 +64,6 @@ require (
6364
go.opentelemetry.io/otel/trace v1.28.0 // indirect
6465
go.uber.org/atomic v1.11.0 // indirect
6566
go.uber.org/multierr v1.6.0 // indirect
66-
go.uber.org/zap v1.19.1 // indirect
6767
go.yaml.in/yaml/v2 v2.4.2 // indirect
6868
golang.org/x/crypto v0.45.0 // indirect
6969
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: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
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+
"context"
19+
"fmt"
20+
"os"
21+
"path/filepath"
22+
23+
"github.com/aws/aws-sdk-go-v2/service/ecs/types"
24+
sdk "github.com/pipe-cd/piped-plugin-sdk-go"
25+
"sigs.k8s.io/yaml"
26+
27+
"github.com/pipe-cd/pipecd/pkg/app/pipedv1/plugin/ecs/config"
28+
)
29+
30+
var (
31+
_ sdk.PlanPreviewPlugin[config.ECSPluginConfig, config.ECSDeployTargetConfig, config.ECSApplicationSpec] = (*Plugin)(nil)
32+
)
33+
34+
// Plugin implements the PlanPreview feature for the ECS plugin
35+
type Plugin struct{}
36+
37+
func (p *Plugin) GetPlanPreview(
38+
ctx context.Context,
39+
_ *config.ECSPluginConfig,
40+
dts []*sdk.DeployTarget[config.ECSDeployTargetConfig],
41+
input *sdk.GetPlanPreviewInput[config.ECSApplicationSpec],
42+
) (*sdk.GetPlanPreviewResponse, error) {
43+
targetDS := input.Request.TargetDeploymentSource
44+
targetAppCfg, err := targetDS.AppConfig()
45+
if err != nil {
46+
return nil, fmt.Errorf("failed to load target app config: %w", err)
47+
}
48+
targetInput := targetAppCfg.Spec.Input
49+
50+
targetTaskDef, err := loadTaskDef(targetDS.ApplicationDirectory, targetInput.TaskDefinitionFile)
51+
if err != nil {
52+
return nil, fmt.Errorf("failed to load target task definition: %w", err)
53+
}
54+
55+
runningDS := input.Request.RunningDeploymentSource
56+
var runningTaskDef *types.TaskDefinition
57+
58+
if runningDS.CommitHash != "" {
59+
runningAppCfg, err := runningDS.AppConfig()
60+
if err != nil {
61+
return nil, fmt.Errorf("failed to load running app config: %w", err)
62+
}
63+
td, err := loadTaskDef(runningDS.ApplicationDirectory, runningAppCfg.Spec.Input.TaskDefinitionFile)
64+
if err != nil {
65+
return nil, fmt.Errorf("failed to load running task definition: %w", err)
66+
}
67+
runningTaskDef = &td
68+
}
69+
70+
taskDefDiff, err := diffDefinitions(runningTaskDef, &targetTaskDef, "taskdef")
71+
if err != nil {
72+
return nil, fmt.Errorf("failed to diff task definitions: %w", err)
73+
}
74+
75+
var serviceDiff string
76+
if targetInput.ServiceDefinitionFile != "" {
77+
targetServiceDef, err := loadServiceDef(targetDS.ApplicationDirectory, targetInput.ServiceDefinitionFile)
78+
if err != nil {
79+
return nil, fmt.Errorf("failed to load target service definition: %w", err)
80+
}
81+
82+
var runningServiceDef *types.Service
83+
if runningDS.CommitHash != "" {
84+
runningAppCfg, err := runningDS.AppConfig()
85+
if err != nil {
86+
return nil, fmt.Errorf("failed to load running app config: %w", err)
87+
}
88+
if runningAppCfg.Spec.Input.ServiceDefinitionFile != "" {
89+
sd, err := loadServiceDef(runningDS.ApplicationDirectory, runningAppCfg.Spec.Input.ServiceDefinitionFile)
90+
if err != nil {
91+
return nil, fmt.Errorf("failed to load running service definition: %w", err)
92+
}
93+
runningServiceDef = &sd
94+
}
95+
}
96+
97+
serviceDiff, err = diffDefinitions(runningServiceDef, &targetServiceDef, "servicedef")
98+
if err != nil {
99+
return nil, fmt.Errorf("failed to diff service definitions: %w", err)
100+
}
101+
}
102+
103+
return toResponse(dts[0].Name, taskDefDiff, serviceDiff), nil
104+
}
105+
106+
func loadTaskDef(appDir, filename string) (types.TaskDefinition, error) {
107+
data, err := os.ReadFile(filepath.Join(appDir, filename))
108+
if err != nil {
109+
return types.TaskDefinition{}, err
110+
}
111+
var obj types.TaskDefinition
112+
if err := yaml.Unmarshal(data, &obj); err != nil {
113+
return types.TaskDefinition{}, err
114+
}
115+
return obj, nil
116+
}
117+
118+
func loadServiceDef(appDir, filename string) (types.Service, error) {
119+
data, err := os.ReadFile(filepath.Join(appDir, filename))
120+
if err != nil {
121+
return types.Service{}, err
122+
}
123+
var obj types.Service
124+
if err := yaml.Unmarshal(data, &obj); err != nil {
125+
return types.Service{}, err
126+
}
127+
return obj, nil
128+
}
129+
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
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+
"context"
19+
"testing"
20+
21+
sdk "github.com/pipe-cd/piped-plugin-sdk-go"
22+
"github.com/stretchr/testify/assert"
23+
"github.com/stretchr/testify/require"
24+
"go.uber.org/zap/zaptest"
25+
26+
"github.com/pipe-cd/pipecd/pkg/app/pipedv1/plugin/ecs/config"
27+
)
28+
29+
const (
30+
appConfigFile = "testdata/app.pipecd.yaml"
31+
pluginName = "ecs"
32+
deployTarget = "ecs-dev"
33+
runningDir = "testdata/running"
34+
targetDir = "testdata/target"
35+
runningCommit = "abc000"
36+
targetCommit = "abc123"
37+
)
38+
39+
func deployTargets(t *testing.T) []*sdk.DeployTarget[config.ECSDeployTargetConfig] {
40+
t.Helper()
41+
return []*sdk.DeployTarget[config.ECSDeployTargetConfig]{
42+
{Name: deployTarget},
43+
}
44+
}
45+
46+
func makeInput(
47+
t *testing.T,
48+
targetAppDir string,
49+
runningAppDir string, // empty string means first deployment
50+
) *sdk.GetPlanPreviewInput[config.ECSApplicationSpec] {
51+
t.Helper()
52+
53+
appCfg := sdk.LoadApplicationConfigForTest[config.ECSApplicationSpec](t, appConfigFile, pluginName)
54+
55+
targetDS := sdk.DeploymentSource[config.ECSApplicationSpec]{
56+
ApplicationDirectory: targetAppDir,
57+
CommitHash: targetCommit,
58+
ApplicationConfig: appCfg,
59+
}
60+
61+
input := &sdk.GetPlanPreviewInput[config.ECSApplicationSpec]{
62+
Request: sdk.GetPlanPreviewRequest[config.ECSApplicationSpec]{
63+
ApplicationID: "app-id",
64+
ApplicationName: "simple-app",
65+
DeployTargets: []string{deployTarget},
66+
TargetDeploymentSource: targetDS,
67+
},
68+
Logger: zaptest.NewLogger(t),
69+
}
70+
71+
if runningAppDir != "" {
72+
input.Request.RunningDeploymentSource = sdk.DeploymentSource[config.ECSApplicationSpec]{
73+
ApplicationDirectory: runningAppDir,
74+
CommitHash: runningCommit,
75+
ApplicationConfig: appCfg,
76+
}
77+
}
78+
79+
return input
80+
}
81+
82+
func TestPlugin_GetPlanPreview(t *testing.T) {
83+
p := &Plugin{}
84+
85+
tests := []struct {
86+
name string
87+
targetDir string
88+
runningDir string
89+
wantNoChange bool
90+
wantSummary string
91+
wantInDetails []string
92+
}{
93+
{
94+
name: "first deployment (no running source)",
95+
targetDir: targetDir,
96+
runningDir: "",
97+
wantNoChange: false,
98+
wantSummary: "task definition changed, service definition changed",
99+
wantInDetails: []string{
100+
"nginx:2.0",
101+
},
102+
},
103+
{
104+
name: "no change (same files)",
105+
targetDir: runningDir,
106+
runningDir: runningDir,
107+
wantNoChange: true,
108+
wantSummary: "No changes were detected",
109+
},
110+
{
111+
name: "task definition changed",
112+
targetDir: targetDir,
113+
runningDir: runningDir,
114+
wantNoChange: false,
115+
wantSummary: "task definition changed",
116+
wantInDetails: []string{
117+
"-",
118+
"+",
119+
"nginx:1.0",
120+
"nginx:2.0",
121+
},
122+
},
123+
}
124+
125+
for _, tc := range tests {
126+
t.Run(tc.name, func(t *testing.T) {
127+
input := makeInput(t, tc.targetDir, tc.runningDir)
128+
129+
resp, err := p.GetPlanPreview(context.Background(), nil, deployTargets(t), input)
130+
require.NoError(t, err)
131+
require.Len(t, resp.Results, 1)
132+
133+
result := resp.Results[0]
134+
assert.Equal(t, deployTarget, result.DeployTarget)
135+
assert.Equal(t, tc.wantNoChange, result.NoChange)
136+
assert.Equal(t, tc.wantSummary, result.Summary)
137+
assert.Equal(t, "diff", result.DiffLanguage)
138+
139+
for _, s := range tc.wantInDetails {
140+
assert.Contains(t, string(result.Details), s)
141+
}
142+
143+
if tc.wantNoChange {
144+
assert.Nil(t, result.Details)
145+
}
146+
})
147+
}
148+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
apiVersion: pipecd.dev/v1beta1
2+
kind: Application
3+
spec:
4+
name: simple-app
5+
plugin: ecs
6+
deployTarget: ecs-dev
7+
plugins:
8+
ecs:
9+
input:
10+
serviceDefinitionFile: servicedef.yaml
11+
taskDefinitionFile: taskdef.yaml
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
cluster: arn:aws:ecs:us-east-1:123456789012:cluster/pipecd-test
2+
serviceName: pipecd-ecs-test
3+
desiredCount: 2
4+
deploymentController:
5+
type: EXTERNAL
6+
launchType: FARGATE
7+
networkConfiguration:
8+
awsvpcConfiguration:
9+
assignPublicIp: ENABLED
10+
subnets:
11+
- subnet-aaa111
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
family: pipecd-ecs-test
2+
executionRoleArn: arn:aws:iam::123456789012:role/ECSTaskExecutionRole
3+
containerDefinitions:
4+
- name: web
5+
image: nginx:1.0
6+
cpu: 256
7+
memory: 512
8+
requiresCompatibilities:
9+
- FARGATE
10+
networkMode: awsvpc
11+
cpu: "256"
12+
memory: "512"
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
cluster: arn:aws:ecs:us-east-1:123456789012:cluster/pipecd-test
2+
serviceName: pipecd-ecs-test
3+
desiredCount: 2
4+
deploymentController:
5+
type: EXTERNAL
6+
launchType: FARGATE
7+
networkConfiguration:
8+
awsvpcConfiguration:
9+
assignPublicIp: ENABLED
10+
subnets:
11+
- subnet-aaa111
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
family: pipecd-ecs-test
2+
executionRoleArn: arn:aws:iam::123456789012:role/ECSTaskExecutionRole
3+
containerDefinitions:
4+
- name: web
5+
image: nginx:2.0
6+
cpu: 256
7+
memory: 512
8+
requiresCompatibilities:
9+
- FARGATE
10+
networkMode: awsvpc
11+
cpu: "256"
12+
memory: "512"

0 commit comments

Comments
 (0)