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
90 changes: 90 additions & 0 deletions internal/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("GET /api/targets", s.listTargets)
mux.HandleFunc("POST /api/targets", s.createTarget)
mux.HandleFunc("GET /api/targets/{id}", s.getTarget)
mux.HandleFunc("PATCH /api/targets/{id}", s.updateTarget)
mux.HandleFunc("POST /api/targets/{id}/drain", s.targetMode(model.TargetDraining))
mux.HandleFunc("POST /api/targets/{id}/enable", s.targetMode(model.TargetOnline))
mux.HandleFunc("POST /api/targets/{id}/disable", s.targetMode(model.TargetDisabled))
Expand Down Expand Up @@ -494,6 +495,26 @@ type createTargetReq struct {
Bootstrap string `json:"bootstrap"`
}

type updateTargetReq struct {
Name *string `json:"name"`
Host *string `json:"host"`
User *string `json:"user"`
WorkRoot *string `json:"work_root"`
Labels *[]string `json:"labels"`
CapacitySessions *int `json:"capacity_sessions"`
SSHPort *int `json:"ssh_port"`
IdentityFile *string `json:"identity_file"`
Bootstrap *string `json:"bootstrap"`
}

func copyJSONMap(m model.JSONMap) model.JSONMap {
out := model.JSONMap{}
for k, v := range m {
out[k] = v
}
return out
}

// createTarget registers a machine (local or SSH). For SSH it health-checks the
// host, so the response status reflects whether it is reachable (this can take a
// few seconds for an unreachable host).
Expand Down Expand Up @@ -530,6 +551,75 @@ func (s *Server) createTarget(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusCreated, map[string]any{"target": created, "doctor": doctor})
}

func (s *Server) updateTarget(w http.ResponseWriter, r *http.Request) {
var req updateTargetReq
if err := decode(r, &req); err != nil {
writeErr(w, http.StatusBadRequest, err)
return
}
old, err := s.st.GetTarget(r.PathValue("id"))
if err != nil {
writeErr(w, httpStatusFor(err), err)
return
}
if req.CapacitySessions != nil && *req.CapacitySessions < 1 {
writeErr(w, http.StatusBadRequest, errors.New("capacity_sessions must be at least 1"))
return
}

next := *old
if req.Name != nil {
next.Name = *req.Name
}
if req.Host != nil {
next.Host = *req.Host
}
if req.User != nil {
next.User = *req.User
}
if req.WorkRoot != nil {
next.WorkRoot = *req.WorkRoot
}
if req.Labels != nil {
next.Labels = *req.Labels
}
if req.CapacitySessions != nil {
next.CapacitySessions = *req.CapacitySessions
}
next.Metadata = copyJSONMap(old.Metadata)
if req.SSHPort != nil {
if *req.SSHPort > 0 {
next.Metadata["ssh_port"] = float64(*req.SSHPort)
} else {
delete(next.Metadata, "ssh_port")
}
}
if req.IdentityFile != nil {
if *req.IdentityFile != "" {
next.Metadata["identity_file"] = *req.IdentityFile
} else {
delete(next.Metadata, "identity_file")
}
}
if req.Bootstrap != nil {
if *req.Bootstrap != "" {
next.Metadata["bootstrap"] = *req.Bootstrap
} else {
delete(next.Metadata, "bootstrap")
}
}
if len(next.Metadata) == 0 {
next.Metadata = nil
}

updated, err := s.st.UpdateTarget(old.ID, &next)
if err != nil {
writeErr(w, httpStatusFor(err), err)
return
}
writeJSON(w, http.StatusOK, updated)
}

