Skip to content

Commit 0130b1f

Browse files
authored
[ECS-Plugin]: Implement ECS_TRAFFIC_ROUTING stage (#6613)
* feat: implement necessary methods for interacting with elb resources Signed-off-by: Hoang Ngo <adlehoang118@gmail.com> * feat: implement traffic routing stage Signed-off-by: Hoang Ngo <adlehoang118@gmail.com> * restore elb listener weights in rollback stage Signed-off-by: Hoang Ngo <adlehoang118@gmail.com> * test: config percentages Signed-off-by: Hoang Ngo <adlehoang118@gmail.com> --------- Signed-off-by: Hoang Ngo <adlehoang118@gmail.com>
1 parent 72bc2e1 commit 0130b1f

14 files changed

Lines changed: 1069 additions & 3 deletions

File tree

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
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 config
16+
17+
// ECSTrafficRoutingStageOptions contains all configurable values for an ECS_TRAFFIC_ROUTING stage.
18+
type ECSTrafficRoutingStageOptions struct {
19+
// Canary represents the percentage of traffic to route to the canary variant.
20+
// If set, primary will be 100 - canary.
21+
Canary int `json:"canary,omitempty"`
22+
// Primary represents the percentage of traffic to route to the primary variant.
23+
// If set, canary will be 100 - primary.
24+
Primary int `json:"primary,omitempty"`
25+
}
26+
27+
// Percentages returns the traffic split between primary and canary.
28+
// If neither is set, primary gets 100% by default.
29+
func (opts ECSTrafficRoutingStageOptions) Percentages() (primary, canary int) {
30+
if opts.Primary > 0 && opts.Primary <= 100 {
31+
return opts.Primary, 100 - opts.Primary
32+
}
33+
if opts.Canary > 0 && opts.Canary <= 100 {
34+
return 100 - opts.Canary, opts.Canary
35+
}
36+
return 100, 0
37+
}
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
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 config
16+
17+
import "testing"
18+
19+
func TestPercentages(t *testing.T) {
20+
tests := []struct {
21+
name string
22+
opts ECSTrafficRoutingStageOptions
23+
wantPrimary int
24+
wantCanary int
25+
}{
26+
{
27+
name: "default: neither set returns 100/0",
28+
opts: ECSTrafficRoutingStageOptions{},
29+
wantPrimary: 100,
30+
wantCanary: 0,
31+
},
32+
{
33+
name: "primary set to 80",
34+
opts: ECSTrafficRoutingStageOptions{Primary: 80},
35+
wantPrimary: 80,
36+
wantCanary: 20,
37+
},
38+
{
39+
name: "canary set to 30",
40+
opts: ECSTrafficRoutingStageOptions{Canary: 30},
41+
wantPrimary: 70,
42+
wantCanary: 30,
43+
},
44+
{
45+
name: "primary set to 100",
46+
opts: ECSTrafficRoutingStageOptions{Primary: 100},
47+
wantPrimary: 100,
48+
wantCanary: 0,
49+
},
50+
{
51+
name: "canary set to 100",
52+
opts: ECSTrafficRoutingStageOptions{Canary: 100},
53+
wantPrimary: 0,
54+
wantCanary: 100,
55+
},
56+
{
57+
name: "primary takes precedence when both set",
58+
opts: ECSTrafficRoutingStageOptions{Primary: 60, Canary: 30},
59+
wantPrimary: 60,
60+
wantCanary: 40,
61+
},
62+
{
63+
name: "primary out of range (0) falls through to canary",
64+
opts: ECSTrafficRoutingStageOptions{Primary: 0, Canary: 40},
65+
wantPrimary: 60,
66+
wantCanary: 40,
67+
},
68+
{
69+
name: "primary out of range (>100) falls through to canary",
70+
opts: ECSTrafficRoutingStageOptions{Primary: 101, Canary: 40},
71+
wantPrimary: 60,
72+
wantCanary: 40,
73+
},
74+
{
75+
name: "canary out of range (0) returns default",
76+
opts: ECSTrafficRoutingStageOptions{Canary: 0},
77+
wantPrimary: 100,
78+
wantCanary: 0,
79+
},
80+
{
81+
name: "canary out of range (>100) returns default",
82+
opts: ECSTrafficRoutingStageOptions{Canary: 101},
83+
wantPrimary: 100,
84+
wantCanary: 0,
85+
},
86+
}
87+
88+
for _, tt := range tests {
89+
t.Run(tt.name, func(t *testing.T) {
90+
gotPrimary, gotCanary := tt.opts.Percentages()
91+
if gotPrimary != tt.wantPrimary || gotCanary != tt.wantCanary {
92+
t.Errorf("Percentages() = (%d, %d), want (%d, %d)", gotPrimary, gotCanary, tt.wantPrimary, tt.wantCanary)
93+
}
94+
})
95+
}
96+
}

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,10 @@ func (p *ECSPlugin) ExecuteStage(
8181
return &sdk.ExecuteStageResponse{
8282
Status: p.executeECSCanaryRolloutStage(ctx, input, deployTargets[0]),
8383
}, nil
84+
case StageECSTrafficRouting:
85+
return &sdk.ExecuteStageResponse{
86+
Status: p.executeECSTrafficRouting(ctx, input, deployTargets[0]),
87+
}, nil
8488
default:
8589
return nil, ErrUnsupportedStage
8690
}

pkg/app/pipedv1/plugin/ecs/deployment/rollback.go

Lines changed: 52 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ package deployment
1717
import (
1818
"context"
1919
"fmt"
20+
"strings"
2021

2122
"github.com/aws/aws-sdk-go-v2/service/ecs/types"
2223
sdk "github.com/pipe-cd/piped-plugin-sdk-go"
@@ -81,6 +82,16 @@ func (p *ECSPlugin) executeECSRollbackStage(
8182
}
8283
}
8384

85+
// Restore ELB weights before touching task sets to avoid sending traffic to a
86+
// canary target group with no healthy targets during the rollback window.
87+
if cfg.Spec.Input.AccessType == "ELB" && primary != nil {
88+
lp.Info("Restoring ELB listener weights to 100% primary / 0% canary")
89+
if err := restoreELBWeights(ctx, lp, input.Client, client, primary); err != nil {
90+
lp.Errorf("Failed to restore ELB listener weights: %v", err)
91+
return sdk.StageStatusFailure
92+
}
93+
}
94+
8495
lp.Infof("Rolling back ECS service %s and task definition family %s", *serviceDef.ServiceName, *taskDef.Family)
8596
if err := rollback(ctx, lp, client, taskDef, serviceDef, primary); err != nil {
8697
lp.Errorf("Failed to rollback ECS service: %v", err)
@@ -91,6 +102,47 @@ func (p *ECSPlugin) executeECSRollbackStage(
91102
return sdk.StageStatusSuccess
92103
}
93104

105+
// restoreELBWeights resets the ALB listener weights to 100% primary / 0% canary.
106+
// It is a no-op if no listener ARNs are found in metadata (i.e., traffic routing was never performed).
107+
func restoreELBWeights(
108+
ctx context.Context,
109+
lp sdk.StageLogPersister,
110+
mdStore metadataStore,
111+
client provider.Client,
112+
primary *types.LoadBalancer,
113+
) error {
114+
listenersValue, ok, err := mdStore.GetDeploymentPluginMetadata(ctx, currentListenersKey)
115+
if err != nil {
116+
return fmt.Errorf("failed to get listener ARNs from metadata: %w", err)
117+
}
118+
if !ok || listenersValue == "" {
119+
lp.Info("No ELB listener ARNs found in metadata, skipping ELB weights restore")
120+
return nil
121+
}
122+
123+
canaryARN, ok, err := mdStore.GetDeploymentPluginMetadata(ctx, canaryTargetGroupArnKey)
124+
if err != nil {
125+
return fmt.Errorf("failed to get canary target group ARN from metadata: %w", err)
126+
}
127+
if !ok || canaryARN == "" {
128+
lp.Info("No canary target group ARN found in metadata, skipping ELB weights restore")
129+
return nil
130+
}
131+
132+
listenerArns := strings.Split(listenersValue, ",")
133+
routingCfg := provider.RoutingTrafficConfig{
134+
{TargetGroupArn: *primary.TargetGroupArn, Weight: 100},
135+
{TargetGroupArn: canaryARN, Weight: 0},
136+
}
137+
138+
modifiedRules, err := client.ModifyListeners(ctx, listenerArns, routingCfg)
139+
if err != nil {
140+
return fmt.Errorf("failed to restore ELB listener weights: %w", err)
141+
}
142+
lp.Infof("Restored ELB listener weights to 100%% primary / 0%% canary, modified %d rules", len(modifiedRules))
143+
return nil
144+
}
145+
94146
// rollback restores the ECS service and task set to the state defined in the running deployment source.
95147
func rollback(
96148
ctx context.Context,
@@ -132,9 +184,6 @@ func rollback(
132184
return fmt.Errorf("failed to update primary task set for service %s: %w", *service.ServiceName, err)
133185
}
134186

135-
// TODO: Rollback ELB listener weights (100% primary, 0% canary)
136-
// once the GetListenerArns and ModifyListeners being implemented
137-
138187
// Delete all previous task sets including any remaining canary tasksets
139188
lp.Info("Deleting previous task sets")
140189
for _, ts := range prevTaskSets {

pkg/app/pipedv1/plugin/ecs/deployment/rollback_test.go

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ import (
2323
"github.com/aws/aws-sdk-go-v2/service/ecs/types"
2424
"github.com/stretchr/testify/assert"
2525
"github.com/stretchr/testify/require"
26+
27+
"github.com/pipe-cd/pipecd/pkg/app/pipedv1/plugin/ecs/provider"
2628
)
2729

2830
func TestRollBack(t *testing.T) {
@@ -327,3 +329,152 @@ func TestRollBack(t *testing.T) {
327329
})
328330
}
329331
}
332+
333+
func TestRestoreELBWeights(t *testing.T) {
334+
t.Parallel()
335+
336+
var (
337+
primaryARN = "arn:aws:elasticloadbalancing:us-east-1:123:targetgroup/primary/aaa"
338+
canaryARN = "arn:aws:elasticloadbalancing:us-east-1:123:targetgroup/canary/bbb"
339+
listenerARN1 = "arn:aws:elasticloadbalancing:us-east-1:123:listener/app/my-alb/aaa/bbb"
340+
listenerARN2 = "arn:aws:elasticloadbalancing:us-east-1:123:listener/app/my-alb/aaa/ccc"
341+
primaryLB = &types.LoadBalancer{TargetGroupArn: aws.String(primaryARN)}
342+
)
343+
344+
testcases := []struct {
345+
name string
346+
primary *types.LoadBalancer
347+
metadata *mockMetadataStore
348+
client *mockECSClient
349+
wantErr bool
350+
wantErrMsg string
351+
}{
352+
{
353+
name: "success: listener ARNs and canary ARN in metadata, weights restored to 100/0",
354+
primary: primaryLB,
355+
metadata: func() *mockMetadataStore {
356+
m := happyMetadataStore()
357+
m.GetFunc = func(_ context.Context, key string) (string, bool, error) {
358+
switch key {
359+
case currentListenersKey:
360+
return listenerARN1 + "," + listenerARN2, true, nil
361+
case canaryTargetGroupArnKey:
362+
return canaryARN, true, nil
363+
}
364+
return "", false, nil
365+
}
366+
return m
367+
}(),
368+
client: func() *mockECSClient {
369+
m := &mockECSClient{}
370+
m.ModifyListenersFunc = func(_ context.Context, listenerArns []string, cfg provider.RoutingTrafficConfig) ([]string, error) {
371+
assert.Equal(t, []string{listenerARN1, listenerARN2}, listenerArns)
372+
assert.Equal(t, provider.RoutingTrafficConfig{
373+
{TargetGroupArn: primaryARN, Weight: 100},
374+
{TargetGroupArn: canaryARN, Weight: 0},
375+
}, cfg)
376+
return []string{"rule-1"}, nil
377+
}
378+
return m
379+
}(),
380+
},
381+
{
382+
name: "success (no-op): no listener ARNs in metadata (traffic routing never ran)",
383+
primary: primaryLB,
384+
metadata: happyMetadataStore(), // GetFunc returns (_, false, nil) for all keys
385+
client: &mockECSClient{},
386+
},
387+
{
388+
name: "success (no-op): listener ARNs found but no canary ARN in metadata",
389+
primary: primaryLB,
390+
metadata: func() *mockMetadataStore {
391+
m := happyMetadataStore()
392+
m.GetFunc = func(_ context.Context, key string) (string, bool, error) {
393+
if key == currentListenersKey {
394+
return listenerARN1, true, nil
395+
}
396+
return "", false, nil
397+
}
398+
return m
399+
}(),
400+
client: &mockECSClient{},
401+
},
402+
{
403+
name: "fail: GetDeploymentPluginMetadata error for listener ARNs",
404+
primary: primaryLB,
405+
metadata: func() *mockMetadataStore {
406+
m := happyMetadataStore()
407+
m.GetFunc = func(_ context.Context, key string) (string, bool, error) {
408+
if key == currentListenersKey {
409+
return "", false, errors.New("metadata error")
410+
}
411+
return "", false, nil
412+
}
413+
return m
414+
}(),
415+
client: &mockECSClient{},
416+
wantErr: true,
417+
wantErrMsg: "failed to get listener ARNs from metadata",
418+
},
419+
{
420+
name: "fail: GetDeploymentPluginMetadata error for canary ARN",
421+
primary: primaryLB,
422+
metadata: func() *mockMetadataStore {
423+
m := happyMetadataStore()
424+
m.GetFunc = func(_ context.Context, key string) (string, bool, error) {
425+
switch key {
426+
case currentListenersKey:
427+
return listenerARN1, true, nil
428+
case canaryTargetGroupArnKey:
429+
return "", false, errors.New("metadata error")
430+
}
431+
return "", false, nil
432+
}
433+
return m
434+
}(),
435+
client: &mockECSClient{},
436+
wantErr: true,
437+
wantErrMsg: "failed to get canary target group ARN from metadata",
438+
},
439+
{
440+
name: "fail: ModifyListeners error",
441+
primary: primaryLB,
442+
metadata: func() *mockMetadataStore {
443+
m := happyMetadataStore()
444+
m.GetFunc = func(_ context.Context, key string) (string, bool, error) {
445+
switch key {
446+
case currentListenersKey:
447+
return listenerARN1, true, nil
448+
case canaryTargetGroupArnKey:
449+
return canaryARN, true, nil
450+
}
451+
return "", false, nil
452+
}
453+
return m
454+
}(),
455+
client: func() *mockECSClient {
456+
m := &mockECSClient{}
457+
m.ModifyListenersFunc = func(_ context.Context, _ []string, _ provider.RoutingTrafficConfig) ([]string, error) {
458+
return nil, errors.New("modify listeners error")
459+
}
460+
return m
461+
}(),
462+
wantErr: true,
463+
wantErrMsg: "failed to restore ELB listener weights",
464+
},
465+
}
466+
467+
for _, tc := range testcases {
468+
t.Run(tc.name, func(t *testing.T) {
469+
t.Parallel()
470+
471+
err := restoreELBWeights(context.Background(), &fakeLogPersister{}, tc.metadata, tc.client, tc.primary)
472+
if tc.wantErr {
473+
require.Error(t, err)
474+
assert.Contains(t, err.Error(), tc.wantErrMsg)
475+
return
476+
}
477+
require.NoError(t, err)
478+
})
479+
}
480+
}

0 commit comments

Comments
 (0)