Skip to content

Commit d728293

Browse files
authored
Merge pull request #393 from dokku/341-bug-planned-and-executed-resource-command-argument-order-can-differ
fix: sort resource and ps-scale command arguments
2 parents 4ac6a76 + 70960e3 commit d728293

5 files changed

Lines changed: 130 additions & 7 deletions

File tree

tasks/ps_scale_task.go

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,11 @@ package tasks
22

33
import (
44
"fmt"
5-
"github.com/dokku/docket/subprocess"
5+
"sort"
66
"strconv"
77
"strings"
8+
9+
"github.com/dokku/docket/subprocess"
810
)
911

1012
// PsScaleTask manages the process scale for a given dokku application
@@ -100,7 +102,15 @@ func (t PsScaleTask) Plan() PlanResult {
100102
}
101103
toScale := []string{}
102104
mutations := []string{}
103-
for proctype, qty := range t.Scale {
105+
// Range over sorted process types so the ps:scale args and the
106+
// mutation list are deterministic across runs (issue #341).
107+
proctypes := make([]string, 0, len(t.Scale))
108+
for proctype := range t.Scale {
109+
proctypes = append(proctypes, proctype)
110+
}
111+
sort.Strings(proctypes)
112+
for _, proctype := range proctypes {
113+
qty := t.Scale[proctype]
104114
if cur, ok := existing[proctype]; ok && cur == qty {
105115
continue
106116
}

tasks/ps_scale_task_test.go

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
package tasks
22

33
import (
4+
"reflect"
45
"testing"
56

7+
"github.com/dokku/docket/subprocess"
68
_ "github.com/gliderlabs/sigil/builtin"
79
)
810

@@ -30,6 +32,39 @@ func TestPsScaleTaskNilScale(t *testing.T) {
3032
}
3133
}
3234

35+
func TestPsScaleCommandDeterministicOrder(t *testing.T) {
36+
// web and worker both drift, so the ps:scale command lists both (appended
37+
// after the app) and both mutations are reported. Sorting the process types
38+
// must yield byte-identical output on every run (issue #341).
39+
defer subprocess.SetExecRunner(fakeDokku(map[string]string{
40+
"--quiet ps:scale test-app": "web: 1\nworker: 1",
41+
}))()
42+
43+
task := PsScaleTask{
44+
App: "test-app",
45+
Scale: map[string]int{"worker": 3, "web": 2},
46+
State: StatePresent,
47+
}
48+
49+
wantCommands := []string{"dokku ps:scale test-app web=2 worker=3"}
50+
wantMutations := []string{"scale web=2 (was 1)", "scale worker=3 (was 1)"}
51+
52+
// Repeat so a reintroduced map-order bug is caught reliably rather than
53+
// passing by chance on a lucky iteration.
54+
for i := 0; i < 20; i++ {
55+
plan := task.Plan()
56+
if plan.Error != nil {
57+
t.Fatalf("iteration %d: unexpected plan error: %v", i, plan.Error)
58+
}
59+
if !reflect.DeepEqual(plan.Commands, wantCommands) {
60+
t.Fatalf("iteration %d commands = %v, want %v", i, plan.Commands, wantCommands)
61+
}
62+
if !reflect.DeepEqual(plan.Mutations, wantMutations) {
63+
t.Fatalf("iteration %d mutations = %v, want %v", i, plan.Mutations, wantMutations)
64+
}
65+
}
66+
}
67+
3368
func TestGetTasksPsScaleTaskParsedCorrectly(t *testing.T) {
3469
data := []byte(`---
3570
- tasks:

tasks/resource_limit_task_test.go

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package tasks
22

33
import (
4+
"reflect"
45
"strings"
56
"testing"
67

@@ -113,6 +114,39 @@ func TestResourceLimitClearBeforeClearsNonDesiredResource(t *testing.T) {
113114
}
114115
}
115116

117+
func TestResourceLimitSetCommandDeterministicOrder(t *testing.T) {
118+
// Both cpu and memory drift, so the set command carries both flags and both
119+
// mutations are reported. Sorting the resource keys must yield byte-identical,
120+
// alphabetical output on every run so plan and apply agree (issue #341).
121+
defer subprocess.SetExecRunner(fakeDokku(map[string]string{
122+
"--quiet resource:limit test-app": "cpu: 50\nmemory: 256",
123+
}))()
124+
125+
task := ResourceLimitTask{
126+
App: "test-app",
127+
Resources: map[string]string{"memory": "512", "cpu": "100"},
128+
State: StatePresent,
129+
}
130+
131+
wantCommands := []string{"dokku resource:limit --cpu 100 --memory 512 test-app"}
132+
wantMutations := []string{`set cpu=100 (was "50")`, `set memory=512 (was "256")`}
133+
134+
// Repeat so a reintroduced map-order bug is caught reliably rather than
135+
// passing by chance on a lucky iteration.
136+
for i := 0; i < 20; i++ {
137+
plan := task.Plan()
138+
if plan.Error != nil {
139+
t.Fatalf("iteration %d: unexpected plan error: %v", i, plan.Error)
140+
}
141+
if !reflect.DeepEqual(plan.Commands, wantCommands) {
142+
t.Fatalf("iteration %d commands = %v, want %v", i, plan.Commands, wantCommands)
143+
}
144+
if !reflect.DeepEqual(plan.Mutations, wantMutations) {
145+
t.Fatalf("iteration %d mutations = %v, want %v", i, plan.Mutations, wantMutations)
146+
}
147+
}
148+
}
149+
116150
func TestGetTasksResourceLimitTaskParsedCorrectly(t *testing.T) {
117151
data := []byte(`---
118152
- tasks:

tasks/resource_reserve_task_test.go

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
package tasks
22

33
import (
4+
"reflect"
45
"testing"
56

7+
"github.com/dokku/docket/subprocess"
68
_ "github.com/gliderlabs/sigil/builtin"
79
)
810

@@ -34,6 +36,39 @@ func TestResourceReserveTaskNilResources(t *testing.T) {
3436
}
3537
}
3638

39+
func TestResourceReserveSetCommandDeterministicOrder(t *testing.T) {
40+
// Both cpu and memory drift, so the set command carries both flags and both
41+
// mutations are reported. Sorting the resource keys must yield byte-identical,
42+
// alphabetical output on every run so plan and apply agree (issue #341).
43+
defer subprocess.SetExecRunner(fakeDokku(map[string]string{
44+
"--quiet resource:reserve test-app": "cpu: 50\nmemory: 256",
45+
}))()
46+
47+
task := ResourceReserveTask{
48+
App: "test-app",
49+
Resources: map[string]string{"memory": "512", "cpu": "100"},
50+
State: StatePresent,
51+
}
52+
53+
wantCommands := []string{"dokku resource:reserve --cpu 100 --memory 512 test-app"}
54+
wantMutations := []string{`set cpu=100 (was "50")`, `set memory=512 (was "256")`}
55+
56+
// Repeat so a reintroduced map-order bug is caught reliably rather than
57+
// passing by chance on a lucky iteration.
58+
for i := 0; i < 20; i++ {
59+
plan := task.Plan()
60+
if plan.Error != nil {
61+
t.Fatalf("iteration %d: unexpected plan error: %v", i, plan.Error)
62+
}
63+
if !reflect.DeepEqual(plan.Commands, wantCommands) {
64+
t.Fatalf("iteration %d commands = %v, want %v", i, plan.Commands, wantCommands)
65+
}
66+
if !reflect.DeepEqual(plan.Mutations, wantMutations) {
67+
t.Fatalf("iteration %d mutations = %v, want %v", i, plan.Mutations, wantMutations)
68+
}
69+
}
70+
}
71+
3772
func TestGetTasksResourceReserveTaskParsedCorrectly(t *testing.T) {
3873
data := []byte(`---
3974
- tasks:

tasks/resources.go

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -159,11 +159,11 @@ func planSetResource(subcommand string, rctx ResourceContext) PlanResult {
159159
return PlanResult{Status: PlanStatusError, Error: err}
160160
}
161161

162-
for k := range rctx.Resources {
162+
for _, k := range sortedResourceKeys(rctx.Resources) {
163163
if _, ok := currentResources[k]; !ok {
164164
return PlanResult{
165165
Status: PlanStatusError,
166-
Error: fmt.Errorf("unknown resource %s, valid resources: %v", k, mapKeys(currentResources)),
166+
Error: fmt.Errorf("unknown resource %s, valid resources: %v", k, sortedResourceKeys(currentResources)),
167167
}
168168
}
169169
}
@@ -180,7 +180,8 @@ func planSetResource(subcommand string, rctx ResourceContext) PlanResult {
180180
if effective.ClearBefore {
181181
mutations = append(mutations, "clear before set")
182182
}
183-
for k, v := range rctx.Resources {
183+
for _, k := range sortedResourceKeys(rctx.Resources) {
184+
v := rctx.Resources[k]
184185
if currentResources[k] != v {
185186
mutations = append(mutations, fmt.Sprintf("set %s=%s (was %q)", k, v, currentResources[k]))
186187
}
@@ -269,8 +270,8 @@ func resourceSetInputs(subcommand string, rctx ResourceContext) []subprocess.Exe
269270
inputs = append(inputs, resourceClearInput(subcommand, rctx))
270271
}
271272
args := []string{subcommand}
272-
for key, value := range rctx.Resources {
273-
args = append(args, fmt.Sprintf("--%s", key), value)
273+
for _, key := range sortedResourceKeys(rctx.Resources) {
274+
args = append(args, fmt.Sprintf("--%s", key), rctx.Resources[key])
274275
}
275276
if rctx.ProcessType != "" {
276277
args = append(args, "--process-type", rctx.ProcessType)
@@ -306,3 +307,11 @@ func mapKeys(m map[string]string) []string {
306307
}
307308
return keys
308309
}
310+
311+
// sortedResourceKeys returns the resource keys in deterministic (sorted) order so
312+
// plan and apply build byte-identical command args and mutation lists (issue #341).
313+
func sortedResourceKeys(m map[string]string) []string {
314+
keys := mapKeys(m)
315+
sort.Strings(keys)
316+
return keys
317+
}

0 commit comments

Comments
 (0)