Skip to content

Commit c18e67a

Browse files
committed
feat: implement service create task for managing dokku services
1 parent b8d5787 commit c18e67a

5 files changed

Lines changed: 374 additions & 1 deletion

File tree

docs/dokku_service_create.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
# dokku_service_create
2+
3+
Creates or destroys a dokku service
4+
5+
## Create a redis service named my-redis
6+
7+
```yaml
8+
dokku_service_create:
9+
service: redis
10+
name: my-redis
11+
```
12+
13+
## Create a postgres service named my-db
14+
15+
```yaml
16+
dokku_service_create:
17+
service: postgres
18+
name: my-db
19+
```
20+
21+
## Destroy a redis service named my-redis
22+
23+
```yaml
24+
dokku_service_create:
25+
service: redis
26+
name: my-redis
27+
state: absent
28+
```

tasks/integration_test.go

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -934,3 +934,63 @@ func TestIntegrationMultiTaskWorkflow(t *testing.T) {
934934
}
935935
}
936936
}
937+
938+
func TestIntegrationServiceCreateAndDestroy(t *testing.T) {
939+
skipIfNoDokkuT(t)
940+
941+
serviceName := "omakase-test-service"
942+
serviceType := "redis"
943+
944+
// ensure clean state
945+
destroyService(serviceType, serviceName)
946+
947+
// create the service
948+
task := ServiceCreateTask{Service: serviceType, Name: serviceName, State: StatePresent}
949+
result := task.Execute()
950+
if result.Error != nil {
951+
t.Fatalf("failed to create service: %v", result.Error)
952+
}
953+
if result.State != StatePresent {
954+
t.Errorf("expected state 'present', got '%s'", result.State)
955+
}
956+
if !result.Changed {
957+
t.Error("expected changed=true for new service creation")
958+
}
959+
960+
// creating again should be idempotent
961+
result = task.Execute()
962+
if result.Error != nil {
963+
t.Fatalf("idempotent create failed: %v", result.Error)
964+
}
965+
if result.Changed {
966+
t.Error("expected changed=false for existing service")
967+
}
968+
if result.State != StatePresent {
969+
t.Errorf("expected state 'present', got '%s'", result.State)
970+
}
971+
972+
// destroy the service
973+
destroyTask := ServiceCreateTask{Service: serviceType, Name: serviceName, State: StateAbsent}
974+
result = destroyTask.Execute()
975+
if result.Error != nil {
976+
t.Fatalf("failed to destroy service: %v", result.Error)
977+
}
978+
if result.State != StateAbsent {
979+
t.Errorf("expected state 'absent', got '%s'", result.State)
980+
}
981+
if !result.Changed {
982+
t.Error("expected changed=true for service destruction")
983+
}
984+
985+
// destroying again should be idempotent
986+
result = destroyTask.Execute()
987+
if result.Error != nil {
988+
t.Fatalf("idempotent destroy failed: %v", result.Error)
989+
}
990+
if result.Changed {
991+
t.Error("expected changed=false for nonexistent service")
992+
}
993+
if result.State != StateAbsent {
994+
t.Errorf("expected state 'absent', got '%s'", result.State)
995+
}
996+
}

