Skip to content

Commit cb5b7d9

Browse files
committed
Allow users to push apps using a weighted canary deployment
Signed-off-by: João Pereira <[email protected]>
1 parent 9249609 commit cb5b7d9

10 files changed

+174
-9
lines changed

actor/v7pushaction/create_deployment_for_push_plan.go

+8-1
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,17 @@ func (actor Actor) CreateDeploymentForApplication(pushPlan PushPlan, eventStream
1414
Relationships: resources.Relationships{constant.RelationshipTypeApplication: resources.Relationship{GUID: pushPlan.Application.GUID}},
1515
}
1616

17-
if pushPlan.MaxInFlight != 0 {
17+
if pushPlan.MaxInFlight > 0 {
1818
dep.Options = resources.DeploymentOpts{MaxInFlight: pushPlan.MaxInFlight}
1919
}
2020

21+
if len(pushPlan.InstanceSteps) > 0 {
22+
dep.Options.CanaryDeploymentOptions = resources.CanaryDeploymentOptions{Steps: []resources.CanaryStep{}}
23+
for _, w := range pushPlan.InstanceSteps {
24+
dep.Options.CanaryDeploymentOptions.Steps = append(dep.Options.CanaryDeploymentOptions.Steps, resources.CanaryStep{InstanceWeight: w})
25+
}
26+
}
27+
2128
deploymentGUID, warnings, err := actor.V7Actor.CreateDeployment(dep)
2229

2330
if err != nil {

actor/v7pushaction/create_deployment_for_push_plan_test.go

+38
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,44 @@ var _ = Describe("CreateDeploymentForApplication()", func() {
152152
Expect(events).To(ConsistOf(StartingDeployment, InstanceDetails, WaitingForDeployment))
153153
})
154154
})
155+
156+
When("canary weights are provided", func() {
157+
BeforeEach(func() {
158+
fakeV7Actor.PollStartForDeploymentCalls(func(_ resources.Application, _ string, _ bool, handleInstanceDetails func(string)) (warnings v7action.Warnings, err error) {
159+
handleInstanceDetails("Instances starting...")
160+
return nil, nil
161+
})
162+
163+
fakeV7Actor.CreateDeploymentReturns(
164+
"some-deployment-guid",
165+
v7action.Warnings{"some-deployment-warning"},
166+
nil,
167+
)
168+
paramPlan.Strategy = "canary"
169+
paramPlan.InstanceSteps = []int64{1, 2, 3, 4}
170+
})
171+
172+
It("creates the correct deployment from the object", func() {
173+
Expect(fakeV7Actor.CreateDeploymentCallCount()).To(Equal(1))
174+
dep := fakeV7Actor.CreateDeploymentArgsForCall(0)
175+
Expect(dep).To(Equal(resources.Deployment{
176+
Strategy: "canary",
177+
Options: resources.DeploymentOpts{
178+
CanaryDeploymentOptions: resources.CanaryDeploymentOptions{
179+
Steps: []resources.CanaryStep{
180+
{InstanceWeight: 1},
181+
{InstanceWeight: 2},
182+
{InstanceWeight: 3},
183+
{InstanceWeight: 4},
184+
},
185+
},
186+
},
187+
Relationships: resources.Relationships{
188+
constant.RelationshipTypeApplication: resources.Relationship{GUID: "some-app-guid"},
189+
},
190+
}))
191+
})
192+
})
155193
})
156194

