From f4a998925bf6ff500892cfabe7968007297fd179 Mon Sep 17 00:00:00 2001 From: Jose Diaz-Gonzalez Date: Sat, 25 Apr 2026 04:52:08 -0400 Subject: [PATCH 1/2] feat: implement dokku_domains task for managing app and global domains Adds a new DomainsTask supporting four states compatible with the upstream ansible-dokku dokku_domains module: - present: idempotently add domains (only adds missing ones) - absent: idempotently remove domains (only removes existing ones) - set: replace all domains unconditionally - clear: remove all domains unconditionally Supports both per-app and global domain operations via the global flag. Includes unit tests, YAML parsing tests, integration tests, and documentation. --- docs/dokku_domains.md | 41 +++++ tasks/domains_task.go | 347 +++++++++++++++++++++++++++++++++++++ tasks/integration_test.go | 95 ++++++++++ tasks/main.go | 4 + tasks/main_test.go | 95 ++++++++++ tasks/task_execute_test.go | 76 +++++++- 6 files changed, 657 insertions(+), 1 deletion(-) create mode 100644 docs/dokku_domains.md create mode 100644 tasks/domains_task.go diff --git a/docs/dokku_domains.md b/docs/dokku_domains.md new file mode 100644 index 0000000..c0bc8b6 --- /dev/null +++ b/docs/dokku_domains.md @@ -0,0 +1,41 @@ +# dokku_domains + +Manages the domains for a given dokku application or globally + +## Add domains to an app + +```yaml +dokku_domains: + app: example-app + domains: + - example.com + - www.example.com +``` + +## Remove domains from an app + +```yaml +dokku_domains: + app: example-app + domains: + - old.example.com + state: absent +``` + +## Set global domains + +```yaml +dokku_domains: + global: true + domains: + - global.example.com + state: set +``` + +## Clear all domains from an app + +```yaml +dokku_domains: + app: example-app + state: clear +``` diff --git a/tasks/domains_task.go b/tasks/domains_task.go new file mode 100644 index 0000000..502d14d --- /dev/null +++ b/tasks/domains_task.go @@ -0,0 +1,347 @@ +package tasks + +import ( + "fmt" + "omakase/subprocess" + "strings" + + yaml "gopkg.in/yaml.v3" +) + +// DomainsTask manages the domains for a given dokku application or globally +type DomainsTask struct { + // App is the name of the app + App string `required:"false" yaml:"app"` + + // Global is a flag indicating if the domains should be applied globally + Global bool `required:"false" yaml:"global,omitempty"` + + // Domains is the list of domain names + Domains []string `required:"false" yaml:"domains"` + + // State is the desired state of the domains + State State `required:"false" yaml:"state" default:"present" options:"present,absent,set,clear"` +} + +// DomainsTaskExample contains an example of a DomainsTask +type DomainsTaskExample struct { + // Name is the task name holding the DomainsTask description + Name string `yaml:"-"` + + // DomainsTask is the DomainsTask configuration + DomainsTask DomainsTask `yaml:"dokku_domains"` +} + +// DesiredState returns the desired state of the domains +func (t DomainsTask) DesiredState() State { + return t.State +} + +// Doc returns the docblock for the domains task +func (t DomainsTask) Doc() string { + return "Manages the domains for a given dokku application or globally" +} + +// Examples returns the examples for the domains task +func (t DomainsTask) Examples() ([]Doc, error) { + examples := []DomainsTaskExample{ + { + Name: "Add domains to an app", + DomainsTask: DomainsTask{ + App: "example-app", + Domains: []string{"example.com", "www.example.com"}, + }, + }, + { + Name: "Remove domains from an app", + DomainsTask: DomainsTask{ + App: "example-app", + Domains: []string{"old.example.com"}, + State: "absent", + }, + }, + { + Name: "Set global domains", + DomainsTask: DomainsTask{ + Global: true, + Domains: []string{"global.example.com"}, + State: "set", + }, + }, + { + Name: "Clear all domains from an app", + DomainsTask: DomainsTask{ + App: "example-app", + State: "clear", + }, + }, + } + + var output []Doc + for _, example := range examples { + b, err := yaml.Marshal(example) + if err != nil { + return nil, err + } + + output = append(output, Doc{ + Name: example.Name, + Codeblock: string(b), + }) + } + + return output, nil +} + +// Execute manages the domains +func (t DomainsTask) Execute() TaskOutputState { + funcMap := map[State]func(DomainsTask) TaskOutputState{ + StatePresent: addDomains, + StateAbsent: removeDomains, + StateSet: setDomains, + StateClear: clearDomains, + } + + fn, ok := funcMap[t.State] + if !ok { + return TaskOutputState{ + Error: fmt.Errorf("invalid state: %s", t.State), + } + } + return fn(t) +} + +// validateDomainsTask validates the domains task parameters +func validateDomainsTask(t DomainsTask, requireDomains bool) error { + if t.Global && t.App != "" { + return fmt.Errorf("'app' must not be set when 'global' is set to true") + } + if !t.Global && t.App == "" { + return fmt.Errorf("'app' is required when 'global' is not set to true") + } + if requireDomains && len(t.Domains) == 0 { + return fmt.Errorf("'domains' must not be empty for state '%s'", t.State) + } + return nil +} + +// getDomains fetches current domains for an app or globally +func getDomains(app string, global bool) (map[string]bool, error) { + reportFlag := "--domains-app-vhosts" + args := []string{ + "domains:report", + app, + reportFlag, + } + if global { + args = []string{ + "domains:report", + "--global", + "--domains-global-vhosts", + } + } + + result, err := subprocess.CallExecCommand(subprocess.ExecCommandInput{ + Command: "dokku", + Args: args, + }) + if err != nil { + return nil, err + } + + domains := map[string]bool{} + for _, domain := range strings.Fields(result.StdoutContents()) { + domains[domain] = true + } + return domains, nil +} + +// addDomains adds domains if they don't already exist +func addDomains(t DomainsTask) TaskOutputState { + state := TaskOutputState{ + Changed: false, + State: StateAbsent, + } + + if err := validateDomainsTask(t, true); err != nil { + state.Error = err + return state + } + + currentDomains, err := getDomains(t.App, t.Global) + if err != nil { + state.Error = err + state.Message = err.Error() + return state + } + + var newDomains []string + for _, domain := range t.Domains { + if !currentDomains[domain] { + newDomains = append(newDomains, domain) + } + } + + if len(newDomains) == 0 { + state.State = StatePresent + return state + } + + subcommand := "domains:add" + appName := t.App + if t.Global { + subcommand = "domains:add-global" + appName = "--global" + } + + args := []string{"--quiet", subcommand, appName} + args = append(args, newDomains...) + + result, err := subprocess.CallExecCommand(subprocess.ExecCommandInput{ + Command: "dokku", + Args: args, + }) + if err != nil { + state.Error = err + state.Message = result.StderrContents() + return state + } + + state.Changed = true + state.State = StatePresent + return state +} + +// removeDomains removes domains if they exist +func removeDomains(t DomainsTask) TaskOutputState { + state := TaskOutputState{ + Changed: false, + State: StatePresent, + } + + if err := validateDomainsTask(t, true); err != nil { + state.Error = err + return state + } + + currentDomains, err := getDomains(t.App, t.Global) + if err != nil { + state.Error = err + state.Message = err.Error() + return state + } + + var domainsToRemove []string + for _, domain := range t.Domains { + if currentDomains[domain] { + domainsToRemove = append(domainsToRemove, domain) + } + } + + if len(domainsToRemove) == 0 { + state.State = StateAbsent + return state + } + + subcommand := "domains:remove" + appName := t.App + if t.Global { + subcommand = "domains:remove-global" + appName = "--global" + } + + args := []string{"--quiet", subcommand, appName} + args = append(args, domainsToRemove...) + + result, err := subprocess.CallExecCommand(subprocess.ExecCommandInput{ + Command: "dokku", + Args: args, + }) + if err != nil { + state.Error = err + state.Message = result.StderrContents() + return state + } + + state.Changed = true + state.State = StateAbsent + return state +} + +// setDomains replaces all domains with the specified ones +func setDomains(t DomainsTask) TaskOutputState { + state := TaskOutputState{ + Changed: false, + State: StateAbsent, + } + + if err := validateDomainsTask(t, true); err != nil { + state.Error = err + return state + } + + subcommand := "domains:set" + appName := t.App + if t.Global { + subcommand = "domains:set-global" + appName = "--global" + } + + args := []string{"--quiet", subcommand, appName} + args = append(args, t.Domains...) + + result, err := subprocess.CallExecCommand(subprocess.ExecCommandInput{ + Command: "dokku", + Args: args, + }) + if err != nil { + state.Error = err + state.Message = result.StderrContents() + return state + } + + state.Changed = true + state.State = StateSet + return state +} + +// clearDomains removes all domains +func clearDomains(t DomainsTask) TaskOutputState { + state := TaskOutputState{ + Changed: false, + State: StatePresent, + } + + if err := validateDomainsTask(t, false); err != nil { + state.Error = err + return state + } + + subcommand := "domains:clear" + appName := t.App + if t.Global { + subcommand = "domains:clear-global" + appName = "--global" + } + + args := []string{"--quiet", subcommand, appName} + + result, err := subprocess.CallExecCommand(subprocess.ExecCommandInput{ + Command: "dokku", + Args: args, + }) + if err != nil { + state.Error = err + state.Message = result.StderrContents() + return state + } + + state.Changed = true + state.State = StateClear + return state +} + +// init registers the DomainsTask with the task registry +func init() { + RegisterTask(&DomainsTask{}) +} diff --git a/tasks/integration_test.go b/tasks/integration_test.go index f724aba..7de964a 100644 --- a/tasks/integration_test.go +++ b/tasks/integration_test.go @@ -634,6 +634,101 @@ func TestIntegrationDomainsToggle(t *testing.T) { } } +func TestIntegrationDomainsAddAndRemove(t *testing.T) { + skipIfNoDokkuT(t) + + appName := "omakase-test-domains-task" + + destroyApp(appName) + createApp(appName) + defer destroyApp(appName) + + // add domains + addTask := DomainsTask{ + App: appName, + Domains: []string{"example.com", "www.example.com"}, + State: StatePresent, + } + result := addTask.Execute() + if result.Error != nil { + t.Fatalf("failed to add domains: %v", result.Error) + } + if result.State != StatePresent { + t.Errorf("expected state 'present', got '%s'", result.State) + } + if !result.Changed { + t.Error("expected changed=true for new domains") + } + + // add same domains again (idempotent) + result = addTask.Execute() + if result.Error != nil { + t.Fatalf("idempotent add failed: %v", result.Error) + } + if result.Changed { + t.Error("expected changed=false for existing domains") + } + + // remove one domain + removeTask := DomainsTask{ + App: appName, + Domains: []string{"www.example.com"}, + State: StateAbsent, + } + result = removeTask.Execute() + if result.Error != nil { + t.Fatalf("failed to remove domain: %v", result.Error) + } + if result.State != StateAbsent { + t.Errorf("expected state 'absent', got '%s'", result.State) + } + if !result.Changed { + t.Error("expected changed=true for domain removal") + } + + // remove same domain again (idempotent) + result = removeTask.Execute() + if result.Error != nil { + t.Fatalf("idempotent remove failed: %v", result.Error) + } + if result.Changed { + t.Error("expected changed=false for already-removed domain") + } + + // set domains (replaces all) + setTask := DomainsTask{ + App: appName, + Domains: []string{"new.example.com"}, + State: StateSet, + } + result = setTask.Execute() + if result.Error != nil { + t.Fatalf("failed to set domains: %v", result.Error) + } + if result.State != StateSet { + t.Errorf("expected state 'set', got '%s'", result.State) + } + if !result.Changed { + t.Error("expected changed=true for set domains") + } + + // clear all domains + clearTask := DomainsTask{ + App: appName, + State: StateClear, + } + result = clearTask.Execute() + if result.Error != nil { + t.Fatalf("failed to clear domains: %v", result.Error) + } + if result.State != StateClear { + t.Errorf("expected state 'clear', got '%s'", result.State) + } + if !result.Changed { + t.Error("expected changed=true for clear domains") + } +} + func TestIntegrationProxyToggle(t *testing.T) { skipIfNoDokkuT(t) diff --git a/tasks/main.go b/tasks/main.go index 8c33706..10d8a8e 100644 --- a/tasks/main.go +++ b/tasks/main.go @@ -24,6 +24,10 @@ const ( StateAbsent State = "absent" // StateDeployed represents the deployed state StateDeployed State = "deployed" + // StateSet represents the set state + StateSet State = "set" + // StateClear represents the clear state + StateClear State = "clear" ) // Recipe represents a recipe for a task diff --git a/tasks/main_test.go b/tasks/main_test.go index ff06f96..28c04d2 100644 --- a/tasks/main_test.go +++ b/tasks/main_test.go @@ -157,6 +157,7 @@ func TestRegisteredTasksExist(t *testing.T) { "dokku_builder_property", "dokku_checks_toggle", "dokku_config", + "dokku_domains", "dokku_domains_toggle", "dokku_git_from_image", "dokku_git_sync", @@ -884,3 +885,97 @@ func TestGetTasksNetworkTaskParsedCorrectly(t *testing.T) { t.Errorf("expected default state 'present', got %q", netTask.DesiredState()) } } + +func TestGetTasksDomainsTaskParsedCorrectly(t *testing.T) { + data := []byte(`--- +- tasks: + - name: add domains + dokku_domains: + app: test-app + domains: + - example.com + - www.example.com + state: present +`) + context := map[string]interface{}{} + + tasks, err := GetTasks(data, context) + if err != nil { + t.Fatalf("GetTasks failed: %v", err) + } + + task := tasks.Get("add domains") + if task == nil { + t.Fatal("task 'add domains' not found") + } + + dTask, ok := task.(*DomainsTask) + if !ok { + dt, ok2 := task.(DomainsTask) + if !ok2 { + t.Fatalf("task is not a DomainsTask (type is %T)", task) + } + dTask = &dt + } + + if dTask.App != "test-app" { + t.Errorf("App = %q, want %q", dTask.App, "test-app") + } + if len(dTask.Domains) != 2 { + t.Fatalf("expected 2 domains, got %d", len(dTask.Domains)) + } + if dTask.Domains[0] != "example.com" { + t.Errorf("Domains[0] = %q, want %q", dTask.Domains[0], "example.com") + } + if dTask.Domains[1] != "www.example.com" { + t.Errorf("Domains[1] = %q, want %q", dTask.Domains[1], "www.example.com") + } + if dTask.DesiredState() != StatePresent { + t.Errorf("expected state 'present', got %q", dTask.DesiredState()) + } +} + +func TestGetTasksDomainsTaskGlobalParsedCorrectly(t *testing.T) { + data := []byte(`--- +- tasks: + - name: set global domains + dokku_domains: + global: true + domains: + - global.example.com + state: set +`) + context := map[string]interface{}{} + + tasks, err := GetTasks(data, context) + if err != nil { + t.Fatalf("GetTasks failed: %v", err) + } + + task := tasks.Get("set global domains") + if task == nil { + t.Fatal("task 'set global domains' not found") + } + + dTask, ok := task.(*DomainsTask) + if !ok { + dt, ok2 := task.(DomainsTask) + if !ok2 { + t.Fatalf("task is not a DomainsTask (type is %T)", task) + } + dTask = &dt + } + + if !dTask.Global { + t.Error("Global = false, want true") + } + if len(dTask.Domains) != 1 { + t.Fatalf("expected 1 domain, got %d", len(dTask.Domains)) + } + if dTask.Domains[0] != "global.example.com" { + t.Errorf("Domains[0] = %q, want %q", dTask.Domains[0], "global.example.com") + } + if dTask.DesiredState() != StateSet { + t.Errorf("expected state 'set', got %q", dTask.DesiredState()) + } +} diff --git a/tasks/task_execute_test.go b/tasks/task_execute_test.go index b40d484..925b0f9 100644 --- a/tasks/task_execute_test.go +++ b/tasks/task_execute_test.go @@ -61,6 +61,75 @@ func TestConfigTaskInvalidState(t *testing.T) { } } +func TestDomainsTaskInvalidState(t *testing.T) { + task := DomainsTask{App: "test-app", Domains: []string{"example.com"}, State: "invalid"} + result := task.Execute() + if result.Error == nil { + t.Fatal("Execute with invalid state should return an error") + } +} + +func TestDomainsTaskDesiredState(t *testing.T) { + task := DomainsTask{App: "test-app", Domains: []string{"example.com"}, State: StatePresent} + if task.DesiredState() != StatePresent { + t.Errorf("expected state 'present', got '%s'", task.DesiredState()) + } + + task = DomainsTask{App: "test-app", Domains: []string{"example.com"}, State: StateAbsent} + if task.DesiredState() != StateAbsent { + t.Errorf("expected state 'absent', got '%s'", task.DesiredState()) + } + + task = DomainsTask{App: "test-app", Domains: []string{"example.com"}, State: StateSet} + if task.DesiredState() != StateSet { + t.Errorf("expected state 'set', got '%s'", task.DesiredState()) + } + + task = DomainsTask{App: "test-app", State: StateClear} + if task.DesiredState() != StateClear { + t.Errorf("expected state 'clear', got '%s'", task.DesiredState()) + } +} + +func TestDomainsTaskMissingApp(t *testing.T) { + task := DomainsTask{Domains: []string{"example.com"}, State: StatePresent} + result := task.Execute() + if result.Error == nil { + t.Fatal("Execute without app and global=false should return an error") + } +} + +func TestDomainsTaskGlobalWithApp(t *testing.T) { + task := DomainsTask{App: "test-app", Global: true, Domains: []string{"example.com"}, State: StatePresent} + result := task.Execute() + if result.Error == nil { + t.Fatal("expected error when both global and app are set") + } + if !strings.Contains(result.Error.Error(), "must not be set when 'global' is set to true") { + t.Errorf("unexpected error: %v", result.Error) + } +} + +func TestDomainsTaskEmptyDomains(t *testing.T) { + states := []State{StatePresent, StateAbsent, StateSet} + for _, s := range states { + task := DomainsTask{App: "test-app", Domains: []string{}, State: s} + result := task.Execute() + if result.Error == nil { + t.Fatalf("Execute with empty domains and state=%s should return an error", s) + } + } +} + +func TestDomainsTaskClearNoDomains(t *testing.T) { + task := DomainsTask{App: "test-app", State: StateClear} + result := task.Execute() + // Should fail because dokku isn't running, but NOT because of missing domains + if result.Error != nil && strings.Contains(result.Error.Error(), "must not be empty") { + t.Error("clear state should not require domains") + } +} + func TestDomainsToggleTaskInvalidState(t *testing.T) { task := DomainsToggleTask{App: "test-app", State: "invalid"} result := task.Execute() @@ -375,6 +444,10 @@ func TestAllTasksDesiredState(t *testing.T) { {"ChecksToggleTask absent", &ChecksToggleTask{App: "test", State: StateAbsent}, StateAbsent}, {"ConfigTask present", &ConfigTask{App: "test", State: StatePresent}, StatePresent}, {"ConfigTask absent", &ConfigTask{App: "test", State: StateAbsent}, StateAbsent}, + {"DomainsTask present", &DomainsTask{App: "test", Domains: []string{"example.com"}, State: StatePresent}, StatePresent}, + {"DomainsTask absent", &DomainsTask{App: "test", Domains: []string{"example.com"}, State: StateAbsent}, StateAbsent}, + {"DomainsTask set", &DomainsTask{App: "test", Domains: []string{"example.com"}, State: StateSet}, StateSet}, + {"DomainsTask clear", &DomainsTask{App: "test", State: StateClear}, StateClear}, {"DomainsToggleTask present", &DomainsToggleTask{App: "test", State: StatePresent}, StatePresent}, {"DomainsToggleTask absent", &DomainsToggleTask{App: "test", State: StateAbsent}, StateAbsent}, {"GitFromImageTask deployed", &GitFromImageTask{App: "test", Image: "nginx", State: StateDeployed}, StateDeployed}, @@ -585,7 +658,7 @@ func TestAllTasksExamplesReturnNoError(t *testing.T) { } func TestRegisteredTaskCount(t *testing.T) { - expected := 18 + expected := 19 if got := len(RegisteredTasks); got != expected { t.Errorf("expected %d registered tasks, got %d", expected, got) } @@ -600,6 +673,7 @@ func TestTaskDocStrings(t *testing.T) { {&BuilderPropertyTask{}, "Manages the builder configuration for a given dokku application"}, {&ChecksToggleTask{}, "Enables or disables the checks plugin for a given dokku application"}, {&ConfigTask{}, "Manages the configuration for a given dokku application"}, + {&DomainsTask{}, "Manages the domains for a given dokku application or globally"}, {&DomainsToggleTask{}, "Enables or disables the domains plugin for a given dokku application"}, {&GitFromImageTask{}, "Deploys a git repository from a docker image"}, {&GitSyncTask{}, "Syncs a git repository to a dokku application"}, From f0634bda0b382291a1b1cbf516f51f0d0df7da01 Mon Sep 17 00:00:00 2001 From: Jose Diaz-Gonzalez Date: Sat, 25 Apr 2026 05:02:47 -0400 Subject: [PATCH 2/2] feat: verify domain state via domains:report in integration tests Adds a getReportedDomains helper that queries dokku domains:report to verify the actual domain state after each operation: add, remove, set, and clear. --- tasks/integration_test.go | 54 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/tasks/integration_test.go b/tasks/integration_test.go index 7de964a..75f971f 100644 --- a/tasks/integration_test.go +++ b/tasks/integration_test.go @@ -634,6 +634,19 @@ func TestIntegrationDomainsToggle(t *testing.T) { } } +// getReportedDomains queries dokku domains:report to get the current domain list for an app +func getReportedDomains(appName string) []string { + result, err := subprocess.CallExecCommand(subprocess.ExecCommandInput{ + Command: "dokku", + Args: []string{"domains:report", appName, "--domains-app-vhosts"}, + }) + if err != nil { + return nil + } + + return strings.Fields(result.StdoutContents()) +} + func TestIntegrationDomainsAddAndRemove(t *testing.T) { skipIfNoDokkuT(t) @@ -660,6 +673,19 @@ func TestIntegrationDomainsAddAndRemove(t *testing.T) { t.Error("expected changed=true for new domains") } + // verify domains via domains:report + domains := getReportedDomains(appName) + domainSet := map[string]bool{} + for _, d := range domains { + domainSet[d] = true + } + if !domainSet["example.com"] { + t.Error("expected 'example.com' in domains:report output after add") + } + if !domainSet["www.example.com"] { + t.Error("expected 'www.example.com' in domains:report output after add") + } + // add same domains again (idempotent) result = addTask.Execute() if result.Error != nil { @@ -686,6 +712,19 @@ func TestIntegrationDomainsAddAndRemove(t *testing.T) { t.Error("expected changed=true for domain removal") } + // verify domains via domains:report after removal + domains = getReportedDomains(appName) + domainSet = map[string]bool{} + for _, d := range domains { + domainSet[d] = true + } + if !domainSet["example.com"] { + t.Error("expected 'example.com' to still be present after removing www.example.com") + } + if domainSet["www.example.com"] { + t.Error("expected 'www.example.com' to be absent after removal") + } + // remove same domain again (idempotent) result = removeTask.Execute() if result.Error != nil { @@ -712,6 +751,15 @@ func TestIntegrationDomainsAddAndRemove(t *testing.T) { t.Error("expected changed=true for set domains") } + // verify domains via domains:report after set + domains = getReportedDomains(appName) + if len(domains) != 1 { + t.Fatalf("expected exactly 1 domain after set, got %d: %v", len(domains), domains) + } + if domains[0] != "new.example.com" { + t.Errorf("expected domain 'new.example.com' after set, got '%s'", domains[0]) + } + // clear all domains clearTask := DomainsTask{ App: appName, @@ -727,6 +775,12 @@ func TestIntegrationDomainsAddAndRemove(t *testing.T) { if !result.Changed { t.Error("expected changed=true for clear domains") } + + // verify no domains via domains:report after clear + domains = getReportedDomains(appName) + if len(domains) != 0 { + t.Errorf("expected 0 domains after clear, got %d: %v", len(domains), domains) + } } func TestIntegrationProxyToggle(t *testing.T) {