Skip to content

Commit b00ffb6

Browse files
authored
Merge pull request #120 from dokku/14-implement-service-link-task
feat: implement service link task
2 parents 4803cd7 + 04ac31f commit b00ffb6

6 files changed

Lines changed: 557 additions & 1 deletion

File tree

.github/workflows/test.yml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,12 @@ jobs:
3030
- uses: actions/setup-go@v6
3131
with:
3232
go-version-file: go.mod
33+
- name: enable docker legacy link support
34+
run: |
35+
sudo mkdir -p /etc/systemd/system/docker.service.d
36+
printf '[Service]\nEnvironment="DOCKER_KEEP_DEPRECATED_LEGACY_LINKS_ENV_VARS=1"\n' | sudo tee /etc/systemd/system/docker.service.d/legacy-links.conf
37+
sudo systemctl daemon-reload
38+
sudo systemctl restart docker
3339
- name: install dokku
3440
run: |
3541
sudo mkdir -p /etc/apt/keyrings

docs/dokku_service_link.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
# dokku_service_link
2+
3+
Links or unlinks a dokku service to an app
4+
5+
## Link a redis service named my-redis to my-app
6+
7+
```yaml
8+
dokku_service_link:
9+
app: my-app
10+
service: redis
11+
name: my-redis
12+
```
13+
14+
## Link a postgres service named my-db to my-app
15+
16+
```yaml
17+
dokku_service_link:
18+
app: my-app
19+
service: postgres
20+
name: my-db
21+
```
22+
23+
## Unlink a redis service named my-redis from my-app
24+
25+
```yaml
26+
dokku_service_link:
27+
app: my-app
28+
service: redis
29+
name: my-redis
30+
state: absent
31+
```

tasks/integration_test.go

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

33
import (
4+
"fmt"
45
"omakase/subprocess"
56
"os"
7+
"strconv"
68
"strings"
79
"testing"
810
)
@@ -51,6 +53,69 @@ func skipIfPluginMissingT(t *testing.T, plugin string) {
5153
}
5254
}
5355

