diff --git a/README.md b/README.md index 44bce59..975bb66 100644 --- a/README.md +++ b/README.md @@ -605,6 +605,54 @@ Print the data directory path (`${XDG_DATA_HOME}/dv`). dv data ``` +### dv serve + +Run a bearer-token-protected HTTP API for integrations such as desktop apps. + +```bash +dv serve [--host 127.0.0.1] [--port 7373] [--token TOKEN] +``` + +The first run generates a token and saves it in the dv config. Send it with every +request as `Authorization: Bearer TOKEN`. Commands that take time use Server-Sent +Events: `output` events contain `stream` and `text`, and the final `done` event +contains `exit_code`. + +Create and fully provision a new agent with `POST /containers/new`. The JSON body +supports all non-interactive `dv new` options: + +```json +{ + "name": "core-pr-12345", + "image": "discourse", + "template": "/path/to/template.yml", + "keep_on_failure": false, + "verbose": false, + "pr": "12345", + "branch": "", + "plugins": ["discourse-solved"], + "local_plugins": ["/path/to/local-plugin"], + "themes": ["discourse-air"], + "without_test_db": false +} +``` + +All fields are optional. If `name` is omitted, `dv new` generates one using its +normal naming rules. On success, the stream emits `result` with the final agent +name and its browser-facing hostname, followed by `done` with an exit code of +zero. The hostname is `localhost` when the local proxy is not active: + +```text +event: result +data: {"hostname":"core-pr-12345.dv.localhost","name":"core-pr-12345"} + +event: done +data: {"exit_code":0} +``` + +Explicit names must be Rails hostname-safe because API requests cannot respond to +the interactive confirmation used by the CLI for unsafe names. + ### dv config completion Generate shell completion scripts (rarely needed). For zsh: diff --git a/internal/cli/serve.go b/internal/cli/serve.go index ec05a38..aba1121 100644 --- a/internal/cli/serve.go +++ b/internal/cli/serve.go @@ -158,6 +158,9 @@ func handleServeRequest(w http.ResponseWriter, r *http.Request, configDir string case path == "containers": handleContainers(w, r, configDir) return + case path == "containers/new": + handleContainerNew(w, r, configDir) + return case strings.HasPrefix(path, "containers/"): handleContainer(w, r, configDir, strings.Split(path, "/")) return @@ -175,6 +178,118 @@ func handleServeRequest(w http.ResponseWriter, r *http.Request, configDir string } } +type newAgentRequest struct { + Name string `json:"name"` + Image string `json:"image"` + Template string `json:"template"` + KeepOnFailure bool `json:"keep_on_failure"` + Verbose bool `json:"verbose"` + PR string `json:"pr"` + Branch string `json:"branch"` + Plugins []string `json:"plugins"` + LocalPlugins []string `json:"local_plugins"` + Themes []string `json:"themes"` + WithoutTestDB bool `json:"without_test_db"` +} + +func (req newAgentRequest) args() []string { + args := []string{"new"} + if req.Image != "" { + args = append(args, "--image", req.Image) + } + if req.Template != "" { + args = append(args, "--template", req.Template) + } + if req.KeepOnFailure { + args = append(args, "--keep-on-failure") + } + if req.Verbose { + args = append(args, "--verbose") + } + if req.PR != "" { + args = append(args, "--pr", req.PR) + } + if req.Branch != "" { + args = append(args, "--branch", req.Branch) + } + for _, plugin := range req.Plugins { + args = append(args, "--plugin", plugin) + } + for _, plugin := range req.LocalPlugins { + args = append(args, "--plugin-local", plugin) + } + for _, theme := range req.Themes { + args = append(args, "--theme", theme) + } + if req.WithoutTestDB { + args = append(args, "--without-test-db") + } + if req.Name != "" { + args = append(args, req.Name) + } + return args +} + +func handleContainerNew(w http.ResponseWriter, r *http.Request, configDir string) { + if r.Method != http.MethodPost { + writeJSON(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + + var req newAgentRequest + if err := decodeJSON(r, &req); err != nil { + writeJSON(w, http.StatusBadRequest, err.Error()) + return + } + req.Name = strings.TrimSpace(req.Name) + if req.Name != "" && !isRailsHostnameSafe(req.Name) { + writeJSON(w, http.StatusBadRequest, "name must contain only numbers, letters, dashes, and dots") + return + } + + exe, err := os.Executable() + if err != nil { + writeJSON(w, http.StatusInternalServerError, err.Error()) + return + } + args := req.args() + streamSequence(w, func(sse *sseWriter) error { + err := runExecWithSSE(sse, func(stdout, stderr io.Writer) error { + cmd := exec.CommandContext(r.Context(), exe, args...) + cmd.Stdout = stdout + cmd.Stderr = stderr + return cmd.Run() + }) + if err != nil { + return err + } + cfg, err := config.LoadOrCreate(configDir) + if err != nil { + return err + } + name := strings.TrimSpace(cfg.SelectedAgent) + if name == "" { + name = cfg.DefaultContainer + } + hostname := "localhost" + if labels, err := labelsWithOverrides(name, cfg); err == nil { + hostname = containerResultHostname(labels) + } + sse.writeEvent("result", map[string]string{ + "name": name, + "hostname": hostname, + }) + return nil + }, true) +} + +func containerResultHostname(labels map[string]string) string { + if proxyHost, _, _, _, ok := localproxy.RouteFromLabels(labels); ok { + return proxyHost + } + return "localhost" +} + func handleStatus(w http.ResponseWriter, r *http.Request, configDir string) { cfg, err := config.LoadOrCreate(configDir) if err != nil { diff --git a/internal/cli/serve_test.go b/internal/cli/serve_test.go new file mode 100644 index 0000000..1b2df83 --- /dev/null +++ b/internal/cli/serve_test.go @@ -0,0 +1,133 @@ +package cli + +import ( + "bytes" + "net/http" + "net/http/httptest" + "reflect" + "strings" + "testing" + + "dv/internal/localproxy" +) + +func TestNewAgentRequestArgs(t *testing.T) { + t.Parallel() + + req := newAgentRequest{ + Name: "core-pr-123", + Image: "discourse", + Template: "/tmp/site.yml", + KeepOnFailure: true, + Verbose: true, + PR: "123", + Branch: "feature/test", + Plugins: []string{"discourse-solved", "owner/plugin"}, + LocalPlugins: []string{"/tmp/local-plugin"}, + Themes: []string{"discourse-air"}, + WithoutTestDB: true, + } + + want := []string{ + "new", + "--image", "discourse", + "--template", "/tmp/site.yml", + "--keep-on-failure", + "--verbose", + "--pr", "123", + "--branch", "feature/test", + "--plugin", "discourse-solved", + "--plugin", "owner/plugin", + "--plugin-local", "/tmp/local-plugin", + "--theme", "discourse-air", + "--without-test-db", + "core-pr-123", + } + if got := req.args(); !reflect.DeepEqual(got, want) { + t.Fatalf("args() = %#v, want %#v", got, want) + } +} + +func TestNewAgentRequestArgsMinimal(t *testing.T) { + t.Parallel() + + if got, want := (newAgentRequest{}).args(), []string{"new"}; !reflect.DeepEqual(got, want) { + t.Fatalf("args() = %#v, want %#v", got, want) + } +} + +func TestHandleContainerNewRejectsUnsupportedMethod(t *testing.T) { + t.Parallel() + + recorder := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodGet, "/containers/new", nil) + handleContainerNew(recorder, request, t.TempDir()) + + if recorder.Code != http.StatusMethodNotAllowed { + t.Fatalf("status = %d, want %d", recorder.Code, http.StatusMethodNotAllowed) + } +} + +func TestHandleContainerNewRejectsUnknownFields(t *testing.T) { + t.Parallel() + + recorder := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodPost, "/containers/new", bytes.NewBufferString(`{"unknown":true}`)) + handleContainerNew(recorder, request, t.TempDir()) + + if recorder.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want %d", recorder.Code, http.StatusBadRequest) + } + if !strings.Contains(recorder.Body.String(), "unknown field") { + t.Fatalf("body = %q, want unknown-field error", recorder.Body.String()) + } +} + +func TestHandleContainerNewRejectsUnsafeExplicitName(t *testing.T) { + t.Parallel() + + recorder := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodPost, "/containers/new", bytes.NewBufferString(`{"name":"not_safe"}`)) + handleContainerNew(recorder, request, t.TempDir()) + + if recorder.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want %d", recorder.Code, http.StatusBadRequest) + } + if !strings.Contains(recorder.Body.String(), "numbers, letters, dashes, and dots") { + t.Fatalf("body = %q, want hostname-safety error", recorder.Body.String()) + } +} + +func TestContainerResultHostname(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + labels map[string]string + want string + }{ + { + name: "local proxy hostname", + labels: map[string]string{ + localproxy.LabelHost: "core-pr-41785.dv.localhost", + localproxy.LabelTargetPort: "3001", + localproxy.LabelContainerPort: "3000", + }, + want: "core-pr-41785.dv.localhost", + }, + { + name: "no local proxy metadata", + labels: map[string]string{}, + want: "localhost", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + if got := containerResultHostname(test.labels); got != test.want { + t.Fatalf("containerResultHostname() = %q, want %q", got, test.want) + } + }) + } +}