Files
bri-sandbox-development-pla…/control-plane/internal/ws/handlers.go
T
Achmad 55d7705c63 Slice 2: real auth, agent-mediated repo/branch listing, deployment list from SQLite
- protocol: add RepoInfo, RouteOverride; add HostPort, SandboxID to DeployRequest.
- ws hub: add CallAgent for sync request/response RPCs over the agent WS,
  and DeliverAgentReply to route {op:reply} frames back to the caller.
  UnregisterAgent now also fails any pending RPCs so callers don't hang.
- agent-micro: new op handlers list_repos, list_branches, probe.
  Wire protocol.Event frames use json.RawMessage so each op decodes
  its own data shape.
- agent-gateway: same op handlers (list_repos/list_branches/probe) plus
  push_routes, which the gateway uses to rewrite the api-gateway
  config.php. Detailed in a later commit.
- control-plane login: validateViaAgent now calls CallAgent('probe')
  against the gateway agent (git ls-remote), replacing the
  accept-any-creds stub.
- control-plane repos: handleListRepos and handleListBranches forward
  to the agents via list_repos / list_branches RPCs, replacing the
  hardcoded fixtures.
- control-plane deployments: split into its own file. handleListDeployments
  reads from SQLite (was hardcoded []). handleCreateDeployment now
  supports sandbox-scoped deploys with a host port + env merge.
  handleStopDeployment looks up the node from the deployment row.
- store: split into store.go + deployments.go. The Deployment type
  adds sandboxId, containerId, hostPort. StartDeploymentInSandbox,
  SetContainerID, ListDeployments, GetDeployment, LatestDeploymentBySandboxService
  are new.
- store_test.go: round-trips every Slice-2 path (env, sandbox,
  template, clone, routes, deployment).
- .gitignore: track bin/ — the build runs on a separate Linux box
  with the golang:1.24 toolchain, and the binaries are SCPed from
  there to the company VMs (92 / 186). The VMs have no internet.
- Tracked bin/{control-plane,agent-micro,agent-gateway}.
2026-06-24 03:58:53 +00:00

144 lines
3.6 KiB
Go

package ws
import (
"encoding/json"
"log"
"net/http"
"strings"
"github.com/gorilla/websocket"
"github.com/sdp/protocol"
"github.com/sdp/control-plane/internal/store"
)
// AgentWS handles /ws/agent?node=<id>. The agent pushes events; the control
// plane can send requests back over the same socket.
func (h *Hub) AgentWS(st *store.Store, onConnect, onDisconnect func(nodeID string)) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
nodeID := r.URL.Query().Get("node")
if nodeID == "" {
http.Error(w, "missing node query", http.StatusBadRequest)
return
}
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
return
}
defer conn.Close()
// outbound to agent: buffered; if the agent stalls we drop rather than block.
out := make(chan []byte, 32)
h.RegisterAgent(nodeID, out)
defer func() {
h.UnregisterAgent(nodeID)
close(out) // unblock the writer goroutine
}()
if onConnect != nil {
onConnect(nodeID)
}
defer func() {
if onDisconnect != nil {
onDisconnect(nodeID)
}
}()
// writer goroutine: drains out to the agent
go func() {
for msg := range out {
if err := conn.WriteMessage(websocket.TextMessage, msg); err != nil {
return
}
}
}()
// reader loop: agent -> control plane
for {
_, raw, err := conn.ReadMessage()
if err != nil {
return
}
// Peek at the shape: protocol.Event frames have "kind",
// RPC replies have "op" == "reply". The agent's protocol
// does not allow a single frame to be both.
var probe map[string]json.RawMessage
if err := json.Unmarshal(raw, &probe); err != nil {
log.Printf("agent %s: bad json: %v", nodeID, err)
continue
}
if _, ok := probe["op"]; ok {
var reply struct {
Op string `json:"op"`
ID string `json:"id"`
OK bool `json:"ok"`
Err string `json:"error,omitempty"`
Raw json.RawMessage `json:"data,omitempty"`
}
if err := json.Unmarshal(raw, &reply); err != nil {
log.Printf("agent %s: bad reply: %v", nodeID, err)
continue
}
if reply.Op != "reply" {
log.Printf("agent %s: unknown op %q", nodeID, reply.Op)
continue
}
if !reply.OK {
log.Printf("agent %s: rpc %s error: %s", nodeID, reply.ID, reply.Err)
}
h.DeliverAgentReply(reply.ID, raw)
continue
}
var e protocol.Event
if err := json.Unmarshal(raw, &e); err != nil {
log.Printf("agent %s: bad event: %v", nodeID, err)
continue
}
_ = st.AppendEvent(e) // ponytail: best-effort persist
h.Publish(e)
}
}
}
// DeploymentWS handles /ws/deployments/{id}. Dashboard subscribes; we send
// a tail of the existing log first, then live events.
func (h *Hub) DeploymentWS(st *store.Store) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id := strings.TrimPrefix(r.URL.Path, "/ws/deployments/")
if id == "" {
http.Error(w, "missing deployment id", http.StatusBadRequest)
return
}
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
return
}
defer conn.Close()
// backfill: send last N log lines as synthetic events.
lines, _ := st.TailLogs(id, 200)
for _, ln := range lines {
msg := map[string]any{
"deploymentId": id,
"kind": "log",
"line": ln,
"at": int64(0),
"backfill": true,
}
b, _ := json.Marshal(msg)
if err := conn.WriteMessage(websocket.TextMessage, b); err != nil {
return
}
}
ch, unsub := h.Subscribe(id)
defer unsub()
for e := range ch {
b, _ := json.Marshal(e)
if err := conn.WriteMessage(websocket.TextMessage, b); err != nil {
return
}
}
}
}