Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 0 additions & 3 deletions docs/tasks/dokku_http_auth_user.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,6 @@ dokku_http_auth_user:
password: secret
- username: ops
password: hunter2
update_password: false
```

### Rotate an existing user's password
Expand All @@ -54,7 +53,6 @@ dokku_http_auth_user:
app: hello-world
users:
- username: ops
update_password: false
state: absent
```

Expand All @@ -64,7 +62,6 @@ dokku_http_auth_user:
dokku_http_auth_user:
app: hello-world
users: []
update_password: false
state: absent
```

Expand Down
1 change: 0 additions & 1 deletion docs/tasks/dokku_ps_scale.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,6 @@ dokku_ps_scale:
scale:
web: 2
worker: 1
skip_deploy: false
```

### Scale web and worker processes without deploy
Expand Down
3 changes: 0 additions & 3 deletions docs/tasks/dokku_resource_limit.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,6 @@ dokku_resource_limit:
resources:
cpu: "100"
memory: "256"
clear_before: false
```

### Set memory limit for web process type
Expand All @@ -39,7 +38,6 @@ dokku_resource_limit:
process_type: web
resources:
memory: "512"
clear_before: false
```

### Clear all resource limits
Expand All @@ -48,7 +46,6 @@ dokku_resource_limit:
dokku_resource_limit:
app: hello-world
resources: {}
clear_before: false
state: absent
```

Expand Down
3 changes: 0 additions & 3 deletions docs/tasks/dokku_resource_reserve.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,6 @@ dokku_resource_reserve:
resources:
cpu: "100"
memory: "256"
clear_before: false
```

### Set memory reservation for web process type
Expand All @@ -39,7 +38,6 @@ dokku_resource_reserve:
process_type: web
resources:
memory: "512"
clear_before: false
```

### Clear all resource reservations
Expand All @@ -48,7 +46,6 @@ dokku_resource_reserve:
dokku_resource_reserve:
app: hello-world
resources: {}
clear_before: false
state: absent
```