// doctorTarget re-runs readiness diagnostics on a target.
func (s *Server) doctorTarget(w http.ResponseWriter, r *http.Request) {
rep, err := s.o.DoctorTarget(r.Context(), r.PathValue("id"))
Expand Down
87 changes: 87 additions & 0 deletions internal/api/api_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,21 @@ func postJSON(t *testing.T, url string, body any) *http.Response {
return resp
}

func patchJSON(t *testing.T, url string, body any) *http.Response {
t.Helper()
b, _ := json.Marshal(body)
req, err := http.NewRequest(http.MethodPatch, url, bytes.NewReader(b))
if err != nil {
t.Fatalf("PATCH %s: %v", url, err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("PATCH %s: %v", url, err)
}
return resp
}

func TestHealth_ReportsOK(t *testing.T) {
srv, _, _ := newTestServer(t)
var body struct {
Expand Down Expand Up @@ -325,3 +340,75 @@ func TestCreateTarget_RegistersAndHealthChecks(t *testing.T) {
t.Fatalf("target not listed: %+v", targets)
}
}

func TestUpdateTarget_PatchesFieldsAndMetadata(t *testing.T) {
srv, _, st := newTestServer(t)
tgt := &model.Target{Name: "remote", Kind: model.TargetSSH, Status: model.TargetOnline,
WorkRoot: "/old", CapacitySessions: 4, Host: "old-host", User: "old-user",
Labels: []string{"old"}, CPUSummary: "cpu", MemorySummary: "mem", DiskSummary: "disk",
Metadata: model.JSONMap{"ssh_port": float64(22), "identity_file": "/old/key", "keep": "value"}}
if err := st.CreateTarget(tgt); err != nil {
t.Fatalf("create target: %v", err)
}
if err := st.ClaimTargetSlot(tgt.ID); err != nil {
t.Fatalf("claim slot: %v", err)
}
if err := st.SetTargetStatus(tgt.ID, model.TargetDraining); err != nil {
t.Fatalf("drain target: %v", err)
}

resp := patchJSON(t, srv.URL+"/api/targets/"+tgt.ID, map[string]any{
"name": "renamed",
"host": "new-host",
"user": "bot",
"work_root": "/new",
"labels": []string{"gpu", "linux"},
"capacity_sessions": 2,
"ssh_port": 2222,
"identity_file": "/home/bot/.ssh/id_ed25519",
"bootstrap": "echo ready",
})
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("patch status=%d, want 200", resp.StatusCode)
}
var got model.Target
if err := json.NewDecoder(resp.Body).Decode(&got); err != nil {
t.Fatalf("decode patch response: %v", err)
}
if got.Name != "renamed" || got.Host != "new-host" || got.User != "bot" ||
got.WorkRoot != "/new" || got.CapacitySessions != 2 || got.AvailableSessions != 1 {
t.Fatalf("updated target wrong: %+v", got)
}
if got.Kind != model.TargetSSH || got.Status != model.TargetDraining ||
got.CPUSummary != "cpu" || got.MemorySummary != "mem" || got.DiskSummary != "disk" {
t.Fatalf("immutable/status summary fields should be preserved: %+v", got)
}
if len(got.Labels) != 2 || got.Labels[0] != "gpu" || got.Labels[1] != "linux" {
t.Fatalf("labels not updated: %+v", got.Labels)
}
if got.Metadata["ssh_port"] != float64(2222) ||
got.Metadata["identity_file"] != "/home/bot/.ssh/id_ed25519" ||
got.Metadata["bootstrap"] != "echo ready" ||
got.Metadata["keep"] != "value" {
t.Fatalf("metadata not updated/preserved: %#v", got.Metadata)
}
}

func TestUpdateTarget_RejectsMissingAndInvalidCapacity(t *testing.T) {
srv, _, st := newTestServer(t)
resp := patchJSON(t, srv.URL+"/api/targets/missing", map[string]any{"name": "x"})
resp.Body.Close()
if resp.StatusCode != http.StatusNotFound {
t.Fatalf("missing patch status=%d, want 404", resp.StatusCode)
}
tgt := &model.Target{Name: "remote", Kind: model.TargetSSH, WorkRoot: "/w", CapacitySessions: 1}
if err := st.CreateTarget(tgt); err != nil {
t.Fatalf("create target: %v", err)
}
resp = patchJSON(t, srv.URL+"/api/targets/"+tgt.ID, map[string]any{"capacity_sessions": 0})
resp.Body.Close()
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("invalid capacity status=%d, want 400", resp.StatusCode)
}
}
61 changes: 61 additions & 0 deletions internal/store/store_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,67 @@ func TestTargetCapacity_LimitsScheduling(t *testing.T) {
}
}

func TestUpdateTarget_AdjustsAvailableForCapacityChange(t *testing.T) {
st := newTestStore(t)
tgt := &model.Target{Name: "t", Kind: model.TargetLocal, Status: model.TargetOnline,
WorkRoot: "/w", CapacitySessions: 4}
if err := st.CreateTarget(tgt); err != nil {
t.Fatalf("create target: %v", err)
}
if err := st.ClaimTargetSlot(tgt.ID); err != nil {
t.Fatalf("claim 1: %v", err)
}
if err := st.ClaimTargetSlot(tgt.ID); err != nil {
t.Fatalf("claim 2: %v", err)
}

updated, err := st.UpdateTarget(tgt.ID, &model.Target{
Name: "renamed", Kind: model.TargetSSH, Host: "box", User: "bot",
WorkRoot: "/new", Labels: []string{"gpu"}, CapacitySessions: 6,
Metadata: model.JSONMap{"ssh_port": float64(2222)},
})
if err != nil {
t.Fatalf("update grow: %v", err)
}
if updated.Kind != model.TargetLocal || updated.Status != model.TargetOnline {
t.Fatalf("kind/status should be preserved, got %s/%s", updated.Kind, updated.Status)
}
if updated.CapacitySessions != 6 || updated.AvailableSessions != 4 {
t.Fatalf("capacity/available=%d/%d, want 6/4", updated.CapacitySessions, updated.AvailableSessions)
}
if updated.Name != "renamed" || updated.Host != "box" || updated.User != "bot" ||
updated.WorkRoot != "/new" || len(updated.Labels) != 1 || updated.Labels[0] != "gpu" {
t.Fatalf("mutable fields not updated: %+v", updated)
}

updated, err = st.UpdateTarget(tgt.ID, &model.Target{
Name: "renamed", WorkRoot: "/new", CapacitySessions: 1,
Metadata: model.JSONMap{"ssh_port": float64(2222)},
})
if err != nil {
t.Fatalf("update shrink: %v", err)
}
if updated.CapacitySessions != 1 || updated.AvailableSessions != 0 {
t.Fatalf("capacity/available=%d/%d, want 1/0", updated.CapacitySessions, updated.AvailableSessions)
}
}

