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
6 changes: 6 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,12 @@ jobs:
- uses: actions/setup-go@v6
with:
go-version-file: go.mod
- name: enable docker legacy link support
run: |
sudo mkdir -p /etc/systemd/system/docker.service.d
printf '[Service]\nEnvironment="DOCKER_KEEP_DEPRECATED_LEGACY_LINKS_ENV_VARS=1"\n' | sudo tee /etc/systemd/system/docker.service.d/legacy-links.conf
sudo systemctl daemon-reload
sudo systemctl restart docker
- name: install dokku
run: |
sudo mkdir -p /etc/apt/keyrings
Expand Down
31 changes: 31 additions & 0 deletions docs/dokku_service_link.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# dokku_service_link

Links or unlinks a dokku service to an app

## Link a redis service named my-redis to my-app

```yaml
dokku_service_link:
app: my-app
service: redis
name: my-redis
```

## Link a postgres service named my-db to my-app

```yaml
dokku_service_link:
app: my-app
service: postgres
name: my-db
```

## Unlink a redis service named my-redis from my-app

```yaml
dokku_service_link:
app: my-app
service: redis
name: my-redis
state: absent
```
195 changes: 195 additions & 0 deletions tasks/integration_test.go
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
package tasks

import (
"fmt"
"omakase/subprocess"
"os"
"strconv"
"strings"
"testing"
)
Expand Down Expand Up @@ -51,6 +53,69 @@ func skipIfPluginMissingT(t *testing.T, plugin string) {
}
}

func dockerLinkSupported() bool {
result, err := subprocess.CallExecCommand(subprocess.ExecCommandInput{
Command: "docker",
Args: []string{"version", "--format", "{{.Server.Version}}"},
})
if err != nil {
return false
}

version := strings.TrimSpace(result.StdoutContents())
parts := strings.SplitN(version, ".", 2)
if len(parts) == 0 {
return false
}

major, err := strconv.Atoi(parts[0])
if err != nil {
return false
}

// Docker < 29 supports --link natively
if major < 29 {
return true
}

// Docker >= 29 requires DOCKER_KEEP_DEPRECATED_LEGACY_LINKS_ENV_VARS=1
// on the daemon. Test by creating two containers with --link and checking
// if the link env vars are present.
subprocess.CallExecCommand(subprocess.ExecCommandInput{
Command: "docker",
Args: []string{"rm", "-f", "omakase-link-test-target", "omakase-link-test-client"},
})

_, err = subprocess.CallExecCommand(subprocess.ExecCommandInput{
Command: "docker",
Args: []string{"run", "-d", "--name", "omakase-link-test-target", "alpine", "sleep", "30"},
})
if err != nil {
return false
}
defer subprocess.CallExecCommand(subprocess.ExecCommandInput{
Command: "docker",
Args: []string{"rm", "-f", "omakase-link-test-target", "omakase-link-test-client"},
})

result, err = subprocess.CallExecCommand(subprocess.ExecCommandInput{
Command: "docker",
Args: []string{"run", "--rm", "--name", "omakase-link-test-client", "--link", "omakase-link-test-target:target", "alpine", "env"},
})
if err != nil {
return false
}

return strings.Contains(result.StdoutContents(), "TARGET_NAME=")
}

func skipIfDockerLinkUnsupportedT(t *testing.T) {
t.Helper()
if !dockerLinkSupported() {
t.Skip("skipping integration test: docker does not support legacy container links")
}
}

func TestIntegrationAppCreateAndDestroy(t *testing.T) {
skipIfNoDokkuT(t)

Expand Down Expand Up @@ -1021,3 +1086,133 @@ func TestIntegrationServiceCreateAndDestroy(t *testing.T) {
t.Errorf("expected state 'absent', got '%s'", result.State)
}
}

