diff --git a/docs/dokku_ps_scale.md b/docs/dokku_ps_scale.md new file mode 100644 index 0000000..8a6b033 --- /dev/null +++ b/docs/dokku_ps_scale.md @@ -0,0 +1,25 @@ +# dokku_ps_scale + +Manages the process scale for a given dokku application + +## Scale web and worker processes + +```yaml +dokku_ps_scale: + app: hello-world + scale: + web: 2 + worker: 1 + skip_deploy: false +``` + +## Scale web and worker processes without deploy + +```yaml +dokku_ps_scale: + app: hello-world + scale: + web: 4 + worker: 4 + skip_deploy: true +``` diff --git a/tasks/integration_test.go b/tasks/integration_test.go index 0e5cf7d..ec2aa27 100644 --- a/tasks/integration_test.go +++ b/tasks/integration_test.go @@ -116,6 +116,35 @@ func skipIfDockerLinkUnsupportedT(t *testing.T) { } } +// getCurrentContainerIDs reads the container IDs from dokku's internal +// CONTAINER files (e.g., /home/dokku/APP/CONTAINER.web.1) which are the +// authoritative source for the current deployment's containers. +func getCurrentContainerIDs(appName, processType string) ([]string, error) { + scale, err := getPsScale(appName) + if err != nil { + return nil, err + } + count, ok := scale[processType] + if !ok || count == 0 { + return nil, nil + } + var ids []string + for i := 1; i <= count; i++ { + result, err := subprocess.CallExecCommand(subprocess.ExecCommandInput{ + Command: "cat", + Args: []string{fmt.Sprintf("/home/dokku/%s/CONTAINER.%s.%d", appName, processType, i)}, + }) + if err != nil { + continue + } + id := strings.TrimSpace(result.StdoutContents()) + if id != "" { + ids = append(ids, id) + } + } + return ids, nil +} + func TestIntegrationAppCreateAndDestroy(t *testing.T) { skipIfNoDokkuT(t) @@ -699,7 +728,7 @@ func TestIntegrationGitFromImage(t *testing.T) { task := GitFromImageTask{ App: appName, - Image: "nginx:latest", + Image: "dokku/smoke-test-app:dockerfile", State: StateDeployed, } result := task.Execute() @@ -976,6 +1005,179 @@ func TestIntegrationResourceReserveProcessType(t *testing.T) { } } +func TestIntegrationPsScale(t *testing.T) { + skipIfNoDokkuT(t) + + appName := "omakase-test-psscale" + + // ensure clean state + destroyApp(appName) + createApp(appName) + defer destroyApp(appName) + + // deploy the smoke test app so we have running containers to scale + deployTask := GitFromImageTask{ + App: appName, + Image: "dokku/smoke-test-app:dockerfile", + State: StateDeployed, + } + deployResult := deployTask.Execute() + if deployResult.Error != nil { + t.Fatalf("failed to deploy app: %v", deployResult.Error) + } + + // verify initial web container count is 1 via docker ps + initialContainers, err := getCurrentContainerIDs(appName, "web") + if err != nil { + t.Fatalf("failed to list containers: %v", err) + } + if len(initialContainers) != 1 { + t.Fatalf("expected 1 initial web container, got %d", len(initialContainers)) + } + + // verify the initial container is running via docker inspect + inspectResult, err := subprocess.CallExecCommand(subprocess.ExecCommandInput{ + Command: "docker", + Args: []string{"inspect", "--format", "{{.State.Running}}", initialContainers[0]}, + }) + if err != nil { + t.Fatalf("failed to inspect initial container: %v", err) + } + if strings.TrimSpace(inspectResult.StdoutContents()) != "true" { + t.Errorf("expected initial container to be running") + } + + // scale web to 2 + scaleTask := PsScaleTask{ + App: appName, + Scale: map[string]int{"web": 2}, + State: StatePresent, + } + result := scaleTask.Execute() + if result.Error != nil { + t.Fatalf("failed to scale app: %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 scaling up") + } + + // clean up old containers and verify 2 web containers via docker ps + scaledContainers, err := getCurrentContainerIDs(appName, "web") + if err != nil { + t.Fatalf("failed to list containers after scale: %v", err) + } + if len(scaledContainers) != 2 { + t.Fatalf("expected 2 web containers after scaling, got %d", len(scaledContainers)) + } + + // verify each container is running via docker inspect + for _, containerID := range scaledContainers { + inspectResult, err := subprocess.CallExecCommand(subprocess.ExecCommandInput{ + Command: "docker", + Args: []string{"inspect", "--format", "{{.State.Running}}", containerID}, + }) + if err != nil { + t.Fatalf("failed to inspect container %s: %v", containerID, err) + } + if strings.TrimSpace(inspectResult.StdoutContents()) != "true" { + t.Errorf("expected container %s to be running", containerID) + } + } + + // scaling again should be idempotent + result = scaleTask.Execute() + if result.Error != nil { + t.Fatalf("idempotent scale failed: %v", result.Error) + } + if result.Changed { + t.Error("expected changed=false for unchanged scale") + } + + // scale back to 1 + scaleDownTask := PsScaleTask{ + App: appName, + Scale: map[string]int{"web": 1}, + State: StatePresent, + } + result = scaleDownTask.Execute() + if result.Error != nil { + t.Fatalf("failed to scale down: %v", result.Error) + } + if !result.Changed { + t.Error("expected changed=true for scaling down") + } + + // clean up old containers and verify 1 web container after scale down + finalContainers, err := getCurrentContainerIDs(appName, "web") + if err != nil { + t.Fatalf("failed to list containers after scale down: %v", err) + } + if len(finalContainers) != 1 { + t.Fatalf("expected 1 web container after scale down, got %d", len(finalContainers)) + } + + // verify the final container is running via docker inspect + inspectResult, err = subprocess.CallExecCommand(subprocess.ExecCommandInput{ + Command: "docker", + Args: []string{"inspect", "--format", "{{.State.Running}}", finalContainers[0]}, + }) + if err != nil { + t.Fatalf("failed to inspect final container: %v", err) + } + if strings.TrimSpace(inspectResult.StdoutContents()) != "true" { + t.Errorf("expected final container to be running") + } +} + +func TestIntegrationPsScaleSkipDeploy(t *testing.T) { + skipIfNoDokkuT(t) + + appName := "omakase-test-psscale-sd" + + destroyApp(appName) + createApp(appName) + defer destroyApp(appName) + + // scale with skip_deploy on an undeployed app + scaleTask := PsScaleTask{ + App: appName, + Scale: map[string]int{"web": 2, "worker": 1}, + SkipDeploy: true, + State: StatePresent, + } + result := scaleTask.Execute() + if result.Error != nil { + t.Fatalf("failed to scale with skip_deploy: %v", result.Error) + } + if !result.Changed { + t.Error("expected changed=true for initial scale") + } + + // verify the scale was set correctly + scale, err := getPsScale(appName) + if err != nil { + t.Fatalf("failed to get ps scale: %v", err) + } + if scale["web"] != 2 { + t.Errorf("expected web=2, got web=%d", scale["web"]) + } + if scale["worker"] != 1 { + t.Errorf("expected worker=1, got worker=%d", scale["worker"]) + } + + // idempotent + result = scaleTask.Execute() + if result.Error != nil { + t.Fatalf("idempotent scale failed: %v", result.Error) + } + if result.Changed { + t.Error("expected changed=false for unchanged scale") + } +} + func TestIntegrationMultiTaskWorkflow(t *testing.T) { skipIfNoDokkuT(t) @@ -1170,6 +1372,58 @@ func TestIntegrationServiceLinkAndUnlink(t *testing.T) { t.Error("expected service container to have a hostname set") } + // deploy the smoke test app so we can verify the link inside a running container + deployTask := GitFromImageTask{ + App: appName, + Image: "dokku/smoke-test-app:dockerfile", + State: StateDeployed, + } + deployResult := deployTask.Execute() + if deployResult.Error != nil { + t.Fatalf("failed to deploy app: %v", deployResult.Error) + } + + // find the running app container + appContainerResult, err := subprocess.CallExecCommand(subprocess.ExecCommandInput{ + Command: "docker", + Args: []string{"ps", "--filter", fmt.Sprintf("label=com.dokku.app-name=%s", appName), "--filter", "label=com.dokku.process-type=web", "--format", "{{.ID}}"}, + }) + if err != nil { + t.Fatalf("failed to find app container: %v", err) + } + appContainerID := strings.TrimSpace(appContainerResult.StdoutContents()) + if appContainerID == "" { + t.Fatal("expected at least one running app container after deploy") + } + // take the first container if multiple lines + appContainerIDs := strings.Split(appContainerID, "\n") + appContainerID = appContainerIDs[0] + + // verify the app container is running via docker inspect + appInspectResult, err := subprocess.CallExecCommand(subprocess.ExecCommandInput{ + Command: "docker", + Args: []string{"inspect", "--format", "{{.State.Running}}", appContainerID}, + }) + if err != nil { + t.Fatalf("failed to inspect app container: %v", err) + } + if strings.TrimSpace(appInspectResult.StdoutContents()) != "true" { + t.Error("expected app container to be running") + } + + // verify REDIS_URL is present inside the running container via docker exec + execResult, err := subprocess.CallExecCommand(subprocess.ExecCommandInput{ + Command: "docker", + Args: []string{"exec", appContainerID, "env"}, + }) + if err != nil { + t.Fatalf("failed to exec env in app container: %v", err) + } + envOutput := execResult.StdoutContents() + if !strings.Contains(envOutput, "REDIS_URL=redis://") { + t.Error("expected REDIS_URL=redis://... to be present in app container environment") + } + // linking again should be idempotent result = linkTask.Execute() if result.Error != nil { diff --git a/tasks/main_test.go b/tasks/main_test.go index 16886e3..0f75d07 100644 --- a/tasks/main_test.go +++ b/tasks/main_test.go @@ -163,6 +163,7 @@ func TestRegisteredTasksExist(t *testing.T) { "dokku_network_property", "dokku_ports", "dokku_proxy_toggle", + "dokku_ps_scale", "dokku_resource_limit", "dokku_resource_reserve", "dokku_service_create", @@ -753,6 +754,55 @@ func TestGetTasksServiceLinkTaskParsedCorrectly(t *testing.T) { } } +func TestGetTasksPsScaleTaskParsedCorrectly(t *testing.T) { + data := []byte(`--- +- tasks: + - name: scale processes + dokku_ps_scale: + app: test-app + scale: + web: 2 + worker: 1 + skip_deploy: true +`) + context := map[string]interface{}{} + + tasks, err := GetTasks(data, context) + if err != nil { + t.Fatalf("GetTasks failed: %v", err) + } + + task := tasks.Get("scale processes") + if task == nil { + t.Fatal("task 'scale processes' not found") + } + + psTask, ok := task.(*PsScaleTask) + if !ok { + pt, ok2 := task.(PsScaleTask) + if !ok2 { + t.Fatalf("task is not a PsScaleTask (type is %T)", task) + } + psTask = &pt + } + + if psTask.App != "test-app" { + t.Errorf("App = %q, want %q", psTask.App, "test-app") + } + if len(psTask.Scale) != 2 { + t.Fatalf("expected 2 scale entries, got %d", len(psTask.Scale)) + } + if psTask.Scale["web"] != 2 { + t.Errorf("Scale[web] = %d, want %d", psTask.Scale["web"], 2) + } + if psTask.Scale["worker"] != 1 { + t.Errorf("Scale[worker] = %d, want %d", psTask.Scale["worker"], 1) + } + if !psTask.SkipDeploy { + t.Error("SkipDeploy = false, want true (YAML value should be preserved)") + } +} + func TestGetTasksServiceLinkWithTemplateContext(t *testing.T) { data := []byte(`--- - tasks: diff --git a/tasks/ps_scale_task.go b/tasks/ps_scale_task.go new file mode 100644 index 0000000..fea4fc5 --- /dev/null +++ b/tasks/ps_scale_task.go @@ -0,0 +1,197 @@ +package tasks + +import ( + "fmt" + "omakase/subprocess" + "strconv" + "strings" + + yaml "gopkg.in/yaml.v3" +) + +// PsScaleTask manages the process scale for a given dokku application +type PsScaleTask struct { + // App is the name of the app + App string `required:"true" yaml:"app"` + + // Scale is a map of process types to quantities + Scale map[string]int `required:"true" yaml:"scale"` + + // SkipDeploy skips the corresponding deploy + SkipDeploy bool `yaml:"skip_deploy" default:"false"` + + // State is the desired state of the process scale + State State `required:"false" yaml:"state,omitempty" default:"present" options:"present"` +} + +// PsScaleTaskExample contains an example of a PsScaleTask +type PsScaleTaskExample struct { + // Name is the task name holding the PsScaleTask description + Name string `yaml:"-"` + + // PsScaleTask is the PsScaleTask configuration + PsScaleTask PsScaleTask `yaml:"dokku_ps_scale"` +} + +// DesiredState returns the desired state of the process scale +func (t PsScaleTask) DesiredState() State { + return t.State +} + +// Doc returns the docblock for the ps scale task +func (t PsScaleTask) Doc() string { + return "Manages the process scale for a given dokku application" +} + +// Examples returns the examples for the ps scale task +func (t PsScaleTask) Examples() ([]Doc, error) { + examples := []PsScaleTaskExample{ + { + Name: "Scale web and worker processes", + PsScaleTask: PsScaleTask{ + App: "hello-world", + Scale: map[string]int{ + "web": 2, + "worker": 1, + }, + }, + }, + { + Name: "Scale web and worker processes without deploy", + PsScaleTask: PsScaleTask{ + App: "hello-world", + SkipDeploy: true, + Scale: map[string]int{ + "web": 4, + "worker": 4, + }, + }, + }, + } + + 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 sets the process scale for a given dokku application +func (t PsScaleTask) Execute() TaskOutputState { + if t.State == StatePresent { + if t.Scale == nil || len(t.Scale) == 0 { + return TaskOutputState{ + Error: fmt.Errorf("scale must be specified when state is present"), + } + } + } + + funcMap := map[State]func(PsScaleTask) TaskOutputState{ + "present": setPsScale, + } + + fn, ok := funcMap[t.State] + if !ok { + return TaskOutputState{ + Error: fmt.Errorf("invalid state: %s", t.State), + } + } + return fn(t) +} + +// getPsScale retrieves the current process scale for a given dokku application +func getPsScale(app string) (map[string]int, error) { + result, err := subprocess.CallExecCommand(subprocess.ExecCommandInput{ + Command: "dokku", + Args: []string{"--quiet", "ps:scale", app}, + }) + if err != nil { + return nil, err + } + + scale := map[string]int{} + for _, line := range strings.Split(result.StdoutContents(), "\n") { + // strip all whitespace from the line, matching the upstream ansible module + line = strings.Join(strings.Fields(line), "") + if !strings.Contains(line, ":") { + continue + } + parts := strings.SplitN(line, ":", 2) + if len(parts) != 2 { + continue + } + qty, err := strconv.Atoi(parts[1]) + if err != nil { + continue + } + scale[parts[0]] = qty + } + return scale, nil +} + +// setPsScale sets the process scale for a given dokku application +func setPsScale(t PsScaleTask) TaskOutputState { + state := TaskOutputState{ + Changed: false, + State: "absent", + } + + existing, err := getPsScale(t.App) + if err != nil { + state.Error = err + state.Message = err.Error() + return state + } + + var proctypesToScale []string + for proctype, qty := range t.Scale { + if existingQty, ok := existing[proctype]; ok && existingQty == qty { + continue + } + proctypesToScale = append(proctypesToScale, fmt.Sprintf("%s=%d", proctype, qty)) + } + + if len(proctypesToScale) == 0 { + state.State = "present" + return state + } + + args := []string{ + "ps:scale", + } + + if t.SkipDeploy { + args = append(args, "--skip-deploy") + } + + args = append(args, t.App) + args = append(args, proctypesToScale...) + + result, err := subprocess.CallExecCommand(subprocess.ExecCommandInput{ + Command: "dokku", + Args: args, + }) + if err != nil { + state.Error = err + state.Message = result.StderrContents() + return state + } + + state.Changed = true + state.State = "present" + return state +} + +// init registers the PsScaleTask with the task registry +func init() { + RegisterTask(&PsScaleTask{}) +} diff --git a/tasks/task_execute_test.go b/tasks/task_execute_test.go index 6065ca1..243a5cd 100644 --- a/tasks/task_execute_test.go +++ b/tasks/task_execute_test.go @@ -179,6 +179,37 @@ func TestStorageEnsureAbsentStateReturnsError(t *testing.T) { } } +func TestPsScaleTaskInvalidState(t *testing.T) { + task := PsScaleTask{App: "test-app", Scale: map[string]int{"web": 1}, State: "invalid"} + result := task.Execute() + if result.Error == nil { + t.Fatal("Execute with invalid state should return an error") + } +} + +func TestPsScaleTaskDesiredState(t *testing.T) { + task := PsScaleTask{App: "test-app", Scale: map[string]int{"web": 1}, State: StatePresent} + if task.DesiredState() != StatePresent { + t.Errorf("expected state 'present', got '%s'", task.DesiredState()) + } +} + +func TestPsScaleTaskEmptyScale(t *testing.T) { + task := PsScaleTask{App: "test-app", Scale: map[string]int{}, State: StatePresent} + result := task.Execute() + if result.Error == nil { + t.Fatal("Execute with empty scale and state=present should return an error") + } +} + +func TestPsScaleTaskNilScale(t *testing.T) { + task := PsScaleTask{App: "test-app", State: StatePresent} + result := task.Execute() + if result.Error == nil { + t.Fatal("Execute with nil scale and state=present should return an error") + } +} + func TestResourceLimitTaskInvalidState(t *testing.T) { task := ResourceLimitTask{ App: "test-app", @@ -332,6 +363,7 @@ func TestAllTasksDesiredState(t *testing.T) { {"NetworkPropertyTask absent", &NetworkPropertyTask{App: "test", Property: "bind-all-interfaces", State: StateAbsent}, StateAbsent}, {"PortsTask present", &PortsTask{App: "test", State: StatePresent}, StatePresent}, {"PortsTask absent", &PortsTask{App: "test", State: StateAbsent}, StateAbsent}, + {"PsScaleTask present", &PsScaleTask{App: "test", Scale: map[string]int{"web": 1}, State: StatePresent}, StatePresent}, {"ResourceLimitTask present", &ResourceLimitTask{App: "test", Resources: map[string]string{"cpu": "100"}, State: StatePresent}, StatePresent}, {"ResourceLimitTask absent", &ResourceLimitTask{App: "test", State: StateAbsent}, StateAbsent}, {"ResourceReserveTask present", &ResourceReserveTask{App: "test", Resources: map[string]string{"cpu": "100"}, State: StatePresent}, StatePresent}, @@ -531,7 +563,7 @@ func TestAllTasksExamplesReturnNoError(t *testing.T) { } func TestRegisteredTaskCount(t *testing.T) { - expected := 16 + expected := 17 if got := len(RegisteredTasks); got != expected { t.Errorf("expected %d registered tasks, got %d", expected, got) } @@ -551,6 +583,7 @@ func TestTaskDocStrings(t *testing.T) { {&GitSyncTask{}, "Syncs a git repository to a dokku application"}, {&NetworkPropertyTask{}, "Manages the network property for a given dokku application"}, {&PortsTask{}, "Manages the ports for a given dokku application"}, + {&PsScaleTask{}, "Manages the process scale 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"},