func TestUpdateTarget_RejectsInvalidOrMissingTarget(t *testing.T) {
st := newTestStore(t)
tgt := &model.Target{Name: "t", Kind: model.TargetLocal, WorkRoot: "/w", CapacitySessions: 1}
if err := st.CreateTarget(tgt); err != nil {
t.Fatalf("create target: %v", err)
}
if _, err := st.UpdateTarget(tgt.ID, &model.Target{Name: "t", WorkRoot: "/w"}); err == nil {
t.Fatal("capacity below 1 should be rejected")
}
if _, err := st.UpdateTarget("missing", &model.Target{
Name: "missing", WorkRoot: "/w", CapacitySessions: 1,
}); !errors.Is(err, ErrNotFound) {
t.Fatalf("missing target should return ErrNotFound, got %v", err)
}
}

// The dashboard query must return only small rows — never transcript content,
// no matter how large the transcript grows.
func TestDashboard_NoTranscriptBlobs(t *testing.T) {
Expand Down
48 changes: 48 additions & 0 deletions internal/store/targets.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,54 @@ func (s *Store) CreateTarget(t *model.Target) error {
return err
}

// UpdateTarget updates a target's mutable operator configuration and preserves
// scheduling state, health summaries, and last-seen data. Capacity changes keep
// currently used slots accounted for: available becomes max(new-used, 0).
func (s *Store) UpdateTarget(id string, t *model.Target) (*model.Target, error) {
if t.CapacitySessions < 1 {
return nil, fmt.Errorf("store: capacity_sessions must be at least 1")
}
tx, err := s.db.Begin()
if err != nil {
return nil, err
}
defer tx.Rollback() //nolint:errcheck

row := tx.QueryRow(`SELECT `+targetCols+` FROM targets WHERE id = ?`, id)
old, err := scanTarget(row)
if err != nil {
return nil, err
}
used := old.CapacitySessions - old.AvailableSessions
if used < 0 {
used = 0
}
available := t.CapacitySessions - used
if available < 0 {
available = 0
}

if _, err := tx.Exec(
`UPDATE targets
SET name = ?, host = ?, user = ?, work_root = ?, labels = ?,
capacity_sessions = ?, available_sessions = ?, metadata = ?
WHERE id = ?`,
t.Name, nullStr(t.Host), nullStr(t.User), t.WorkRoot, joinLabels(t.Labels),
t.CapacitySessions, available, t.Metadata, id,
); err != nil {
return nil, err
}
row = tx.QueryRow(`SELECT `+targetCols+` FROM targets WHERE id = ?`, id)
updated, err := scanTarget(row)
if err != nil {
return nil, err
}
if err := tx.Commit(); err != nil {
return nil, err
}
return updated, nil
}

var targetCols = `id, name, kind, status, host, user, work_root, labels,
capacity_sessions, available_sessions, cpu_summary, memory_summary,
disk_summary, last_seen_at, metadata`
Expand Down
8 changes: 8 additions & 0 deletions ui/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,14 @@ export function put<T>(url: string, body?: unknown): Promise<T> {
}).then(parse);
}

export function patch<T>(url: string, body?: unknown): Promise<T> {
return fetch(url, {
method: "PATCH",
headers: body ? { "Content-Type": "application/json" } : undefined,
body: body ? JSON.stringify(body) : undefined,
}).then(parse);
}

export function del<T>(url: string): Promise<T> {
return fetch(url, { method: "DELETE" }).then(parse);
}
Expand Down
1 change: 1 addition & 0 deletions ui/src/icons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ const PATHS = {
check: "M20 6 9 17l-5-5",
alert: "M12 9v4 M12 17h.01 M10.3 3.9 1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0Z",
copy: "M9 9h11v11H9z M15 9V4H4v11h5",
pencil: "M18 2.5a2.1 2.1 0 0 1 3 3L8 18.5 4 20l1.5-4L18 2.5Z M15.5 5 19 8.5",
stop: "M7 7h10v10H7z",
play: "m7 5 12 7-12 7Z",
menu: "M4 6h16 M4 12h16 M4 18h16",
Expand Down
Loading
Loading