Skip to content
Merged
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
6 changes: 6 additions & 0 deletions backend/internal/cli/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,12 @@ func (c *commandContext) postJSON(ctx context.Context, path string, body, out an
return c.doJSON(ctx, http.MethodPost, path, body, out)
}

// patchJSON sends body as JSON to PATCH /api/v1/<path> on the running daemon
// and decodes a 2xx response into out.
func (c *commandContext) patchJSON(ctx context.Context, path string, body, out any) error {
return c.doJSON(ctx, http.MethodPatch, path, body, out)
}

// deleteJSON sends DELETE /api/v1/<path> to the running daemon and decodes a
// 2xx response into out.
func (c *commandContext) deleteJSON(ctx context.Context, path string, out any) error {
Expand Down
8 changes: 8 additions & 0 deletions backend/internal/cli/dto_drift_e2e_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,14 @@ func (f *fakeSessionService) Kill(context.Context, domain.SessionID) (bool, erro
return false, nil
}

func (f *fakeSessionService) Cleanup(context.Context, domain.ProjectID) ([]domain.SessionID, error) {
return nil, nil
}

func (f *fakeSessionService) Rename(context.Context, domain.SessionID, string) error {
return nil
}

func (f *fakeSessionService) Send(context.Context, domain.SessionID, string) error {
return nil
}
Expand Down
121 changes: 121 additions & 0 deletions backend/internal/cli/orchestrator.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
package cli

import (
"context"
"fmt"
"sort"
"strings"
"time"

"github.com/spf13/cobra"
)

type orchestratorListOptions struct {
json bool
}

type orchestratorListOutput struct {
Data []sessionListEntry `json:"data"`
}

func newOrchestratorCommand(ctx *commandContext) *cobra.Command {
cmd := &cobra.Command{
Use: "orchestrator",
Short: "Manage orchestrator sessions",
}
cmd.AddCommand(newOrchestratorListCommand(ctx))
return cmd
}

func newOrchestratorListCommand(ctx *commandContext) *cobra.Command {
var opts orchestratorListOptions
cmd := &cobra.Command{
Use: "ls",
Aliases: []string{"list"},
Short: "List orchestrator sessions",
Args: noArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
return ctx.listOrchestrators(cmd.Context(), cmd, opts)
},
}
cmd.Flags().BoolVar(&opts.json, "json", false, "Output as JSON")
return cmd
}

func (c *commandContext) listOrchestrators(ctx context.Context, cmd *cobra.Command, opts orchestratorListOptions) error {
var res sessionListResponse
if err := c.getJSON(ctx, "orchestrators", &res); err != nil {
return err
}
orchestrators := filterAndSortOrchestrators(res.Sessions)
if opts.json {
return writeJSON(cmd.OutOrStdout(), orchestratorListOutput{Data: sessionListEntries(orchestrators)})
}
return writeOrchestratorList(cmd, orchestrators)
}

func filterAndSortOrchestrators(sessions []sessionDTO) []sessionDTO {
out := make([]sessionDTO, 0, len(sessions))
for _, sess := range sessions {
if sess.Kind != "orchestrator" {
continue
}
out = append(out, sess)
}
sort.Slice(out, func(i, j int) bool {
if out[i].ProjectID != out[j].ProjectID {
return out[i].ProjectID < out[j].ProjectID
}
return out[i].ID < out[j].ID
})
return out
}

func writeOrchestratorList(cmd *cobra.Command, sessions []sessionDTO) error {
out := cmd.OutOrStdout()
if len(sessions) == 0 {
_, err := fmt.Fprintln(out, "(no orchestrators)")
return err
}
currentProject := ""
for _, sess := range sessions {
if sess.ProjectID != currentProject {
if currentProject != "" {
if _, err := fmt.Fprintln(out); err != nil {
return err
}
}
currentProject = sess.ProjectID
if _, err := fmt.Fprintf(out, "%s:\n", currentProject); err != nil {
return err
}
}
if _, err := fmt.Fprintf(out, " %s", sess.ID); err != nil {
return err
}
parts := orchestratorLineParts(sess)
if len(parts) > 0 {
if _, err := fmt.Fprintf(out, " %s", strings.Join(parts, " ")); err != nil {
return err
}
}
if _, err := fmt.Fprintln(out); err != nil {
return err
}
}
return nil
}