157195
Describe("waiting for app to start", func() {

actor/v7pushaction/push_plan.go

+2
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ type PushPlan struct {
2222
Strategy constant.DeploymentStrategy
2323
MaxInFlight int
2424
TaskTypeApplication bool
25+
InstanceSteps []int64
2526

2627
DockerImageCredentials v7action.DockerImageCredentials
2728

@@ -48,6 +49,7 @@ type FlagOverrides struct {
4849
HealthCheckTimeout int64
4950
HealthCheckType constant.HealthCheckType
5051
Instances types.NullInt
52+
InstanceSteps []int64
5153
Memory string
5254
MaxInFlight *int
5355
NoStart bool

actor/v7pushaction/setup_deployment_information_for_push_plan.go

+4
Original file line numberDiff line numberDiff line change
@@ -9,5 +9,9 @@ func SetupDeploymentInformationForPushPlan(pushPlan PushPlan, overrides FlagOver
99
pushPlan.MaxInFlight = *overrides.MaxInFlight
1010
}
1111

12+
if overrides.Strategy == constant.DeploymentStrategyCanary && overrides.InstanceSteps != nil {
13+
pushPlan.InstanceSteps = overrides.InstanceSteps
14+
}
15+
1216
return pushPlan, nil
1317
}

actor/v7pushaction/setup_deployment_information_for_push_plan_test.go

+34
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ var _ = Describe("SetupDeploymentInformationForPushPlan", func() {
3232
overrides.Strategy = "rolling"
3333
maxInFlight := 5
3434
overrides.MaxInFlight = &maxInFlight
35+
overrides.InstanceSteps = []int64{1, 2, 3, 4}
3536
})
3637

3738
It("sets the strategy on the push plan", func() {
@@ -43,12 +44,35 @@ var _ = Describe("SetupDeploymentInformationForPushPlan", func() {
4344
Expect(executeErr).ToNot(HaveOccurred())
4445
Expect(expectedPushPlan.MaxInFlight).To(Equal(5))
4546
})
47+
48+
When("strategy is rolling", func() {
49+
BeforeEach(func() {
50+
overrides.Strategy = "rolling"
51+
})
52+
53+
It("does not set the canary steps", func() {
54+
Expect(executeErr).ToNot(HaveOccurred())
55+
Expect(expectedPushPlan.InstanceSteps).To(BeEmpty())
56+
})
57+
})
58+
59+
When("strategy is canary", func() {
60+
BeforeEach(func() {
61+
overrides.Strategy = "canary"
62+
})
63+
64+
It("does not set the canary steps", func() {
65+
Expect(executeErr).ToNot(HaveOccurred())
66+
Expect(expectedPushPlan.InstanceSteps).To(ContainElements(1, 2, 3, 4))
67+
})
68+
})
4669
})
4770

4871
When("flag overrides does not specify strategy", func() {
4972
BeforeEach(func() {
5073
maxInFlight := 10
5174
overrides.MaxInFlight = &maxInFlight
75+
overrides.InstanceSteps = []int64{1, 2, 3, 4}
5276
})
5377
It("leaves the strategy as its default value on the push plan", func() {
5478
Expect(executeErr).ToNot(HaveOccurred())
@@ -59,12 +83,22 @@ var _ = Describe("SetupDeploymentInformationForPushPlan", func() {
5983
Expect(executeErr).ToNot(HaveOccurred())
6084
Expect(expectedPushPlan.MaxInFlight).To(Equal(0))
6185
})
86+
87+
It("does not set canary steps", func() {
88+
Expect(executeErr).ToNot(HaveOccurred())
89+
Expect(expectedPushPlan.InstanceSteps).To(BeEmpty())
90+
})
6291
})
6392

6493
When("flag not provided", func() {
6594
It("does not set MaxInFlight", func() {
6695
Expect(executeErr).ToNot(HaveOccurred())
6796
Expect(expectedPushPlan.MaxInFlight).To(Equal(0))
6897
})
98+
99+
It("does not set the canary steps", func() {
100+
Expect(executeErr).ToNot(HaveOccurred())
101+
Expect(expectedPushPlan.InstanceSteps).To(BeEmpty())
102+
})
69103
})
70104
})

api/cloudcontroller/ccv3/deployment_test.go

+3-2
Original file line numberDiff line numberDiff line change
@@ -251,6 +251,7 @@ var _ = Describe("Deployment", func() {
251251
dep.Strategy = constant.DeploymentStrategyCanary
252252
dep.RevisionGUID = revisionGUID
253253
dep.Relationships = resources.Relationships{constant.RelationshipTypeApplication: resources.Relationship{GUID: "some-app-guid"}}
254+
dep.Options.CanaryDeploymentOptions = resources.CanaryDeploymentOptions{Steps: []resources.CanaryStep{{InstanceWeight: 1}, {InstanceWeight: 2}}}
254255
deploymentGUID, warnings, executeErr = client.CreateApplicationDeployment(dep)
255256
})
256257

@@ -277,7 +278,7 @@ var _ = Describe("Deployment", func() {
277278
server.AppendHandlers(
278279
CombineHandlers(
279280
VerifyRequest(http.MethodPost, "/v3/deployments"),
280-
VerifyJSON(`{"revision":{ "guid":"some-revision-guid" }, "strategy": "canary", "relationships":{"app":{"data":{"guid":"some-app-guid"}}}}`),
281+
VerifyJSON(`{"revision":{ "guid":"some-revision-guid" }, "strategy": "canary", "relationships":{"app":{"data":{"guid":"some-app-guid"}}},"options":{"max_in_flight":1, "canary": {"steps": [{"instance_weight": 1}, {"instance_weight": 2}]}}}`),
281282
RespondWith(http.StatusAccepted, response, http.Header{"X-Cf-Warnings": {"warning"}}),
282283
),
283284
)
@@ -306,7 +307,7 @@ var _ = Describe("Deployment", func() {
306307
server.AppendHandlers(
307308
CombineHandlers(
308309
VerifyRequest(http.MethodPost, "/v3/deployments"),
309-
VerifyJSON(`{"revision":{ "guid":"some-revision-guid" }, "strategy": "canary", "relationships":{"app":{"data":{"guid":"some-app-guid"}}}}`),
310+
VerifyJSON(`{"revision":{ "guid":"some-revision-guid" }, "strategy": "canary","options":{"max_in_flight":1, "canary": {"steps": [{"instance_weight": 1}, {"instance_weight": 2}]}}, "relationships":{"app":{"data":{"guid":"some-app-guid"}}}}`),
310311
RespondWith(http.StatusTeapot, response, http.Header{}),
311312
),
312313
)

command/v7/push_command.go

+28
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"context"
55
"fmt"
66
"os"
7+
"strconv"
78
"strings"
89

910
"github.com/cloudfoundry/bosh-cli/director/template"
@@ -88,6 +89,7 @@ type PushCommand struct {
8889
HealthCheckHTTPEndpoint string `long:"endpoint" description:"Valid path on the app for an HTTP health check. Only used when specifying --health-check-type=http"`
8990
HealthCheckType flag.HealthCheckType `long:"health-check-type" short:"u" description:"Application health check type. Defaults to 'port'. 'http' requires a valid endpoint, for example, '/health'."`
9091
Instances flag.Instances `long:"instances" short:"i" description:"Number of instances"`
92+
InstanceSteps string `long:"instance-steps" description:"An array of percentage steps to deploy when using deployment strategy canary. (e.g. 20,40,60)"`
9193
Lifecycle constant.AppLifecycleType `long:"lifecycle" description:"App lifecycle type to stage and run the app" default:""`
9294
LogRateLimit string `long:"log-rate-limit" short:"l" description:"Log rate limit per second, in bytes (e.g. 128B, 4K, 1M). -l=-1 represents unlimited"`
9395
PathToManifest flag.ManifestPathWithExistenceCheck `long:"manifest" short:"f" description:"Path to manifest"`
@@ -343,6 +345,17 @@ func (cmd PushCommand) GetFlagOverrides() (v7pushaction.FlagOverrides, error) {
343345
pathsToVarsFiles = append(pathsToVarsFiles, string(varFilePath))
344346
}
345347

348+
var instanceSteps []int64
349+
if len(cmd.InstanceSteps) > 0 {
350+
for _, v := range strings.Split(cmd.InstanceSteps, ",") {
351+
parsedInt, err := strconv.ParseInt(v, 0, 64)
352+
if err != nil {
353+
return v7pushaction.FlagOverrides{}, err
354+
}
355+
instanceSteps = append(instanceSteps, parsedInt)
356+
}
357+
}
358+
346359
return v7pushaction.FlagOverrides{
347360
AppName: cmd.OptionalArgs.AppName,
348361
Buildpacks: cmd.Buildpacks,
@@ -355,6 +368,7 @@ func (cmd PushCommand) GetFlagOverrides() (v7pushaction.FlagOverrides, error) {
355368
HealthCheckType: cmd.HealthCheckType.Type,
356369
HealthCheckTimeout: cmd.HealthCheckTimeout.Value,
357370
Instances: cmd.Instances.NullInt,
371+
InstanceSteps: instanceSteps,
358372
MaxInFlight: cmd.MaxInFlight,
359373
Memory: cmd.Memory,
360374
NoStart: cmd.NoStart,
@@ -558,11 +572,25 @@ func (cmd PushCommand) ValidateFlags() error {
558572
return translatableerror.RequiredFlagsError{Arg1: "--max-in-flight", Arg2: "--strategy"}
559573
case cmd.Strategy.Name != constant.DeploymentStrategyDefault && cmd.MaxInFlight != nil && *cmd.MaxInFlight < 1:
560574
return translatableerror.IncorrectUsageError{Message: "--max-in-flight must be greater than or equal to 1"}
575+
case len(cmd.InstanceSteps) > 0 && cmd.Strategy.Name != constant.DeploymentStrategyCanary:
576+
return translatableerror.ArgumentCombinationError{Args: []string{"--instance-steps", "--strategy=canary"}}
577+
case len(cmd.InstanceSteps) > 0 && !cmd.validateInstanceSteps():
578+
return translatableerror.ParseArgumentError{ArgumentName: "--instance-steps", ExpectedType: "list of weights"}
561579
}
562580

563581
return nil
564582
}
565583

584+
func (cmd PushCommand) validateInstanceSteps() bool {
585+
for _, v := range strings.Split(cmd.InstanceSteps, ",") {
586+
_, err := strconv.ParseInt(v, 0, 64)
587+
if err != nil {
588+
return false
589+
}
590+
}
591+
return true
592+
}
593+
566594
func (cmd PushCommand) validBuildpacks() bool {
567595
for _, buildpack := range cmd.Buildpacks {
568596
if (buildpack == "null" || buildpack == "default") && len(cmd.Buildpacks) > 1 {

command/v7/push_command_test.go

+41
Original file line numberDiff line numberDiff line change
@@ -620,6 +620,28 @@ var _ = Describe("push Command", func() {
620620
})
621621
})
622622

623+
Describe("canary steps", func() {
624+
BeforeEach(func() {
625+
cmd.InstanceSteps = "1,2,3,4"
626+
})
627+
628+
When("canary strategy is provided", func() {
629+
BeforeEach(func() {
630+
cmd.Strategy = flag.DeploymentStrategy{Name: "canary"}
631+
})
632+
633+
It("should succeed", func() {
634+
Expect(executeErr).ToNot(HaveOccurred())
635+
})
636+
})
637+
638+
When("canary strategy is NOT provided", func() {
639+
It("it just fails", func() {
640+
Expect(executeErr).To(HaveOccurred())
641+
})
642+
})
643+
})
644+
623645
When("when getting the application summary succeeds", func() {
624646
BeforeEach(func() {
625647
summary := v7action.DetailedApplicationSummary{
@@ -1399,5 +1421,24 @@ var _ = Describe("push Command", func() {
13991421
"--docker-username",
14001422
},
14011423
}),
1424+
1425+
Entry("instance-steps is not a list of ints",
1426+
func() {
1427+
cmd.Strategy = flag.DeploymentStrategy{Name: constant.DeploymentStrategyCanary}
1428+
cmd.InstanceSteps = "impossible"
1429+
},
1430+
translatableerror.ParseArgumentError{
1431+
ArgumentName: "--instance-steps",
1432+
ExpectedType: "list of weights",
1433+
}),
1434+
1435+
Entry("instance-steps can only be used with canary strategy",
1436+
func() {
1437+
cmd.InstanceSteps = "impossible"
1438+
},
1439+
translatableerror.ArgumentCombinationError{
1440+
Args: []string{
1441+
"--instance-steps", "--strategy=canary",
1442+
}}),
14021443
)
14031444
})

integration/v7/push/help_test.go

+1
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ var _ = Describe("help", func() {
8080
Eventually(session).Should(Say(`--endpoint`))
8181
Eventually(session).Should(Say(`--health-check-type, -u`))
8282
Eventually(session).Should(Say(`--instances, -i`))
83+
Eventually(session).Should(Say(`--instance-steps`))
8384
Eventually(session).Should(Say(`--log-rate-limit, -l\s+Log rate limit per second, in bytes \(e.g. 128B, 4K, 1M\). -l=-1 represents unlimited`))
8485
Eventually(session).Should(Say(`--manifest, -f`))
8586
Eventually(session).Should(Say(`--max-in-flight`))

resources/deployment_resource.go

+15-6
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,20 @@ type Deployment struct {
2424
}
2525

2626
type DeploymentOpts struct {
27-
MaxInFlight int `json:"max_in_flight"`
27+
MaxInFlight int `json:"max_in_flight,omitempty"`
28+
CanaryDeploymentOptions CanaryDeploymentOptions `json:"canary,omitempty"`
29+
}
30+
31+
func (d DeploymentOpts) IsEmpty() bool {
32+
return d.MaxInFlight == 0 && len(d.CanaryDeploymentOptions.Steps) == 0
33+
}
34+
35+
type CanaryDeploymentOptions struct {
36+
Steps []CanaryStep `json:"steps"`
37+
}
38+
39+
type CanaryStep struct {
40+
InstanceWeight int64 `json:"instance_weight"`
2841
}
2942

3043
// MarshalJSON converts a Deployment into a Cloud Controller Deployment.
@@ -56,12 +69,8 @@ func (d Deployment) MarshalJSON() ([]byte, error) {
5669
ccDeployment.Strategy = d.Strategy
5770
}
5871

59-
var b DeploymentOpts
60-
if d.Options != b {
72+
if !d.Options.IsEmpty() {
6173
ccDeployment.Options = &d.Options
62-
if d.Options.MaxInFlight < 1 {
63-
ccDeployment.Options.MaxInFlight = 1
64-
}
6574
}
6675

6776
ccDeployment.Relationships = d.Relationships

0 commit comments

Comments
 (0)