56+
func dockerLinkSupported() bool {
57+
result, err := subprocess.CallExecCommand(subprocess.ExecCommandInput{
58+
Command: "docker",
59+
Args: []string{"version", "--format", "{{.Server.Version}}"},
60+
})
61+
if err != nil {
62+
return false
63+
}
64+
65+
version := strings.TrimSpace(result.StdoutContents())
66+
parts := strings.SplitN(version, ".", 2)
67+
if len(parts) == 0 {
68+
return false
69+
}
70+
71+
major, err := strconv.Atoi(parts[0])
72+
if err != nil {
73+
return false
74+
}
75+
76+
// Docker < 29 supports --link natively
77+
if major < 29 {
78+
return true
79+
}
80+
81+
// Docker >= 29 requires DOCKER_KEEP_DEPRECATED_LEGACY_LINKS_ENV_VARS=1
82+
// on the daemon. Test by creating two containers with --link and checking
83+
// if the link env vars are present.
84+
subprocess.CallExecCommand(subprocess.ExecCommandInput{
85+
Command: "docker",
86+
Args: []string{"rm", "-f", "omakase-link-test-target", "omakase-link-test-client"},
87+
})
88+
89+
_, err = subprocess.CallExecCommand(subprocess.ExecCommandInput{
90+
Command: "docker",
91+
Args: []string{"run", "-d", "--name", "omakase-link-test-target", "alpine", "sleep", "30"},
92+
})
93+
if err != nil {
94+
return false
95+
}
96+
defer subprocess.CallExecCommand(subprocess.ExecCommandInput{
97+
Command: "docker",
98+
Args: []string{"rm", "-f", "omakase-link-test-target", "omakase-link-test-client"},
99+
})
100+
101+
result, err = subprocess.CallExecCommand(subprocess.ExecCommandInput{
102+
Command: "docker",
103+
Args: []string{"run", "--rm", "--name", "omakase-link-test-client", "--link", "omakase-link-test-target:target", "alpine", "env"},
104+
})
105+
if err != nil {
106+
return false
107+
}
108+
109+
return strings.Contains(result.StdoutContents(), "TARGET_NAME=")
110+
}
111+
112+
func skipIfDockerLinkUnsupportedT(t *testing.T) {
113+
t.Helper()
114+
if !dockerLinkSupported() {
115+
t.Skip("skipping integration test: docker does not support legacy container links")
116+
}
117+
}
118+
54119
func TestIntegrationAppCreateAndDestroy(t *testing.T) {
55120
skipIfNoDokkuT(t)
56121

@@ -1021,3 +1086,133 @@ func TestIntegrationServiceCreateAndDestroy(t *testing.T) {
10211086
t.Errorf("expected state 'absent', got '%s'", result.State)
10221087
}
10231088
}
1089+
1090+
func TestIntegrationServiceLinkAndUnlink(t *testing.T) {
1091+
skipIfNoDokkuT(t)
1092+
skipIfPluginMissingT(t, "redis")
1093+
skipIfDockerLinkUnsupportedT(t)
1094+
1095+
appName := "omakase-test-link-app"
1096+
serviceName := "omakase-test-link-svc"
1097+
serviceType := "redis"
1098+
1099+
// ensure clean state
1100+
destroyApp(appName)
1101+
destroyService(serviceType, serviceName)
1102+
1103+
// create prerequisites
1104+
createApp(appName)
1105+
defer destroyApp(appName)
1106+
1107+
createTask := ServiceCreateTask{Service: serviceType, Name: serviceName, State: StatePresent}
1108+
createResult := createTask.Execute()
1109+
if createResult.Error != nil {
1110+
t.Fatalf("failed to create service: %v", createResult.Error)
1111+
}
1112+
defer func() {
1113+
// unlink before destroying service
1114+
unlinkTask := ServiceLinkTask{App: appName, Service: serviceType, Name: serviceName, State: StateAbsent}
1115+
unlinkTask.Execute()
1116+
destroyService(serviceType, serviceName)
1117+
}()
1118+
1119+
// verify service container is running via docker inspect
1120+
containerName := fmt.Sprintf("dokku.%s.%s", serviceType, serviceName)
1121+
inspectResult, err := subprocess.CallExecCommand(subprocess.ExecCommandInput{
1122+
Command: "docker",
1123+
Args: []string{"inspect", "--format", "{{.State.Running}}", containerName},
1124+
})
1125+
if err != nil {
1126+
t.Fatalf("failed to inspect service container: %v", err)
1127+
}
1128+
if strings.TrimSpace(inspectResult.StdoutContents()) != "true" {
1129+
t.Errorf("expected service container %q to be running", containerName)
1130+
}
1131+
1132+
// link service to app
1133+
linkTask := ServiceLinkTask{App: appName, Service: serviceType, Name: serviceName, State: StatePresent}
1134+
result := linkTask.Execute()
1135+
if result.Error != nil {
1136+
t.Fatalf("failed to link service: %v", result.Error)
1137+
}
1138+
if result.State != StatePresent {
1139+
t.Errorf("expected state 'present', got '%s'", result.State)
1140+
}
1141+
if !result.Changed {
1142+
t.Error("expected changed=true for new service link")
1143+
}
1144+
1145+
// verify REDIS_URL config var was set by the link
1146+
configResult, err := subprocess.CallExecCommand(subprocess.ExecCommandInput{
1147+
Command: "dokku",
1148+
Args: []string{"config:get", appName, "REDIS_URL"},
1149+
})
1150+
if err != nil {
1151+
t.Fatalf("failed to get REDIS_URL after link: %v", err)
1152+
}
1153+
redisURL := strings.TrimSpace(configResult.StdoutContents())
1154+
if redisURL == "" {
1155+
t.Error("expected REDIS_URL to be set after linking service")
1156+
}
1157+
if !strings.HasPrefix(redisURL, "redis://") {
1158+
t.Errorf("expected REDIS_URL to start with 'redis://', got %q", redisURL)
1159+
}
1160+
1161+
// verify the service container exposes the expected network alias via docker inspect
1162+
aliasResult, err := subprocess.CallExecCommand(subprocess.ExecCommandInput{
1163+
Command: "docker",
1164+
Args: []string{"inspect", "--format", "{{.Config.Hostname}}", containerName},
1165+
})
1166+
if err != nil {
1167+
t.Fatalf("failed to inspect service container hostname: %v", err)
1168+
}
1169+
if strings.TrimSpace(aliasResult.StdoutContents()) == "" {
1170+
t.Error("expected service container to have a hostname set")
1171+
}
1172+
1173+
// linking again should be idempotent
1174+
result = linkTask.Execute()
1175+
if result.Error != nil {
1176+
t.Fatalf("idempotent link failed: %v", result.Error)
1177+
}
1178+
if result.Changed {
1179+
t.Error("expected changed=false for existing service link")
1180+
}
1181+
if result.State != StatePresent {
1182+
t.Errorf("expected state 'present', got '%s'", result.State)
1183+
}
1184+
1185+
// unlink service from app
1186+
unlinkTask := ServiceLinkTask{App: appName, Service: serviceType, Name: serviceName, State: StateAbsent}
1187+
result = unlinkTask.Execute()
1188+
if result.Error != nil {
1189+
t.Fatalf("failed to unlink service: %v", result.Error)
1190+
}
1191+
if result.State != StateAbsent {
1192+
t.Errorf("expected state 'absent', got '%s'", result.State)
1193+
}
1194+
if !result.Changed {
1195+
t.Error("expected changed=true for service unlink")
1196+
}
1197+
1198+
// verify REDIS_URL config var was removed by the unlink
1199+
configResult, err = subprocess.CallExecCommand(subprocess.ExecCommandInput{
1200+
Command: "dokku",
1201+
Args: []string{"config:get", appName, "REDIS_URL"},
1202+
})
1203+
if err == nil && strings.TrimSpace(configResult.StdoutContents()) != "" {
1204+
t.Error("expected REDIS_URL to be unset after unlinking service")
1205+
}
1206+
1207+
// unlinking again should be idempotent
1208+
result = unlinkTask.Execute()
1209+
if result.Error != nil {
1210+
t.Fatalf("idempotent unlink failed: %v", result.Error)
1211+
}
1212+
if result.Changed {
1213+
t.Error("expected changed=false for already-unlinked service")
1214+
}
1215+
if result.State != StateAbsent {
1216+
t.Errorf("expected state 'absent', got '%s'", result.State)
1217+
}
1218+
}

