From c18e67a14a226f3c4d7019e2fdf684012ea19ae3 Mon Sep 17 00:00:00 2001 From: Jose Diaz-Gonzalez Date: Sat, 25 Apr 2026 00:33:29 -0400 Subject: [PATCH 1/2] feat: implement service create task for managing dokku services --- docs/dokku_service_create.md | 28 ++++++ tasks/integration_test.go | 60 ++++++++++++ tasks/main_test.go | 81 ++++++++++++++++ tasks/service_create_task.go | 181 +++++++++++++++++++++++++++++++++++ tasks/task_execute_test.go | 25 ++++- 5 files changed, 374 insertions(+), 1 deletion(-) create mode 100644 docs/dokku_service_create.md create mode 100644 tasks/service_create_task.go diff --git a/docs/dokku_service_create.md b/docs/dokku_service_create.md new file mode 100644 index 0000000..4560fdc --- /dev/null +++ b/docs/dokku_service_create.md @@ -0,0 +1,28 @@ +# dokku_service_create + +Creates or destroys a dokku service + +## Create a redis service named my-redis + +```yaml +dokku_service_create: + service: redis + name: my-redis +``` + +## Create a postgres service named my-db + +```yaml +dokku_service_create: + service: postgres + name: my-db +``` + +## Destroy a redis service named my-redis + +```yaml +dokku_service_create: + service: redis + name: my-redis + state: absent +``` diff --git a/tasks/integration_test.go b/tasks/integration_test.go index 8ecd201..5f4b3b1 100644 --- a/tasks/integration_test.go +++ b/tasks/integration_test.go @@ -934,3 +934,63 @@ func TestIntegrationMultiTaskWorkflow(t *testing.T) { } } } + +func TestIntegrationServiceCreateAndDestroy(t *testing.T) { + skipIfNoDokkuT(t) + + serviceName := "omakase-test-service" + serviceType := "redis" + + // ensure clean state + destroyService(serviceType, serviceName) + + // create the service + task := ServiceCreateTask{Service: serviceType, Name: serviceName, State: StatePresent} + result := task.Execute() + if result.Error != nil { + t.Fatalf("failed to create service: %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 service creation") + } + + // creating again should be idempotent + result = task.Execute() + if result.Error != nil { + t.Fatalf("idempotent create failed: %v", result.Error) + } + if result.Changed { + t.Error("expected changed=false for existing service") + } + if result.State != StatePresent { + t.Errorf("expected state 'present', got '%s'", result.State) + } + + // destroy the service + destroyTask := ServiceCreateTask{Service: serviceType, Name: serviceName, State: StateAbsent} + result = destroyTask.Execute() + if result.Error != nil { + t.Fatalf("failed to destroy service: %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 service destruction") + } + + // destroying again should be idempotent + result = destroyTask.Execute() + if result.Error != nil { + t.Fatalf("idempotent destroy failed: %v", result.Error) + } + if result.Changed { + t.Error("expected changed=false for nonexistent service") + } + if result.State != StateAbsent { + t.Errorf("expected state 'absent', got '%s'", result.State) + } +} diff --git a/tasks/main_test.go b/tasks/main_test.go index 318b7c7..cf13382 100644 --- a/tasks/main_test.go +++ b/tasks/main_test.go @@ -165,6 +165,7 @@ func TestRegisteredTasksExist(t *testing.T) { "dokku_proxy_toggle", "dokku_resource_limit", "dokku_resource_reserve", + "dokku_service_create", "dokku_storage_ensure", "dokku_storage_mount", } @@ -626,3 +627,83 @@ func TestGetTasksResourceReserveTaskParsedCorrectly(t *testing.T) { t.Error("ClearBefore = false, want true (YAML value should be preserved)") } } + +func TestGetTasksServiceCreateTaskParsedCorrectly(t *testing.T) { + data := []byte(`--- +- tasks: + - name: create redis service + dokku_service_create: + service: redis + name: my-redis +`) + context := map[string]interface{}{} + + tasks, err := GetTasks(data, context) + if err != nil { + t.Fatalf("GetTasks failed: %v", err) + } + + task := tasks.Get("create redis service") + if task == nil { + t.Fatal("task 'create redis service' not found") + } + + scTask, ok := task.(*ServiceCreateTask) + if !ok { + st, ok2 := task.(ServiceCreateTask) + if !ok2 { + t.Fatalf("task is not a ServiceCreateTask (type is %T)", task) + } + scTask = &st + } + + if scTask.Service != "redis" { + t.Errorf("Service = %q, want %q", scTask.Service, "redis") + } + if scTask.Name != "my-redis" { + t.Errorf("Name = %q, want %q", scTask.Name, "my-redis") + } + if scTask.DesiredState() != StatePresent { + t.Errorf("expected default state 'present', got %q", scTask.DesiredState()) + } +} + +func TestGetTasksServiceCreateWithTemplateContext(t *testing.T) { + data := []byte(`--- +- tasks: + - name: create {{ .service_type }} service + dokku_service_create: + service: {{ .service_type }} + name: {{ .service_name }} +`) + context := map[string]interface{}{ + "service_type": "postgres", + "service_name": "my-db", + } + + tasks, err := GetTasks(data, context) + if err != nil { + t.Fatalf("GetTasks failed: %v", err) + } + + task := tasks.Get("create postgres service") + if task == nil { + t.Fatal("task 'create postgres service' not found") + } + + scTask, ok := task.(*ServiceCreateTask) + if !ok { + st, ok2 := task.(ServiceCreateTask) + if !ok2 { + t.Fatalf("task is not a ServiceCreateTask (type is %T)", task) + } + scTask = &st + } + + if scTask.Service != "postgres" { + t.Errorf("Service = %q, want %q", scTask.Service, "postgres") + } + if scTask.Name != "my-db" { + t.Errorf("Name = %q, want %q", scTask.Name, "my-db") + } +} diff --git a/tasks/service_create_task.go b/tasks/service_create_task.go new file mode 100644 index 0000000..ad496e6 --- /dev/null +++ b/tasks/service_create_task.go @@ -0,0 +1,181 @@ +package tasks + +import ( + "fmt" + "omakase/subprocess" + + yaml "gopkg.in/yaml.v3" +) + +// ServiceCreateTask creates or destroys a dokku service +type ServiceCreateTask struct { + // Service is the type of service to create (e.g. redis, postgres, mysql) + Service string `required:"true" yaml:"service"` + + // Name is the name of the service instance + Name string `required:"true" yaml:"name"` + + // State is the desired state of the service + State State `required:"false" yaml:"state,omitempty" default:"present" options:"present,absent"` +} + +// ServiceCreateTaskExample contains an example of a ServiceCreateTask +type ServiceCreateTaskExample struct { + // Name is the task name holding the ServiceCreateTask description + Name string `yaml:"-"` + + // ServiceCreateTask is the ServiceCreateTask configuration + ServiceCreateTask ServiceCreateTask `yaml:"dokku_service_create"` +} + +// DesiredState returns the desired state of the service +func (t ServiceCreateTask) DesiredState() State { + return t.State +} + +// Doc returns the docblock for the service create task +func (t ServiceCreateTask) Doc() string { + return "Creates or destroys a dokku service" +} + +// Examples returns a list of ServiceCreateTaskExamples as yaml +func (t ServiceCreateTask) Examples() ([]Doc, error) { + examples := []ServiceCreateTaskExample{ + { + Name: "Create a redis service named my-redis", + ServiceCreateTask: ServiceCreateTask{ + Service: "redis", + Name: "my-redis", + }, + }, + { + Name: "Create a postgres service named my-db", + ServiceCreateTask: ServiceCreateTask{ + Service: "postgres", + Name: "my-db", + }, + }, + { + Name: "Destroy a redis service named my-redis", + ServiceCreateTask: ServiceCreateTask{ + Service: "redis", + Name: "my-redis", + State: "absent", + }, + }, + } + + 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 creates or destroys a dokku service +func (t ServiceCreateTask) Execute() TaskOutputState { + funcMap := map[State]func(string, string) TaskOutputState{ + "present": createService, + "absent": destroyService, + } + + fn, ok := funcMap[t.State] + if !ok { + return TaskOutputState{ + Error: fmt.Errorf("invalid state: %s", t.State), + } + } + return fn(t.Service, t.Name) +} + +// serviceExists checks if a dokku service exists +func serviceExists(service, name string) bool { + result, err := subprocess.CallExecCommand(subprocess.ExecCommandInput{ + Command: "dokku", + Args: []string{ + "--quiet", + fmt.Sprintf("%s:exists", service), + name, + }, + }) + if err != nil { + return false + } + + return result.ExitCode == 0 +} + +// createService creates a dokku service +func createService(service, name string) TaskOutputState { + state := TaskOutputState{ + Changed: false, + State: "absent", + } + if serviceExists(service, name) { + state.State = "present" + return state + } + + result, err := subprocess.CallExecCommand(subprocess.ExecCommandInput{ + Command: "dokku", + Args: []string{ + "--quiet", + fmt.Sprintf("%s:create", service), + name, + }, + }) + if err != nil { + state.Error = err + state.Message = result.StderrContents() + return state + } + + state.Changed = true + state.State = "present" + return state +} + +// destroyService destroys a dokku service +func destroyService(service, name string) TaskOutputState { + state := TaskOutputState{ + Changed: false, + State: "present", + } + if !serviceExists(service, name) { + state.State = "absent" + return state + } + + result, err := subprocess.CallExecCommand(subprocess.ExecCommandInput{ + Command: "dokku", + Args: []string{ + "--quiet", + "--force", + fmt.Sprintf("%s:destroy", service), + name, + }, + }) + if err != nil { + state.Error = err + state.Message = result.StderrContents() + return state + } + + state.Changed = true + state.State = "absent" + return state +} + +// init registers the ServiceCreateTask with the task registry +func init() { + RegisterTask(&ServiceCreateTask{}) +} diff --git a/tasks/task_execute_test.go b/tasks/task_execute_test.go index 12bc326..95a4849 100644 --- a/tasks/task_execute_test.go +++ b/tasks/task_execute_test.go @@ -219,6 +219,26 @@ func TestResourceLimitTaskNilResources(t *testing.T) { } } +func TestServiceCreateTaskInvalidState(t *testing.T) { + task := ServiceCreateTask{Service: "redis", Name: "test-service", State: "invalid"} + result := task.Execute() + if result.Error == nil { + t.Fatal("Execute with invalid state should return an error") + } +} + +func TestServiceCreateTaskDesiredState(t *testing.T) { + task := ServiceCreateTask{Service: "redis", Name: "test-service", State: StatePresent} + if task.DesiredState() != StatePresent { + t.Errorf("expected state 'present', got '%s'", task.DesiredState()) + } + + task = ServiceCreateTask{Service: "redis", Name: "test-service", State: StateAbsent} + if task.DesiredState() != StateAbsent { + t.Errorf("expected state 'absent', got '%s'", task.DesiredState()) + } +} + func TestResourceReserveTaskInvalidState(t *testing.T) { task := ResourceReserveTask{ App: "test-app", @@ -296,6 +316,8 @@ func TestAllTasksDesiredState(t *testing.T) { {"ResourceLimitTask absent", &ResourceLimitTask{App: "test", State: StateAbsent}, StateAbsent}, {"ResourceReserveTask present", &ResourceReserveTask{App: "test", Resources: map[string]string{"cpu": "100"}, State: StatePresent}, StatePresent}, {"ResourceReserveTask absent", &ResourceReserveTask{App: "test", State: StateAbsent}, StateAbsent}, + {"ServiceCreateTask present", &ServiceCreateTask{Service: "redis", Name: "test", State: StatePresent}, StatePresent}, + {"ServiceCreateTask absent", &ServiceCreateTask{Service: "redis", Name: "test", State: StateAbsent}, StateAbsent}, {"ProxyToggleTask present", &ProxyToggleTask{App: "test", State: StatePresent}, StatePresent}, {"ProxyToggleTask absent", &ProxyToggleTask{App: "test", State: StateAbsent}, StateAbsent}, {"StorageEnsureTask present", &StorageEnsureTask{App: "test", Chown: "heroku", State: StatePresent}, StatePresent}, @@ -487,7 +509,7 @@ func TestAllTasksExamplesReturnNoError(t *testing.T) { } func TestRegisteredTaskCount(t *testing.T) { - expected := 14 + expected := 15 if got := len(RegisteredTasks); got != expected { t.Errorf("expected %d registered tasks, got %d", expected, got) } @@ -509,6 +531,7 @@ func TestTaskDocStrings(t *testing.T) { {&PortsTask{}, "Manages the ports for a given dokku application"}, {&ResourceLimitTask{}, "Manages the resource limits for a given dokku application"}, {&ResourceReserveTask{}, "Manages the resource reservations for a given dokku application"}, + {&ServiceCreateTask{}, "Creates or destroys a dokku service"}, {&ProxyToggleTask{}, "Enables or disables the proxy plugin for a given dokku application"}, {&StorageEnsureTask{}, "Ensures the storage for a given dokku application"}, {&StorageMountTask{}, "Mounts or unmounts the storage for a given dokku application"}, From 992a761054d60b6bf200e2cc812351884ddb0cd7 Mon Sep 17 00:00:00 2001 From: Jose Diaz-Gonzalez Date: Sat, 25 Apr 2026 00:40:46 -0400 Subject: [PATCH 2/2] feat: install redis plugin in CI and skip service tests if plugin missing --- .github/workflows/test.yml | 2 ++ tasks/integration_test.go | 27 +++++++++++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 157b796..93010dd 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -38,5 +38,7 @@ jobs: sudo apt-get update -qq sudo DEBIAN_FRONTEND=noninteractive DEBCONF_NONINTERACTIVE_SEEN=true apt-get install -qq -y dokku sudo dokku plugin:install-dependencies --core + - name: install dokku redis plugin + run: sudo dokku plugin:install https://github.com/dokku/dokku-redis.git redis - name: run integration tests run: sudo go test -v -count=1 -run TestIntegration ./tasks/ diff --git a/tasks/integration_test.go b/tasks/integration_test.go index 5f4b3b1..2f88c26 100644 --- a/tasks/integration_test.go +++ b/tasks/integration_test.go @@ -3,6 +3,7 @@ package tasks import ( "omakase/subprocess" "os" + "strings" "testing" ) @@ -25,6 +26,31 @@ func skipIfNoDokkuT(t *testing.T) { } } +func dokkuPluginInstalled(plugin string) bool { + result, err := subprocess.CallExecCommand(subprocess.ExecCommandInput{ + Command: "dokku", + Args: []string{"plugin:list"}, + }) + if err != nil { + return false + } + + for _, line := range strings.Split(result.StdoutContents(), "\n") { + fields := strings.Fields(line) + if len(fields) > 0 && fields[0] == plugin { + return true + } + } + return false +} + +func skipIfPluginMissingT(t *testing.T, plugin string) { + t.Helper() + if !dokkuPluginInstalled(plugin) { + t.Skipf("skipping integration test: dokku plugin %q not installed", plugin) + } +} + func TestIntegrationAppCreateAndDestroy(t *testing.T) { skipIfNoDokkuT(t) @@ -937,6 +963,7 @@ func TestIntegrationMultiTaskWorkflow(t *testing.T) { func TestIntegrationServiceCreateAndDestroy(t *testing.T) { skipIfNoDokkuT(t) + skipIfPluginMissingT(t, "redis") serviceName := "omakase-test-service" serviceType := "redis"