func TestIntegrationServiceLinkAndUnlink(t *testing.T) {
skipIfNoDokkuT(t)
skipIfPluginMissingT(t, "redis")
skipIfDockerLinkUnsupportedT(t)

appName := "omakase-test-link-app"
serviceName := "omakase-test-link-svc"
serviceType := "redis"

// ensure clean state
destroyApp(appName)
destroyService(serviceType, serviceName)

// create prerequisites
createApp(appName)
defer destroyApp(appName)

createTask := ServiceCreateTask{Service: serviceType, Name: serviceName, State: StatePresent}
createResult := createTask.Execute()
if createResult.Error != nil {
t.Fatalf("failed to create service: %v", createResult.Error)
}
defer func() {
// unlink before destroying service
unlinkTask := ServiceLinkTask{App: appName, Service: serviceType, Name: serviceName, State: StateAbsent}
unlinkTask.Execute()
destroyService(serviceType, serviceName)
}()

// verify service container is running via docker inspect
containerName := fmt.Sprintf("dokku.%s.%s", serviceType, serviceName)
inspectResult, err := subprocess.CallExecCommand(subprocess.ExecCommandInput{
Command: "docker",
Args: []string{"inspect", "--format", "{{.State.Running}}", containerName},
})
if err != nil {
t.Fatalf("failed to inspect service container: %v", err)
}
if strings.TrimSpace(inspectResult.StdoutContents()) != "true" {
t.Errorf("expected service container %q to be running", containerName)
}

// link service to app
linkTask := ServiceLinkTask{App: appName, Service: serviceType, Name: serviceName, State: StatePresent}
result := linkTask.Execute()
if result.Error != nil {
t.Fatalf("failed to link 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 link")
}

// verify REDIS_URL config var was set by the link
configResult, err := subprocess.CallExecCommand(subprocess.ExecCommandInput{
Command: "dokku",
Args: []string{"config:get", appName, "REDIS_URL"},
})
if err != nil {
t.Fatalf("failed to get REDIS_URL after link: %v", err)
}
redisURL := strings.TrimSpace(configResult.StdoutContents())
if redisURL == "" {
t.Error("expected REDIS_URL to be set after linking service")
}
if !strings.HasPrefix(redisURL, "redis://") {
t.Errorf("expected REDIS_URL to start with 'redis://', got %q", redisURL)
}

// verify the service container exposes the expected network alias via docker inspect
aliasResult, err := subprocess.CallExecCommand(subprocess.ExecCommandInput{
Command: "docker",
Args: []string{"inspect", "--format", "{{.Config.Hostname}}", containerName},
})
if err != nil {
t.Fatalf("failed to inspect service container hostname: %v", err)
}
if strings.TrimSpace(aliasResult.StdoutContents()) == "" {
t.Error("expected service container to have a hostname set")
}

// linking again should be idempotent
result = linkTask.Execute()
if result.Error != nil {
t.Fatalf("idempotent link failed: %v", result.Error)
}
if result.Changed {
t.Error("expected changed=false for existing service link")
}
if result.State != StatePresent {
t.Errorf("expected state 'present', got '%s'", result.State)
}

// unlink service from app
unlinkTask := ServiceLinkTask{App: appName, Service: serviceType, Name: serviceName, State: StateAbsent}
result = unlinkTask.Execute()
if result.Error != nil {
t.Fatalf("failed to unlink 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 unlink")
}

// verify REDIS_URL config var was removed by the unlink
configResult, err = subprocess.CallExecCommand(subprocess.ExecCommandInput{
Command: "dokku",
Args: []string{"config:get", appName, "REDIS_URL"},
})
if err == nil && strings.TrimSpace(configResult.StdoutContents()) != "" {
t.Error("expected REDIS_URL to be unset after unlinking service")
}

// unlinking again should be idempotent
result = unlinkTask.Execute()
if result.Error != nil {
t.Fatalf("idempotent unlink failed: %v", result.Error)
}
if result.Changed {
t.Error("expected changed=false for already-unlinked service")
}
if result.State != StateAbsent {
t.Errorf("expected state 'absent', got '%s'", result.State)
}
}
90 changes: 90 additions & 0 deletions tasks/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,7 @@ func TestRegisteredTasksExist(t *testing.T) {
"dokku_resource_limit",
"dokku_resource_reserve",
"dokku_service_create",
"dokku_service_link",
"dokku_storage_ensure",
"dokku_storage_mount",
}
Expand Down Expand Up @@ -707,3 +708,92 @@ func TestGetTasksServiceCreateWithTemplateContext(t *testing.T) {
t.Errorf("Name = %q, want %q", scTask.Name, "my-db")
}
}

func TestGetTasksServiceLinkTaskParsedCorrectly(t *testing.T) {
data := []byte(`---
- tasks:
- name: link redis service
dokku_service_link:
app: my-app
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("link redis service")
if task == nil {
t.Fatal("task 'link redis service' not found")
}

slTask, ok := task.(*ServiceLinkTask)
if !ok {
st, ok2 := task.(ServiceLinkTask)
if !ok2 {
t.Fatalf("task is not a ServiceLinkTask (type is %T)", task)
}
slTask = &st
}

if slTask.App != "my-app" {
t.Errorf("App = %q, want %q", slTask.App, "my-app")
}
if slTask.Service != "redis" {
t.Errorf("Service = %q, want %q", slTask.Service, "redis")
}
if slTask.Name != "my-redis" {
t.Errorf("Name = %q, want %q", slTask.Name, "my-redis")
}
if slTask.DesiredState() != StatePresent {
t.Errorf("expected default state 'present', got %q", slTask.DesiredState())
}
}

func TestGetTasksServiceLinkWithTemplateContext(t *testing.T) {
data := []byte(`---
- tasks:
- name: link {{ .service_type }} service
dokku_service_link:
app: {{ .app_name }}
service: {{ .service_type }}
name: {{ .service_name }}
`)
context := map[string]interface{}{
"app_name": "my-app",
"service_type": "postgres",
"service_name": "my-db",
}

tasks, err := GetTasks(data, context)
if err != nil {
t.Fatalf("GetTasks failed: %v", err)
}

task := tasks.Get("link postgres service")
if task == nil {
t.Fatal("task 'link postgres service' not found")
}

slTask, ok := task.(*ServiceLinkTask)
if !ok {
st, ok2 := task.(ServiceLinkTask)
if !ok2 {
t.Fatalf("task is not a ServiceLinkTask (type is %T)", task)
}
slTask = &st
}

if slTask.App != "my-app" {
t.Errorf("App = %q, want %q", slTask.App, "my-app")
}
if slTask.Service != "postgres" {
t.Errorf("Service = %q, want %q", slTask.Service, "postgres")
}
if slTask.Name != "my-db" {
t.Errorf("Name = %q, want %q", slTask.Name, "my-db")
}
}
Loading
Loading