tasks/main_test.go

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,7 @@ func TestRegisteredTasksExist(t *testing.T) {
166166
"dokku_resource_limit",
167167
"dokku_resource_reserve",
168168
"dokku_service_create",
169+
"dokku_service_link",
169170
"dokku_storage_ensure",
170171
"dokku_storage_mount",
171172
}
@@ -707,3 +708,92 @@ func TestGetTasksServiceCreateWithTemplateContext(t *testing.T) {
707708
t.Errorf("Name = %q, want %q", scTask.Name, "my-db")
708709
}
709710
}
711+
712+
func TestGetTasksServiceLinkTaskParsedCorrectly(t *testing.T) {
713+
data := []byte(`---
714+
- tasks:
715+
- name: link redis service
716+
dokku_service_link:
717+
app: my-app
718+
service: redis
719+
name: my-redis
720+
`)
721+
context := map[string]interface{}{}
722+
723+
tasks, err := GetTasks(data, context)
724+
if err != nil {
725+
t.Fatalf("GetTasks failed: %v", err)
726+
}
727+
728+
task := tasks.Get("link redis service")
729+
if task == nil {
730+
t.Fatal("task 'link redis service' not found")
731+
}
732+
733+
slTask, ok := task.(*ServiceLinkTask)
734+
if !ok {
735+
st, ok2 := task.(ServiceLinkTask)
736+
if !ok2 {
737+
t.Fatalf("task is not a ServiceLinkTask (type is %T)", task)
738+
}
739+
slTask = &st
740+
}
741+
742+
if slTask.App != "my-app" {
743+
t.Errorf("App = %q, want %q", slTask.App, "my-app")
744+
}
745+
if slTask.Service != "redis" {
746+
t.Errorf("Service = %q, want %q", slTask.Service, "redis")
747+
}
748+
if slTask.Name != "my-redis" {
749+
t.Errorf("Name = %q, want %q", slTask.Name, "my-redis")
750+
}
751+
if slTask.DesiredState() != StatePresent {
752+
t.Errorf("expected default state 'present', got %q", slTask.DesiredState())
753+
}
754+
}
755+
756+
func TestGetTasksServiceLinkWithTemplateContext(t *testing.T) {
757+
data := []byte(`---
758+
- tasks:
759+
- name: link {{ .service_type }} service
760+
dokku_service_link:
761+
app: {{ .app_name }}
762+
service: {{ .service_type }}
763+
name: {{ .service_name }}
764+
`)
765+
context := map[string]interface{}{
766+
"app_name": "my-app",
767+
"service_type": "postgres",
768+
"service_name": "my-db",
769+
}
770+
771+
tasks, err := GetTasks(data, context)
772+
if err != nil {
773+
t.Fatalf("GetTasks failed: %v", err)
774+
}
775+
776+
task := tasks.Get("link postgres service")
777+
if task == nil {
778+
t.Fatal("task 'link postgres service' not found")
779+
}
780+
781+
slTask, ok := task.(*ServiceLinkTask)
782+
if !ok {
783+
st, ok2 := task.(ServiceLinkTask)
784+
if !ok2 {
785+
t.Fatalf("task is not a ServiceLinkTask (type is %T)", task)
786+
}
787+
slTask = &st
788+
}
789+
790+
if slTask.App != "my-app" {
791+
t.Errorf("App = %q, want %q", slTask.App, "my-app")
792+
}
793+
if slTask.Service != "postgres" {
794+
t.Errorf("Service = %q, want %q", slTask.Service, "postgres")
795+
}
796+
if slTask.Name != "my-db" {
797+
t.Errorf("Name = %q, want %q", slTask.Name, "my-db")
798+
}
799+
}

0 commit comments

Comments
 (0)