tasks/main_test.go

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,7 @@ func TestRegisteredTasksExist(t *testing.T) {
165165
"dokku_proxy_toggle",
166166
"dokku_resource_limit",
167167
"dokku_resource_reserve",
168+
"dokku_service_create",
168169
"dokku_storage_ensure",
169170
"dokku_storage_mount",
170171
}
@@ -626,3 +627,83 @@ func TestGetTasksResourceReserveTaskParsedCorrectly(t *testing.T) {
626627
t.Error("ClearBefore = false, want true (YAML value should be preserved)")
627628
}
628629
}
630+
631+
func TestGetTasksServiceCreateTaskParsedCorrectly(t *testing.T) {
632+
data := []byte(`---
633+
- tasks:
634+
- name: create redis service
635+
dokku_service_create:
636+
service: redis
637+
name: my-redis
638+
`)
639+
context := map[string]interface{}{}
640+
641+
tasks, err := GetTasks(data, context)
642+
if err != nil {
643+
t.Fatalf("GetTasks failed: %v", err)
644+
}
645+
646+
task := tasks.Get("create redis service")
647+
if task == nil {
648+
t.Fatal("task 'create redis service' not found")
649+
}
650+
651+
scTask, ok := task.(*ServiceCreateTask)
652+
if !ok {
653+
st, ok2 := task.(ServiceCreateTask)
654+
if !ok2 {
655+
t.Fatalf("task is not a ServiceCreateTask (type is %T)", task)
656+
}
657+
scTask = &st
658+
}
659+
660+
if scTask.Service != "redis" {
661+
t.Errorf("Service = %q, want %q", scTask.Service, "redis")
662+
}
663+
if scTask.Name != "my-redis" {
664+
t.Errorf("Name = %q, want %q", scTask.Name, "my-redis")
665+
}
666+
if scTask.DesiredState() != StatePresent {
667+
t.Errorf("expected default state 'present', got %q", scTask.DesiredState())
668+
}
669+
}
670+
671+
func TestGetTasksServiceCreateWithTemplateContext(t *testing.T) {
672+
data := []byte(`---
673+
- tasks:
674+
- name: create {{ .service_type }} service
675+
dokku_service_create:
676+
service: {{ .service_type }}
677+
name: {{ .service_name }}
678+
`)
679+
context := map[string]interface{}{
680+
"service_type": "postgres",
681+
"service_name": "my-db",
682+
}
683+
684+
tasks, err := GetTasks(data, context)
685+
if err != nil {
686+
t.Fatalf("GetTasks failed: %v", err)
687+
}
688+
689+
task := tasks.Get("create postgres service")
690+
if task == nil {
691+
t.Fatal("task 'create postgres service' not found")
692+
}
693+
694+
scTask, ok := task.(*ServiceCreateTask)
695+
if !ok {
696+
st, ok2 := task.(ServiceCreateTask)
697+
if !ok2 {
698+
t.Fatalf("task is not a ServiceCreateTask (type is %T)", task)
699+
}
700+
scTask = &st
701+
}
702+
703+
if scTask.Service != "postgres" {
704+
t.Errorf("Service = %q, want %q", scTask.Service, "postgres")
705+
}
706+
if scTask.Name != "my-db" {
707+
t.Errorf("Name = %q, want %q", scTask.Name, "my-db")
708+
}
709+
}