func orchestratorLineParts(sess sessionDTO) []string {
parts := []string{}
if !sess.Activity.LastActivityAt.IsZero() {
parts = append(parts, "("+formatSessionAge(time.Since(sess.Activity.LastActivityAt))+")")
}
if sess.Status != "" {
parts = append(parts, "["+sess.Status+"]")
}
if sess.IsTerminated {
parts = append(parts, "terminated")
}
return parts
}
83 changes: 83 additions & 0 deletions backend/internal/cli/orchestrator_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
package cli

import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"reflect"
"strings"
"testing"
)

func orchestratorCommandServer(t *testing.T) (*httptest.Server, *sessionRequestLog) {
t.Helper()
log := &sessionRequestLog{}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
log.append(r)
w.Header().Set("Content-Type", "application/json")
switch {
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/orchestrators":
_, _ = io.WriteString(w, `{"sessions":[`+
sessionJSON("other-orch", "other", "orchestrator", "idle", false)+`,`+
sessionJSON("demo-worker", "demo", "worker", "working", false)+`,`+
sessionJSON("demo-orch", "demo", "orchestrator", "working", false)+`]}`)
default:
http.NotFound(w, r)
}
}))
t.Cleanup(srv.Close)
return srv, log
}

func TestOrchestratorList_TableOutput(t *testing.T) {
cfg := setConfigEnv(t)
srv, log := orchestratorCommandServer(t)
writeRunFileFor(t, cfg, srv)

out, errOut, err := executeCLI(t, Deps{
ProcessAlive: func(int) bool { return true },
}, "orchestrator", "ls")
if err != nil {
t.Fatalf("orchestrator ls failed: %v\nstderr=%s", err, errOut)
}
if !strings.Contains(out, "demo:") || !strings.Contains(out, "demo-orch") {
t.Fatalf("output missing demo orchestrator:\n%s", out)
}
if !strings.Contains(out, "other:") || !strings.Contains(out, "other-orch") {
t.Fatalf("output missing other orchestrator:\n%s", out)
}
if strings.Contains(out, "demo-worker") {
t.Fatalf("worker session should not be shown in orchestrator ls:\n%s", out)
}
want := []string{"GET /api/v1/orchestrators"}
if got := log.all(); !reflect.DeepEqual(got, want) {
t.Fatalf("requests = %#v, want %#v", got, want)
}
}

func TestOrchestratorList_JSONOutputDecodes(t *testing.T) {
cfg := setConfigEnv(t)
srv, _ := orchestratorCommandServer(t)
writeRunFileFor(t, cfg, srv)

out, errOut, err := executeCLI(t, Deps{
ProcessAlive: func(int) bool { return true },
}, "orchestrator", "ls", "--json")
if err != nil {
t.Fatalf("orchestrator ls --json failed: %v\nstderr=%s", err, errOut)
}
var got orchestratorListOutput
if err := json.Unmarshal([]byte(out), &got); err != nil {
t.Fatalf("orchestrator ls --json output is not decodable: %v\noutput=%s", err, out)
}
if len(got.Data) != 2 {
t.Fatalf("len(data) = %d, want 2; data=%#v", len(got.Data), got.Data)
}
if got.Data[0].ID != "demo-orch" || got.Data[0].ProjectID != "demo" || got.Data[0].Role != "orchestrator" {
t.Fatalf("unexpected first JSON entry: %#v", got.Data[0])
}
if got.Data[1].ID != "other-orch" || got.Data[1].ProjectID != "other" || got.Data[1].Role != "orchestrator" {
t.Fatalf("unexpected second JSON entry: %#v", got.Data[1])
}
}
1 change: 1 addition & 0 deletions backend/internal/cli/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,7 @@ func NewRootCommand(deps Deps) *cobra.Command {
root.AddCommand(newSendCommand(ctx))
root.AddCommand(newProjectCommand(ctx))
root.AddCommand(newSessionCommand(ctx))
root.AddCommand(newOrchestratorCommand(ctx))
root.AddCommand(newCompletionCommand())
root.AddCommand(newVersionCommand())

Expand Down
Loading
Loading