diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 93010dd..792903d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -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 diff --git a/docs/dokku_service_link.md b/docs/dokku_service_link.md new file mode 100644 index 0000000..4b6decc --- /dev/null +++ b/docs/dokku_service_link.md @@ -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 +``` diff --git a/tasks/integration_test.go b/tasks/integration_test.go index 2f88c26..0e5cf7d 100644 --- a/tasks/integration_test.go +++ b/tasks/integration_test.go @@ -1,8 +1,10 @@ package tasks import ( + "fmt" "omakase/subprocess" "os" + "strconv" "strings" "testing" ) @@ -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) @@ -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) + } +} diff --git a/tasks/main_test.go b/tasks/main_test.go index cf13382..16886e3 100644 --- a/tasks/main_test.go +++ b/tasks/main_test.go @@ -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", } @@ -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") + } +} diff --git a/tasks/service_link_task.go b/tasks/service_link_task.go new file mode 100644 index 0000000..12b2951 --- /dev/null +++ b/tasks/service_link_task.go @@ -0,0 +1,211 @@ +package tasks + +import ( + "fmt" + "omakase/subprocess" + + yaml "gopkg.in/yaml.v3" +) + +// ServiceLinkTask links or unlinks a dokku service to an app +type ServiceLinkTask struct { + // App is the name of the app to link the service to + App string `required:"true" yaml:"app"` + + // Service is the type of service to link (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 link + State State `required:"false" yaml:"state,omitempty" default:"present" options:"present,absent"` +} + +// ServiceLinkTaskExample contains an example of a ServiceLinkTask +type ServiceLinkTaskExample struct { + // Name is the task name holding the ServiceLinkTask description + Name string `yaml:"-"` + + // ServiceLinkTask is the ServiceLinkTask configuration + ServiceLinkTask ServiceLinkTask `yaml:"dokku_service_link"` +} + +// DesiredState returns the desired state of the service link +func (t ServiceLinkTask) DesiredState() State { + return t.State +} + +// Doc returns the docblock for the service link task +func (t ServiceLinkTask) Doc() string { + return "Links or unlinks a dokku service to an app" +} + +// Examples returns a list of ServiceLinkTaskExamples as yaml +func (t ServiceLinkTask) Examples() ([]Doc, error) { + examples := []ServiceLinkTaskExample{ + { + Name: "Link a redis service named my-redis to my-app", + ServiceLinkTask: ServiceLinkTask{ + App: "my-app", + Service: "redis", + Name: "my-redis", + }, + }, + { + Name: "Link a postgres service named my-db to my-app", + ServiceLinkTask: ServiceLinkTask{ + App: "my-app", + Service: "postgres", + Name: "my-db", + }, + }, + { + Name: "Unlink a redis service named my-redis from my-app", + ServiceLinkTask: ServiceLinkTask{ + App: "my-app", + 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 links or unlinks a dokku service to an app +func (t ServiceLinkTask) Execute() TaskOutputState { + funcMap := map[State]func(string, string, string) TaskOutputState{ + "present": linkService, + "absent": unlinkService, + } + + fn, ok := funcMap[t.State] + if !ok { + return TaskOutputState{ + Error: fmt.Errorf("invalid state: %s", t.State), + } + } + return fn(t.Service, t.Name, t.App) +} + +// serviceLinked checks if a dokku service is linked to an app +func serviceLinked(service, name, app string) bool { + result, err := subprocess.CallExecCommand(subprocess.ExecCommandInput{ + Command: "dokku", + Args: []string{ + "--quiet", + fmt.Sprintf("%s:linked", service), + name, + app, + }, + }) + if err != nil { + return false + } + + return result.ExitCode == 0 +} + +// linkService links a dokku service to an app +func linkService(service, name, app string) TaskOutputState { + state := TaskOutputState{ + Changed: false, + State: "absent", + } + + if !serviceExists(service, name) { + state.Error = fmt.Errorf("service %s %s does not exist", service, name) + return state + } + + if !appExists(app) { + state.Error = fmt.Errorf("app %s does not exist", app) + return state + } + + if serviceLinked(service, name, app) { + state.State = "present" + return state + } + + result, err := subprocess.CallExecCommand(subprocess.ExecCommandInput{ + Command: "dokku", + Args: []string{ + "--quiet", + fmt.Sprintf("%s:link", service), + name, + app, + }, + }) + if err != nil { + state.Error = err + state.Message = result.StderrContents() + return state + } + + state.Changed = true + state.State = "present" + return state +} + +// unlinkService unlinks a dokku service from an app +func unlinkService(service, name, app string) TaskOutputState { + state := TaskOutputState{ + Changed: false, + State: "present", + } + + if !serviceExists(service, name) { + state.Error = fmt.Errorf("service %s %s does not exist", service, name) + return state + } + + if !appExists(app) { + state.Error = fmt.Errorf("app %s does not exist", app) + return state + } + + if !serviceLinked(service, name, app) { + state.State = "absent" + return state + } + + result, err := subprocess.CallExecCommand(subprocess.ExecCommandInput{ + Command: "dokku", + Args: []string{ + "--quiet", + fmt.Sprintf("%s:unlink", service), + name, + app, + }, + }) + if err != nil { + state.Error = err + state.Message = result.StderrContents() + return state + } + + state.Changed = true + state.State = "absent" + return state +} + +// init registers the ServiceLinkTask with the task registry +func init() { + RegisterTask(&ServiceLinkTask{}) +} diff --git a/tasks/task_execute_test.go b/tasks/task_execute_test.go index 95a4849..6065ca1 100644 --- a/tasks/task_execute_test.go +++ b/tasks/task_execute_test.go @@ -239,6 +239,26 @@ func TestServiceCreateTaskDesiredState(t *testing.T) { } } +func TestServiceLinkTaskInvalidState(t *testing.T) { + task := ServiceLinkTask{App: "test-app", 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 TestServiceLinkTaskDesiredState(t *testing.T) { + task := ServiceLinkTask{App: "test-app", Service: "redis", Name: "test-service", State: StatePresent} + if task.DesiredState() != StatePresent { + t.Errorf("expected state 'present', got '%s'", task.DesiredState()) + } + + task = ServiceLinkTask{App: "test-app", 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", @@ -318,6 +338,8 @@ func TestAllTasksDesiredState(t *testing.T) { {"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}, + {"ServiceLinkTask present", &ServiceLinkTask{App: "test", Service: "redis", Name: "test", State: StatePresent}, StatePresent}, + {"ServiceLinkTask absent", &ServiceLinkTask{App: "test", 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}, @@ -509,7 +531,7 @@ func TestAllTasksExamplesReturnNoError(t *testing.T) { } func TestRegisteredTaskCount(t *testing.T) { - expected := 15 + expected := 16 if got := len(RegisteredTasks); got != expected { t.Errorf("expected %d registered tasks, got %d", expected, got) } @@ -532,6 +554,7 @@ func TestTaskDocStrings(t *testing.T) { {&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"}, + {&ServiceLinkTask{}, "Links or unlinks a dokku service to an app"}, {&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"},