tasks/service_create_task.go

Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
package tasks
2+
3+
import (
4+
"fmt"
5+
"omakase/subprocess"
6+
7+
yaml "gopkg.in/yaml.v3"
8+
)
9+
10+
// ServiceCreateTask creates or destroys a dokku service
11+
type ServiceCreateTask struct {
12+
// Service is the type of service to create (e.g. redis, postgres, mysql)
13+
Service string `required:"true" yaml:"service"`
14+
15+
// Name is the name of the service instance
16+
Name string `required:"true" yaml:"name"`
17+
18+
// State is the desired state of the service
19+
State State `required:"false" yaml:"state,omitempty" default:"present" options:"present,absent"`
20+
}
21+
22+
// ServiceCreateTaskExample contains an example of a ServiceCreateTask
23+
type ServiceCreateTaskExample struct {
24+
// Name is the task name holding the ServiceCreateTask description
25+
Name string `yaml:"-"`
26+
27+
// ServiceCreateTask is the ServiceCreateTask configuration
28+
ServiceCreateTask ServiceCreateTask `yaml:"dokku_service_create"`
29+
}
30+
31+
// DesiredState returns the desired state of the service
32+
func (t ServiceCreateTask) DesiredState() State {
33+
return t.State
34+
}
35+
36+
// Doc returns the docblock for the service create task
37+
func (t ServiceCreateTask) Doc() string {
38+
return "Creates or destroys a dokku service"
39+
}
40+
41+
// Examples returns a list of ServiceCreateTaskExamples as yaml
42+
func (t ServiceCreateTask) Examples() ([]Doc, error) {
43+
examples := []ServiceCreateTaskExample{
44+
{
45+
Name: "Create a redis service named my-redis",
46+
ServiceCreateTask: ServiceCreateTask{
47+
Service: "redis",
48+
Name: "my-redis",
49+
},
50+
},
51+
{
52+
Name: "Create a postgres service named my-db",
53+
ServiceCreateTask: ServiceCreateTask{
54+
Service: "postgres",
55+
Name: "my-db",
56+
},
57+
},
58+
{
59+
Name: "Destroy a redis service named my-redis",
60+
ServiceCreateTask: ServiceCreateTask{
61+
Service: "redis",
62+
Name: "my-redis",
63+
State: "absent",
64+
},
65+
},
66+
}
67+
68+
var output []Doc
69+
for _, example := range examples {
70+
b, err := yaml.Marshal(example)
71+
if err != nil {
72+
return nil, err
73+
}
74+
75+
output = append(output, Doc{
76+
Name: example.Name,
77+
Codeblock: string(b),
78+
})
79+
}
80+
81+
return output, nil
82+
}
83+
84+
// Execute creates or destroys a dokku service
85+
func (t ServiceCreateTask) Execute() TaskOutputState {
86+
funcMap := map[State]func(string, string) TaskOutputState{
87+
"present": createService,
88+
"absent": destroyService,
89+
}
90+
91+
fn, ok := funcMap[t.State]
92+
if !ok {
93+
return TaskOutputState{
94+
Error: fmt.Errorf("invalid state: %s", t.State),
95+
}
96+
}
97+
return fn(t.Service, t.Name)
98+
}
99+
100+
// serviceExists checks if a dokku service exists
101+
func serviceExists(service, name string) bool {
102+
result, err := subprocess.CallExecCommand(subprocess.ExecCommandInput{
103+
Command: "dokku",
104+
Args: []string{
105+
"--quiet",
106+
fmt.Sprintf("%s:exists", service),
107+
name,
108+
},
109+
})
110+
if err != nil {
111+
return false
112+
}
113+
114+
return result.ExitCode == 0
115+
}
116+
117+
// createService creates a dokku service
118+
func createService(service, name string) TaskOutputState {
119+
state := TaskOutputState{
120+
Changed: false,
121+
State: "absent",
122+
}
123+
if serviceExists(service, name) {
124+
state.State = "present"
125+
return state
126+
}
127+
128+
result, err := subprocess.CallExecCommand(subprocess.ExecCommandInput{
129+
Command: "dokku",
130+
Args: []string{
131+
"--quiet",
132+
fmt.Sprintf("%s:create", service),
133+
name,
134+
},
135+
})
136+
if err != nil {
137+
state.Error = err
138+
state.Message = result.StderrContents()
139+
return state
140+
}
141+
142+
state.Changed = true
143+
state.State = "present"
144+
return state
145+
}
146+
147+
// destroyService destroys a dokku service
148+
func destroyService(service, name string) TaskOutputState {
149+
state := TaskOutputState{
150+
Changed: false,
151+
State: "present",
152+
}
153+
if !serviceExists(service, name) {
154+
state.State = "absent"
155+
return state
156+
}
157+
158+
result, err := subprocess.CallExecCommand(subprocess.ExecCommandInput{
159+
Command: "dokku",
160+
Args: []string{
161+
"--quiet",
162+
"--force",
163+
fmt.Sprintf("%s:destroy", service),
164+
name,
165+
},
166+
})
167+
if err != nil {
168+
state.Error = err
169+
state.Message = result.StderrContents()
170+
return state
171+
}
172+
173+
state.Changed = true
174+
state.State = "absent"
175+
return state
176+
}
177+
178+
// init registers the ServiceCreateTask with the task registry
179+
func init() {
180+
RegisterTask(&ServiceCreateTask{})
181+
}

0 commit comments

Comments
 (0)