3d99940658
Sandbox Deployment Platform — Go control plane + agents, NextJS dashboard, nginx reverse proxy. Cross-compile via Docker; deploy via sshpass to 172.18.136.92 (micro) and 172.18.139.186 (gateway). - control-plane: HTTP API, WS hub, SQLite (modernc.org/sqlite) for progress, .log files for log persistence - agent-micro / agent-gateway: alpine:3.20 + bind-mounted repo, binary exec'd in container, no Dockerfile build step - dashboard: NextJS static export + shadcn/ui components, single WebSocket hook - docker-compose.yml: three services on alpine:latest with docker socket bind for agents - scripts/: build.sh (golang:1.23-alpine cross-compile), deploy.sh, patch-nginx.sh (idempotent nginx splice), ssh wrappers Runtime model: pass-through Bitbucket creds per deploy, never logged or persisted on the agent. Control plane never touches git or docker directly — agents do all the work locally.
112 lines
2.6 KiB
Go
112 lines
2.6 KiB
Go
package ws
|
|
|
|
import (
|
|
"encoding/json"
|
|
"log"
|
|
"net/http"
|
|
"strings"
|
|
|
|
"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
|
|
}
|
|
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
|
|
}
|
|
}
|
|
}
|
|
}
|