diff --git a/docs/dokku_http_auth.md b/docs/dokku_http_auth.md new file mode 100644 index 0000000..59ca22d --- /dev/null +++ b/docs/dokku_http_auth.md @@ -0,0 +1,20 @@ +# dokku_http_auth + +Manages HTTP authentication for a given dokku application + +## Enable HTTP authentication for an app + +```yaml +dokku_http_auth: + app: hello-world + username: admin + password: secret +``` + +## Disable HTTP authentication for an app + +```yaml +dokku_http_auth: + app: hello-world + state: absent +``` diff --git a/tasks/http_auth_task.go b/tasks/http_auth_task.go new file mode 100644 index 0000000..096089c --- /dev/null +++ b/tasks/http_auth_task.go @@ -0,0 +1,204 @@ +package tasks + +import ( + "fmt" + "omakase/subprocess" + "strings" + + yaml "gopkg.in/yaml.v3" +) + +// HttpAuthTask manages HTTP authentication for a dokku application +type HttpAuthTask struct { + // App is the name of the app + App string `required:"true" yaml:"app"` + + // Username is the HTTP auth username + Username string `required:"false" yaml:"username,omitempty"` + + // Password is the HTTP auth password + Password string `required:"false" yaml:"password,omitempty"` + + // State is the state of the HTTP auth + State State `required:"false" yaml:"state,omitempty" default:"present" options:"present,absent"` +} + +// HttpAuthTaskExample contains an example of an HttpAuthTask +type HttpAuthTaskExample struct { + // Name is the task name holding the HttpAuthTask description + Name string `yaml:"-"` + + // DokkuHttpAuth is the HttpAuthTask configuration + DokkuHttpAuth HttpAuthTask `yaml:"dokku_http_auth"` +} + +// DesiredState returns the desired state of the HTTP auth +func (t HttpAuthTask) DesiredState() State { + return t.State +} + +// Doc returns the docblock for the HTTP auth task +func (t HttpAuthTask) Doc() string { + return "Manages HTTP authentication for a given dokku application" +} + +// Examples returns a list of HttpAuthTaskExamples as yaml +func (t HttpAuthTask) Examples() ([]Doc, error) { + examples := []HttpAuthTaskExample{ + { + Name: "Enable HTTP authentication for an app", + DokkuHttpAuth: HttpAuthTask{ + App: "hello-world", + Username: "admin", + Password: "secret", + }, + }, + { + Name: "Disable HTTP authentication for an app", + DokkuHttpAuth: HttpAuthTask{ + App: "hello-world", + 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 enables or disables HTTP authentication for an app +func (t HttpAuthTask) Execute() TaskOutputState { + if t.State == StatePresent && t.Username == "" { + return TaskOutputState{ + Error: fmt.Errorf("username is required when state is present"), + } + } + if t.State == StatePresent && t.Password == "" { + return TaskOutputState{ + Error: fmt.Errorf("password is required when state is present"), + } + } + + funcMap := map[State]func() TaskOutputState{ + "present": func() TaskOutputState { + return enableHttpAuth(t.App, t.Username, t.Password) + }, + "absent": func() TaskOutputState { + return disableHttpAuth(t.App) + }, + } + + fn, ok := funcMap[t.State] + if !ok { + return TaskOutputState{ + Error: fmt.Errorf("invalid state: %s", t.State), + } + } + return fn() +} + +// httpAuthEnabled checks if HTTP authentication is enabled for an app +func httpAuthEnabled(appName string) bool { + result, err := subprocess.CallExecCommand(subprocess.ExecCommandInput{ + Command: "dokku", + Args: []string{ + "--quiet", + "http-auth:report", + appName, + }, + }) + if err != nil { + return false + } + + lines := strings.SplitN(result.StdoutContents(), "\n", 2) + if len(lines) == 0 { + return false + } + + parts := strings.SplitN(lines[0], ":", 2) + if len(parts) < 2 { + return false + } + + return strings.TrimSpace(parts[1]) == "true" +} + +// enableHttpAuth enables HTTP authentication for an app +func enableHttpAuth(app, username, password string) TaskOutputState { + state := TaskOutputState{ + Changed: false, + State: "absent", + } + if httpAuthEnabled(app) { + state.State = "present" + return state + } + + result, err := subprocess.CallExecCommand(subprocess.ExecCommandInput{ + Command: "dokku", + Args: []string{ + "--quiet", + "http-auth:on", + app, + username, + password, + }, + }) + if err != nil { + state.Error = err + state.Message = result.StderrContents() + return state + } + + state.Changed = true + state.State = "present" + return state +} + +// disableHttpAuth disables HTTP authentication for an app +func disableHttpAuth(app string) TaskOutputState { + state := TaskOutputState{ + Changed: false, + State: "present", + } + if !httpAuthEnabled(app) { + state.State = "absent" + return state + } + + result, err := subprocess.CallExecCommand(subprocess.ExecCommandInput{ + Command: "dokku", + Args: []string{ + "--quiet", + "http-auth:off", + app, + }, + }) + if err != nil { + state.Error = err + state.Message = result.StderrContents() + return state + } + + state.Changed = true + state.State = "absent" + return state +} + +// init registers the HttpAuthTask with the task registry +func init() { + RegisterTask(&HttpAuthTask{}) +} diff --git a/tasks/integration_test.go b/tasks/integration_test.go index 75f971f..21893b7 100644 --- a/tasks/integration_test.go +++ b/tasks/integration_test.go @@ -1717,3 +1717,82 @@ func TestIntegrationServiceLinkAndUnlink(t *testing.T) { t.Errorf("expected state 'absent', got '%s'", result.State) } } + +func TestIntegrationHttpAuth(t *testing.T) { + skipIfNoDokkuT(t) + skipIfPluginMissingT(t, "http-auth") + + appName := "omakase-test-http-auth" + + destroyApp(appName) + createApp(appName) + defer destroyApp(appName) + + // enable http auth + enableTask := HttpAuthTask{ + App: appName, + Username: "testuser", + Password: "testpass", + State: StatePresent, + } + result := enableTask.Execute() + if result.Error != nil { + t.Fatalf("failed to enable http auth: %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 enabling http auth") + } + + // verify auth is enabled via http-auth:report + if !httpAuthEnabled(appName) { + t.Error("expected http auth to be enabled after enable") + } + + // enabling again should be idempotent + result = enableTask.Execute() + if result.Error != nil { + t.Fatalf("idempotent enable failed: %v", result.Error) + } + if result.Changed { + t.Error("expected changed=false for already-enabled http auth") + } + if result.State != StatePresent { + t.Errorf("expected state 'present', got '%s'", result.State) + } + + // disable http auth + disableTask := HttpAuthTask{ + App: appName, + State: StateAbsent, + } + result = disableTask.Execute() + if result.Error != nil { + t.Fatalf("failed to disable http auth: %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 disabling http auth") + } + + // verify auth is disabled via http-auth:report + if httpAuthEnabled(appName) { + t.Error("expected http auth to be disabled after disable") + } + + // disabling again should be idempotent + result = disableTask.Execute() + if result.Error != nil { + t.Fatalf("idempotent disable failed: %v", result.Error) + } + if result.Changed { + t.Error("expected changed=false for already-disabled http auth") + } + 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 28c04d2..a02989b 100644 --- a/tasks/main_test.go +++ b/tasks/main_test.go @@ -161,6 +161,7 @@ func TestRegisteredTasksExist(t *testing.T) { "dokku_domains_toggle", "dokku_git_from_image", "dokku_git_sync", + "dokku_http_auth", "dokku_network", "dokku_network_property", "dokku_ports", @@ -979,3 +980,47 @@ func TestGetTasksDomainsTaskGlobalParsedCorrectly(t *testing.T) { t.Errorf("expected state 'set', got %q", dTask.DesiredState()) } } + +func TestGetTasksHttpAuthTaskParsedCorrectly(t *testing.T) { + data := []byte(`--- +- tasks: + - name: enable http auth + dokku_http_auth: + app: test-app + username: admin + password: secret +`) + context := map[string]interface{}{} + + tasks, err := GetTasks(data, context) + if err != nil { + t.Fatalf("GetTasks failed: %v", err) + } + + task := tasks.Get("enable http auth") + if task == nil { + t.Fatal("task 'enable http auth' not found") + } + + haTask, ok := task.(*HttpAuthTask) + if !ok { + ht, ok2 := task.(HttpAuthTask) + if !ok2 { + t.Fatalf("task is not an HttpAuthTask (type is %T)", task) + } + haTask = &ht + } + + if haTask.App != "test-app" { + t.Errorf("App = %q, want %q", haTask.App, "test-app") + } + if haTask.Username != "admin" { + t.Errorf("Username = %q, want %q", haTask.Username, "admin") + } + if haTask.Password != "secret" { + t.Errorf("Password = %q, want %q", haTask.Password, "secret") + } + if haTask.DesiredState() != StatePresent { + t.Errorf("expected default state 'present', got %q", haTask.DesiredState()) + } +} diff --git a/tasks/task_execute_test.go b/tasks/task_execute_test.go index 925b0f9..b8ab627 100644 --- a/tasks/task_execute_test.go +++ b/tasks/task_execute_test.go @@ -138,6 +138,48 @@ func TestDomainsToggleTaskInvalidState(t *testing.T) { } } +func TestHttpAuthTaskInvalidState(t *testing.T) { + task := HttpAuthTask{App: "test-app", Username: "admin", Password: "secret", State: "invalid"} + result := task.Execute() + if result.Error == nil { + t.Fatal("Execute with invalid state should return an error") + } +} + +func TestHttpAuthTaskDesiredState(t *testing.T) { + task := HttpAuthTask{App: "test-app", State: StatePresent} + if task.DesiredState() != StatePresent { + t.Errorf("expected state 'present', got '%s'", task.DesiredState()) + } + + task = HttpAuthTask{App: "test-app", State: StateAbsent} + if task.DesiredState() != StateAbsent { + t.Errorf("expected state 'absent', got '%s'", task.DesiredState()) + } +} + +func TestHttpAuthTaskPresentWithoutUsername(t *testing.T) { + task := HttpAuthTask{App: "test-app", Password: "secret", State: StatePresent} + result := task.Execute() + if result.Error == nil { + t.Fatal("expected error when present state has no username") + } + if !strings.Contains(result.Error.Error(), "username is required") { + t.Errorf("unexpected error: %v", result.Error) + } +} + +func TestHttpAuthTaskPresentWithoutPassword(t *testing.T) { + task := HttpAuthTask{App: "test-app", Username: "admin", State: StatePresent} + result := task.Execute() + if result.Error == nil { + t.Fatal("expected error when present state has no password") + } + if !strings.Contains(result.Error.Error(), "password is required") { + t.Errorf("unexpected error: %v", result.Error) + } +} + func TestGitFromImageTaskInvalidState(t *testing.T) { task := GitFromImageTask{App: "test-app", Image: "nginx", State: "invalid"} result := task.Execute() @@ -451,6 +493,8 @@ func TestAllTasksDesiredState(t *testing.T) { {"DomainsToggleTask present", &DomainsToggleTask{App: "test", State: StatePresent}, StatePresent}, {"DomainsToggleTask absent", &DomainsToggleTask{App: "test", State: StateAbsent}, StateAbsent}, {"GitFromImageTask deployed", &GitFromImageTask{App: "test", Image: "nginx", State: StateDeployed}, StateDeployed}, + {"HttpAuthTask present", &HttpAuthTask{App: "test", Username: "admin", Password: "secret", State: StatePresent}, StatePresent}, + {"HttpAuthTask absent", &HttpAuthTask{App: "test", State: StateAbsent}, StateAbsent}, {"GitSyncTask present", &GitSyncTask{App: "test", Remote: "https://example.com/repo", State: StatePresent}, StatePresent}, {"NetworkTask present", &NetworkTask{Name: "test", State: StatePresent}, StatePresent}, {"NetworkTask absent", &NetworkTask{Name: "test", State: StateAbsent}, StateAbsent}, @@ -658,7 +702,7 @@ func TestAllTasksExamplesReturnNoError(t *testing.T) { } func TestRegisteredTaskCount(t *testing.T) { - expected := 19 + expected := 20 if got := len(RegisteredTasks); got != expected { t.Errorf("expected %d registered tasks, got %d", expected, got) } @@ -677,6 +721,7 @@ func TestTaskDocStrings(t *testing.T) { {&DomainsToggleTask{}, "Enables or disables the domains plugin for a given dokku application"}, {&GitFromImageTask{}, "Deploys a git repository from a docker image"}, {&GitSyncTask{}, "Syncs a git repository to a dokku application"}, + {&HttpAuthTask{}, "Manages HTTP authentication for a given dokku application"}, {&NetworkTask{}, "Creates or destroys a Docker network"}, {&NetworkPropertyTask{}, "Manages the network property for a given dokku application"}, {&PortsTask{}, "Manages the ports for a given dokku application"},