Skip to content
Open
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
48 changes: 48 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
115 changes: 115 additions & 0 deletions internal/cli/serve.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 {
Expand Down
133 changes: 133 additions & 0 deletions internal/cli/serve_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
}