Expand Down
6 changes: 5 additions & 1 deletion generate/docs.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,12 @@ type param struct {

// displayType maps a Go field type to the doc-facing type name. Named string
// types such as State render as "string"; slices render as "list" and maps as
// "dict", mirroring Ansible's type vocabulary.
// "dict", mirroring Ansible's type vocabulary. Pointer fields (e.g. an optional
// *bool) render as their element type so they read as "bool" rather than "*bool".
func displayType(t reflect.Type) string {
if t.Kind() == reflect.Ptr {
return displayType(t.Elem())
}
switch t.Kind() {
case reflect.String:
return "string"
Expand Down
17 changes: 17 additions & 0 deletions tasks/bool_ptr.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package tasks

// boolPtr returns a pointer to b. It is used when building task structs whose
// optional bool fields are declared as *bool so an explicit false is
// distinguishable from an omitted key.
func boolPtr(b bool) *bool { return &b }

// boolValue dereferences p, returning def when p is nil. Optional bool task
// fields are declared as *bool so `restart: false` (and friends) survive
// decoding: go-defaults leaves pointer fields untouched, so the `default:` tag
// only drives the generated docs table and the real default is applied here.
func boolValue(p *bool, def bool) bool {
if p == nil {
return def
}
return *p
}
18 changes: 11 additions & 7 deletions tasks/config_task.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,12 @@ type ConfigTask struct {
// App is the name of the app
App string `required:"true" yaml:"app" description:"Name of the app"`

// Restart is a flag indicating if the app should be restarted
Restart bool `yaml:"restart" default:"true" description:"Flag indicating if the app should be restarted"`
// Restart is a flag indicating if the app should be restarted. It is a
// *bool so an explicit `restart: false` is distinguishable from an omitted
// key: nil defaults to true via boolValue(t.Restart, true). The default:"true"
// tag only drives the generated docs table; go-defaults leaves pointer fields
// untouched, which is what fixes the silent override of an explicit false.
Restart *bool `yaml:"restart,omitempty" default:"true" description:"Flag indicating if the app should be restarted"`

// Config is a map of configuration key-value pairs
Config map[string]string `yaml:"config" description:"Map of configuration key-value pairs"`
Expand Down Expand Up @@ -55,7 +59,7 @@ func (t ConfigTask) Examples() ([]Doc, error) {
Name: "set KEY=VALUE",
ConfigTask: ConfigTask{
App: "hello-world",
Restart: true,
Restart: boolPtr(true),
Config: map[string]string{
"KEY": "VALUE_1",
},
Expand All @@ -65,7 +69,7 @@ func (t ConfigTask) Examples() ([]Doc, error) {
Name: "set KEY=VALUE without restart",
ConfigTask: ConfigTask{
App: "hello-world",
Restart: false,
Restart: boolPtr(false),
Config: map[string]string{
"KEY": "VALUE_1",
},
Expand Down Expand Up @@ -156,7 +160,7 @@ func planConfigSet(t ConfigTask) PlanResult {
status = PlanStatusCreate
}
args := []string{"--quiet", "config:set", "--encoded"}
if !t.Restart {
if !boolValue(t.Restart, true) {
args = append(args, "--no-restart")
}
args = append(args, t.App)
Expand Down Expand Up @@ -192,7 +196,7 @@ func planConfigUnset(t ConfigTask) PlanResult {
mutations = append(mutations, fmt.Sprintf("unset %s", k))
}
args := []string{"--quiet", "config:unset"}
if !t.Restart {
if !boolValue(t.Restart, true) {
args = append(args, "--no-restart")
}
args = append(args, t.App)
Expand Down Expand Up @@ -242,7 +246,7 @@ func (t ConfigTask) ExportApp(app string) ([]interface{}, error) {
if len(config) == 0 {
return nil, nil
}
return []interface{}{ConfigTask{App: app, Restart: true, Config: config}}, nil
return []interface{}{ConfigTask{App: app, Restart: boolPtr(true), Config: config}}, nil
}

// getConfig retrieves the current configuration for a given dokku application
Expand Down
10 changes: 5 additions & 5 deletions tasks/config_task_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ func TestIntegrationConfigSetAndUnset(t *testing.T) {
// set config
setTask := ConfigTask{
App: appName,
Restart: false,
Restart: boolPtr(false),
Config: map[string]string{"TEST_KEY": "test_value"},
State: StatePresent,
}
Expand All @@ -44,7 +44,7 @@ func TestIntegrationConfigSetAndUnset(t *testing.T) {
// unset config
unsetTask := ConfigTask{
App: appName,
Restart: false,
Restart: boolPtr(false),
Config: map[string]string{"TEST_KEY": ""},
State: StateAbsent,
}
Expand Down Expand Up @@ -81,7 +81,7 @@ func TestIntegrationConfigMultipleKeys(t *testing.T) {
// set 3 keys
setTask := ConfigTask{
App: appName,
Restart: false,
Restart: boolPtr(false),
Config: map[string]string{"KEY_A": "val_a", "KEY_B": "val_b", "KEY_C": "val_c"},
State: StatePresent,
}
Expand All @@ -96,7 +96,7 @@ func TestIntegrationConfigMultipleKeys(t *testing.T) {
// update one key, keep others the same
updateTask := ConfigTask{
App: appName,
Restart: false,
Restart: boolPtr(false),
Config: map[string]string{"KEY_A": "val_a", "KEY_B": "val_b_updated", "KEY_C": "val_c"},
State: StatePresent,
}
Expand All @@ -120,7 +120,7 @@ func TestIntegrationConfigMultipleKeys(t *testing.T) {
// unset all keys
unsetTask := ConfigTask{
App: appName,
Restart: false,
Restart: boolPtr(false),
Config: map[string]string{"KEY_A": "", "KEY_B": "", "KEY_C": ""},
State: StateAbsent,
}
Expand Down
99 changes: 95 additions & 4 deletions tasks/config_task_test.go
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
package tasks

import (
"strings"
"testing"

"github.com/dokku/docket/subprocess"
_ "github.com/gliderlabs/sigil/builtin"
)

Expand Down Expand Up @@ -54,10 +56,14 @@ func TestGetTasksConfigTaskParsedCorrectly(t *testing.T) {
if configTask.App != "test-app" {
t.Errorf("App = %q, want %q", configTask.App, "test-app")
}
// Note: defaults.SetDefaults overrides restart=false with the default tag value "true"
// because false is the zero value for bool. This documents the actual behavior.
if !configTask.Restart {
t.Error("Restart = false, want true (defaults.SetDefaults overrides zero-value bool)")
// Restart is a *bool, so an explicit restart: false survives decoding: it is
// no longer clobbered back to true by defaults.SetDefaults (go-defaults
// leaves pointer fields untouched).
if configTask.Restart == nil {
t.Fatal("Restart is nil, want an explicit false")
}
if *configTask.Restart {
t.Error("Restart = true, want false (explicit restart: false must be preserved)")
}
if len(configTask.Config) != 2 {
t.Fatalf("expected 2 config keys, got %d", len(configTask.Config))
Expand All @@ -69,3 +75,88 @@ func TestGetTasksConfigTaskParsedCorrectly(t *testing.T) {
t.Errorf("Config[KEY2] = %q, want %q", configTask.Config["KEY2"], "val2")
}
}

// TestConfigTaskRestartFlag pins the --no-restart flag to the Restart pointer
// across both config:set and config:unset. An explicit restart: false must emit
// --no-restart; an omitted restart (nil) defaults to true and must not.
func TestConfigTaskRestartFlag(t *testing.T) {
cases := []struct {
name string
restart *bool
state State
current string // stdout of the config:export probe
wantVerb string
wantNoRestrt bool
}{
{"set explicit false emits --no-restart", boolPtr(false), StatePresent, "{}", "config:set", true},
{"set omitted defaults to restart", nil, StatePresent, "{}", "config:set", false},
{"set explicit true keeps restart", boolPtr(true), StatePresent, "{}", "config:set", false},
{"unset explicit false emits --no-restart", boolPtr(false), StateAbsent, `{"KEY":"val"}`, "config:unset", true},
{"unset omitted defaults to restart", nil, StateAbsent, `{"KEY":"val"}`, "config:unset", false},
{"unset explicit true keeps restart", boolPtr(true), StateAbsent, `{"KEY":"val"}`, "config:unset", false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
defer subprocess.SetExecRunner(fakeDokku(map[string]string{
"--quiet config:export --format json test-app": tc.current,
}))()

plan := ConfigTask{
App: "test-app",
Restart: tc.restart,
Config: map[string]string{"KEY": "val"},
State: tc.state,
}.Plan()
if plan.Error != nil {
t.Fatalf("unexpected plan error: %v", plan.Error)
}
if len(plan.Commands) == 0 {
t.Fatalf("expected a command, got none (plan=%#v)", plan)
}
cmd := plan.Commands[0]
if !strings.Contains(cmd, tc.wantVerb) {
t.Errorf("command %q does not contain %q", cmd, tc.wantVerb)
}
if got := strings.Contains(cmd, "--no-restart"); got != tc.wantNoRestrt {
t.Errorf("command %q: --no-restart present = %v, want %v", cmd, got, tc.wantNoRestrt)
}
})
}
}

// TestConfigTaskRestartFalseEndToEnd exercises the full parse + SetDefaults
// pipeline: a recipe with restart: false must still yield a --no-restart
// command. This is the regression the go-defaults clobber produced.
func TestConfigTaskRestartFalseEndToEnd(t *testing.T) {
data := []byte(`---
- tasks:
- name: set config
dokku_config:
app: test-app
restart: false
config:
KEY: val
`)
defer subprocess.SetExecRunner(fakeDokku(map[string]string{
"--quiet config:export --format json test-app": "{}",
}))()

parsed, err := GetTasks(data, map[string]interface{}{})
if err != nil {
t.Fatalf("GetTasks failed: %v", err)
}
task := parsed.Get("set config")
if task == nil {
t.Fatal("task 'set config' not found")
}
plan := task.Plan()
if plan.Error != nil {
t.Fatalf("unexpected plan error: %v", plan.Error)
}
if len(plan.Commands) == 0 {
t.Fatalf("expected a command, got none")
}
if !strings.Contains(plan.Commands[0], "--no-restart") {
t.Errorf("restart: false should emit --no-restart, got %q", plan.Commands[0])
}
}
2 changes: 1 addition & 1 deletion tasks/export_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ func TestIntegrationExportConfigRoundTrip(t *testing.T) {
createApp(app)
defer destroyApp(app)

set := ConfigTask{App: app, Restart: false, Config: map[string]string{"EXPORT_KEY": "export_value"}, State: StatePresent}
set := ConfigTask{App: app, Restart: boolPtr(false), Config: map[string]string{"EXPORT_KEY": "export_value"}, State: StatePresent}
if r := set.Execute(); r.Error != nil {
t.Fatalf("set config: %v", r.Error)
}
Expand Down
9 changes: 5 additions & 4 deletions tasks/http_auth_user_task.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,9 @@ type HttpAuthUserTask struct {

// UpdatePassword re-issues http-auth:add-user for users that already exist
// so their password converges. Passwords are not exposed in the report, so
// a rotation cannot be drift-detected; enable this to force convergence.
UpdatePassword bool `required:"false" yaml:"update_password" default:"false" description:"Re-issue add-user for users that already exist so their password converges"`
// a rotation cannot be drift-detected; enable this to force convergence. It
// is a *bool so the value survives decoding unchanged; nil defaults to false.
UpdatePassword *bool `required:"false" yaml:"update_password,omitempty" default:"false" description:"Re-issue add-user for users that already exist so their password converges"`

// State is the desired state of the HTTP auth users
State State `required:"false" yaml:"state,omitempty" default:"present" options:"present,absent" description:"Desired state of the HTTP auth users"`
Expand Down Expand Up @@ -99,7 +100,7 @@ func (t HttpAuthUserTask) Examples() ([]Doc, error) {
DokkuHttpAuthUser: HttpAuthUserTask{
App: "hello-world",
Users: []HttpAuthUser{{Username: "admin", Password: "new-secret"}},
UpdatePassword: true,
UpdatePassword: boolPtr(true),
},
},
{
Expand Down Expand Up @@ -166,7 +167,7 @@ func (t HttpAuthUserTask) Plan() PlanResult {
case !current[u.Username]:
toApply = append(toApply, u)
mutations = append(mutations, "add "+u.Username)
case t.UpdatePassword:
case boolValue(t.UpdatePassword, false):
toApply = append(toApply, u)
mutations = append(mutations, "update "+u.Username)
}
Expand Down
2 changes: 1 addition & 1 deletion tasks/http_auth_user_task_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ func TestIntegrationHttpAuthUser(t *testing.T) {
}

// update_password re-issues add-user for an existing user
rotate := HttpAuthUserTask{App: appName, Users: []HttpAuthUser{{Username: "alice", Password: "new-pass"}}, UpdatePassword: true, State: StatePresent}
rotate := HttpAuthUserTask{App: appName, Users: []HttpAuthUser{{Username: "alice", Password: "new-pass"}}, UpdatePassword: boolPtr(true), State: StatePresent}
result = rotate.Execute()
if result.Error != nil {
t.Fatalf("failed to rotate password: %v", result.Error)
Expand Down
3 changes: 2 additions & 1 deletion tasks/http_auth_user_task_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,8 @@ func TestGetTasksHttpAuthUserTaskParsedCorrectly(t *testing.T) {
if hauTask.App != "test-app" {
t.Errorf("App = %q, want %q", hauTask.App, "test-app")
}
if !hauTask.UpdatePassword {
// UpdatePassword is a *bool, so an explicit update_password: true survives decoding.
if hauTask.UpdatePassword == nil || !*hauTask.UpdatePassword {
t.Error("UpdatePassword = false, want true")
}
if len(hauTask.Users) != 2 {
Expand Down
Loading