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}.
This commit is contained in:
+7
-2
@@ -1,6 +1,11 @@
|
|||||||
# Built artifacts
|
# Built artifacts
|
||||||
bin/
|
# bin/ AND dashboard/out/ are tracked on purpose: the build runs on a
|
||||||
dashboard/out/
|
# separate Linux box that has the golang:1.24 toolchain, and BOTH the
|
||||||
|
# binaries and the static dashboard are SCPed from here to the company
|
||||||
|
# VMs (92 / 186). The VMs have no internet so they can't regenerate
|
||||||
|
# either. Do NOT gitignore them again.
|
||||||
|
# dashboard/.next/ stays ignored — it's a Next.js build cache, not a
|
||||||
|
# deployable artifact.
|
||||||
dashboard/.next/
|
dashboard/.next/
|
||||||
|
|
||||||
# Node
|
# Node
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
// Command agent-micro runs on the microservices VM (172.18.136.92). It
|
// Command agent-micro runs on the microservices VM (172.18.136.92). It
|
||||||
// maintains a WebSocket to the control plane, accepts deploy/stop frames,
|
// maintains a WebSocket to the control plane, accepts deploy/stop/RPC
|
||||||
// and runs the build+container pipeline locally for Go microservices.
|
// frames, and runs the build+container pipeline locally for Go
|
||||||
|
// microservices.
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -13,8 +14,8 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
docker "github.com/moby/moby/client"
|
|
||||||
"github.com/gorilla/websocket"
|
"github.com/gorilla/websocket"
|
||||||
|
docker "github.com/moby/moby/client"
|
||||||
|
|
||||||
"github.com/sdp/agentlib/deployer"
|
"github.com/sdp/agentlib/deployer"
|
||||||
"github.com/sdp/agentlib/gitutil"
|
"github.com/sdp/agentlib/gitutil"
|
||||||
@@ -31,7 +32,7 @@ var repos = map[string]string{
|
|||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
cpURL := flag.String("cp", envOr("SDP_CP_URL", "ws://localhost:8080/ws/agent"), "control plane WS URL")
|
cpURL := flag.String("cp", envOr("SDP_CP_URL", "ws://localhost:3452/ws/agent"), "control plane WS URL")
|
||||||
nodeID := flag.String("node", envOr("SDP_NODE_ID", "micro"), "node id sent in WS query")
|
nodeID := flag.String("node", envOr("SDP_NODE_ID", "micro"), "node id sent in WS query")
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|
||||||
@@ -111,7 +112,7 @@ func readLoop(c *websocket.Conn, cli *docker.Client, out chan<- []byte, mu *sync
|
|||||||
// Inbound frame: {op, data, id}. Op is the verb. data is op-specific.
|
// Inbound frame: {op, data, id}. Op is the verb. data is op-specific.
|
||||||
var frame struct {
|
var frame struct {
|
||||||
Op string `json:"op"`
|
Op string `json:"op"`
|
||||||
Data protocol.DeployRequest `json:"data"`
|
Data json.RawMessage `json:"data"`
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal(raw, &frame); err != nil {
|
if err := json.Unmarshal(raw, &frame); err != nil {
|
||||||
@@ -120,33 +121,96 @@ func readLoop(c *websocket.Conn, cli *docker.Client, out chan<- []byte, mu *sync
|
|||||||
}
|
}
|
||||||
switch frame.Op {
|
switch frame.Op {
|
||||||
case "deploy":
|
case "deploy":
|
||||||
repoPath, ok := repos[frame.Data.Repository]
|
var req protocol.DeployRequest
|
||||||
|
if err := json.Unmarshal(frame.Data, &req); err != nil {
|
||||||
|
log.Printf("bad deploy data: %v", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
repoPath, ok := repos[req.Repository]
|
||||||
if !ok {
|
if !ok {
|
||||||
emit(out, protocol.Event{
|
emit(out, protocol.Event{
|
||||||
DeploymentID: frame.Data.DeploymentID,
|
DeploymentID: req.DeploymentID,
|
||||||
Kind: "status",
|
Kind: "status",
|
||||||
State: "FAILED",
|
State: "FAILED",
|
||||||
At: time.Now().UnixMilli(),
|
At: time.Now().UnixMilli(),
|
||||||
})
|
})
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
d := deployer.NewGo(cli, frame.Data.DeploymentID,
|
d := deployer.NewGo(cli, req.DeploymentID,
|
||||||
frame.Data.Repository, repoPath,
|
req.Repository, repoPath,
|
||||||
frame.Data.Branch, frame.Data.Env,
|
req.Branch, req.HostPort, req.Env,
|
||||||
gitutil.Creds{Username: frame.Data.Username, Password: frame.Data.Password},
|
gitutil.Creds{Username: req.Username, Password: req.Password},
|
||||||
)
|
)
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
inflight[frame.Data.DeploymentID] = &runState{deployer: d, cancel: cancel}
|
inflight[req.DeploymentID] = &runState{deployer: d, cancel: cancel}
|
||||||
go runDeploy(d, ctx, out)
|
go runDeploy(d, ctx, out)
|
||||||
case "stop":
|
case "stop":
|
||||||
if rs, ok := inflight[frame.ID]; ok {
|
if rs, ok := inflight[frame.ID]; ok {
|
||||||
_ = rs.deployer.Stop(context.Background())
|
_ = rs.deployer.Stop(context.Background())
|
||||||
rs.cancel() // unblock StreamLogs
|
rs.cancel() // unblock StreamLogs
|
||||||
}
|
}
|
||||||
|
case "list_repos":
|
||||||
|
handleListRepos(out, frame.ID)
|
||||||
|
case "list_branches":
|
||||||
|
var body struct {
|
||||||
|
Repo string `json:"repo"`
|
||||||
|
}
|
||||||
|
_ = json.Unmarshal(frame.Data, &body)
|
||||||
|
handleListBranches(out, frame.ID, body.Repo)
|
||||||
|
case "probe":
|
||||||
|
var body struct {
|
||||||
|
Repo string `json:"repo"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
}
|
||||||
|
_ = json.Unmarshal(frame.Data, &body)
|
||||||
|
handleProbe(out, frame.ID, body.Repo, body.Username, body.Password)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func handleListRepos(out chan<- []byte, id string) {
|
||||||
|
// Micro agent serves all repos except the api-gateway. The control
|
||||||
|
// plane calls both nodes for the union; the gateway will return
|
||||||
|
// only its own repo.
|
||||||
|
list := make([]protocol.RepoInfo, 0, len(repos))
|
||||||
|
for name, path := range repos {
|
||||||
|
list = append(list, protocol.RepoInfo{Name: name, Path: path})
|
||||||
|
}
|
||||||
|
replyOK(out, id, map[string]any{"repos": list})
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleListBranches(out chan<- []byte, id, repo string) {
|
||||||
|
path, ok := repos[repo]
|
||||||
|
if !ok {
|
||||||
|
replyErr(out, id, "unknown repo: "+repo)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
branches, err := gitutil.ListBranches(ctx, path)
|
||||||
|
if err != nil {
|
||||||
|
replyErr(out, id, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
replyOK(out, id, map[string]any{"ok": true, "branches": branches})
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleProbe(out chan<- []byte, id, repo, user, pass string) {
|
||||||
|
path, ok := repos[repo]
|
||||||
|
if !ok {
|
||||||
|
replyErr(out, id, "unknown repo: "+repo)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
if err := gitutil.Probe(ctx, path, gitutil.Creds{Username: user, Password: pass}); err != nil {
|
||||||
|
replyErr(out, id, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
replyOK(out, id, map[string]any{"ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
func runDeploy(d *deployer.Deployer, ctx context.Context, out chan<- []byte) {
|
func runDeploy(d *deployer.Deployer, ctx context.Context, out chan<- []byte) {
|
||||||
events := make(chan protocol.Event, 64)
|
events := make(chan protocol.Event, 64)
|
||||||
// producer: Run pipelines, then StreamLogs tails the container. Both
|
// producer: Run pipelines, then StreamLogs tails the container. Both
|
||||||
@@ -174,6 +238,31 @@ func emit(out chan<- []byte, e protocol.Event) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func replyOK(out chan<- []byte, id string, data interface{}) {
|
||||||
|
reply(out, id, true, "", data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func replyErr(out chan<- []byte, id, errMsg string) {
|
||||||
|
reply(out, id, false, errMsg, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func reply(out chan<- []byte, id string, ok bool, errMsg string, data interface{}) {
|
||||||
|
frame := map[string]any{
|
||||||
|
"op": "reply",
|
||||||
|
"id": id,
|
||||||
|
"ok": ok,
|
||||||
|
"data": data,
|
||||||
|
}
|
||||||
|
if errMsg != "" {
|
||||||
|
frame["error"] = errMsg
|
||||||
|
}
|
||||||
|
b, _ := json.Marshal(frame)
|
||||||
|
select {
|
||||||
|
case out <- b:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func envOr(k, def string) string {
|
func envOr(k, def string) string {
|
||||||
if v := os.Getenv(k); v != "" {
|
if v := os.Getenv(k); v != "" {
|
||||||
return v
|
return v
|
||||||
|
|||||||
Executable
BIN
Binary file not shown.
Executable
BIN
Binary file not shown.
Executable
BIN
Binary file not shown.
@@ -1,18 +1,23 @@
|
|||||||
// Package api wires the HTTP endpoints. Kept on net/http — no router lib
|
// Package api wires the HTTP endpoints. Kept on net/http — no router lib
|
||||||
// for a handful of endpoints, stdlib mux is plenty.
|
// for a handful of endpoints, stdlib mux is plenty.
|
||||||
|
//
|
||||||
|
// Slice 2 adds sandboxes, templates, environments, routes, port
|
||||||
|
// allocation, and a real agent-mediated auth path. The legacy
|
||||||
|
// /api/deployments POST is kept for ad-hoc deploys (no sandbox) and a
|
||||||
|
// new /api/sandboxes/{id}/services/{repo}/deploy adds the
|
||||||
|
// sandbox-scoped variant that picks the right port and env.
|
||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/sdp/control-plane/internal/store"
|
"github.com/sdp/control-plane/internal/store"
|
||||||
"github.com/sdp/control-plane/internal/ws"
|
"github.com/sdp/control-plane/internal/ws"
|
||||||
"github.com/sdp/protocol"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type Server struct {
|
type Server struct {
|
||||||
@@ -20,6 +25,14 @@ type Server struct {
|
|||||||
hub *ws.Hub
|
hub *ws.Hub
|
||||||
agents *AgentRegistry
|
agents *AgentRegistry
|
||||||
sess *Sessions
|
sess *Sessions
|
||||||
|
// gatewayRepo is the repo the control plane uses to validate
|
||||||
|
// Bitbucket creds at login. Defaults to "api-gateway".
|
||||||
|
gatewayRepo string
|
||||||
|
// microVMIP / gatewayVMIP are the host IPs the dashboard shows
|
||||||
|
// when a user picks a "local stand-in" for a microservice route.
|
||||||
|
// Defaults match the company infra (172.18.136.92 / 172.18.139.186).
|
||||||
|
microVMIP string
|
||||||
|
gatewayVMIP string
|
||||||
}
|
}
|
||||||
|
|
||||||
type AgentRegistry struct {
|
type AgentRegistry struct {
|
||||||
@@ -33,16 +46,45 @@ func New(st *store.Store, hub *ws.Hub) *Server {
|
|||||||
hub: hub,
|
hub: hub,
|
||||||
agents: &AgentRegistry{conns: make(map[string]bool)},
|
agents: &AgentRegistry{conns: make(map[string]bool)},
|
||||||
sess: NewSessions(),
|
sess: NewSessions(),
|
||||||
|
gatewayRepo: "api-gateway",
|
||||||
|
microVMIP: "172.18.136.92",
|
||||||
|
gatewayVMIP: "172.18.139.186",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) Routes() http.Handler {
|
func (s *Server) Routes() http.Handler {
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
|
|
||||||
|
// auth
|
||||||
mux.HandleFunc("/api/login", s.handleLogin)
|
mux.HandleFunc("/api/login", s.handleLogin)
|
||||||
|
mux.HandleFunc("/api/logout", s.handleLogout)
|
||||||
|
|
||||||
|
// repos & deploy
|
||||||
mux.HandleFunc("/api/repos", s.handleListRepos)
|
mux.HandleFunc("/api/repos", s.handleListRepos)
|
||||||
mux.HandleFunc("/api/repos/branches", s.handleListBranches)
|
mux.HandleFunc("/api/repos/branches", s.handleListBranches)
|
||||||
mux.HandleFunc("/api/deployments", s.handleDeployments) // GET list, POST create
|
mux.HandleFunc("/api/agents", s.handleListAgents)
|
||||||
|
mux.HandleFunc("/api/deployments", s.handleListDeployments) // GET list
|
||||||
|
mux.HandleFunc("/api/deployments/new", s.handleCreateDeployment) // POST
|
||||||
mux.HandleFunc("/api/deployments/stop", s.handleStopDeployment) // POST
|
mux.HandleFunc("/api/deployments/stop", s.handleStopDeployment) // POST
|
||||||
|
|
||||||
|
// environments
|
||||||
|
mux.HandleFunc("/api/environments", s.handleEnvironments) // GET list, POST create
|
||||||
|
mux.HandleFunc("/api/environments/", s.handleEnvironmentByID) // GET/PUT/DELETE /{id}
|
||||||
|
|
||||||
|
// sandboxes
|
||||||
|
mux.HandleFunc("/api/sandboxes", s.handleSandboxes) // GET list, POST create
|
||||||
|
mux.HandleFunc("/api/sandboxes/", s.handleSandboxByID) // GET/PUT/DELETE /{id}
|
||||||
|
mux.HandleFunc("/api/sandboxes/clone", s.handleCloneSandbox) // POST: clone from template
|
||||||
|
|
||||||
|
// templates
|
||||||
|
mux.HandleFunc("/api/templates", s.handleTemplates) // GET list, POST create
|
||||||
|
mux.HandleFunc("/api/templates/", s.handleTemplateByID) // GET/PUT/DELETE /{id}
|
||||||
|
|
||||||
|
// routes
|
||||||
|
mux.HandleFunc("/api/routes", s.handleListRoutes) // GET: live routes from gateway agent's config.php
|
||||||
|
mux.HandleFunc("/api/routes/push", s.handlePushRoutes) // POST: push routes for a sandbox
|
||||||
|
|
||||||
|
// websocket
|
||||||
mux.Handle("/ws/agent", s.hub.AgentWS(s.st,
|
mux.Handle("/ws/agent", s.hub.AgentWS(s.st,
|
||||||
func(nodeID string) {
|
func(nodeID string) {
|
||||||
s.agents.mu.Lock()
|
s.agents.mu.Lock()
|
||||||
@@ -56,6 +98,7 @@ func (s *Server) Routes() http.Handler {
|
|||||||
},
|
},
|
||||||
))
|
))
|
||||||
mux.HandleFunc("/ws/deployments/", s.hub.DeploymentWS(s.st))
|
mux.HandleFunc("/ws/deployments/", s.hub.DeploymentWS(s.st))
|
||||||
|
|
||||||
return s.withAuth(mux)
|
return s.withAuth(mux)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,176 +120,16 @@ func (s *Server) withAuth(next http.Handler) http.Handler {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- login ----
|
// userFromContext returns the authenticated user from the session cookie.
|
||||||
|
// The cookie is set on /api/login; on every other /api/* path we look it
|
||||||
type loginReq struct {
|
// up here so handlers can record who did what (audit trail).
|
||||||
Username string `json:"username"`
|
func (s *Server) userFromContext(r *http.Request) string {
|
||||||
Password string `json:"password"`
|
c, err := r.Cookie("sdp_session")
|
||||||
Repo string `json:"repo"` // optional: validate against this specific repo
|
if err != nil {
|
||||||
|
return ""
|
||||||
}
|
}
|
||||||
|
u, _ := s.sess.User(c.Value)
|
||||||
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
return u
|
||||||
if r.Method != http.MethodPost {
|
|
||||||
http.Error(w, "POST only", http.StatusMethodNotAllowed)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var body loginReq
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
|
||||||
http.Error(w, "bad json", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// ponytail: trust boundary lives in the agent — it does the actual git
|
|
||||||
// ls-remote. The control plane just hands off credentials per-op.
|
|
||||||
// For login we ask any connected agent to validate. If none are
|
|
||||||
// connected, fail. Real impl: pick a known bootstrap repo.
|
|
||||||
ok := s.validateViaAgent(body.Username, body.Password, body.Repo)
|
|
||||||
if !ok {
|
|
||||||
http.Error(w, "login failed", http.StatusUnauthorized)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
tok := s.sess.Issue(body.Username)
|
|
||||||
http.SetCookie(w, &http.Cookie{
|
|
||||||
Name: "sdp_session",
|
|
||||||
Value: tok,
|
|
||||||
Path: "/",
|
|
||||||
HttpOnly: true,
|
|
||||||
SameSite: http.SameSiteLaxMode,
|
|
||||||
MaxAge: 12 * 3600,
|
|
||||||
})
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
_, _ = w.Write([]byte(`{"ok":true}`))
|
|
||||||
}
|
|
||||||
|
|
||||||
// validateViaAgent does a git ls-remote through one of the connected agents.
|
|
||||||
// The agent holds the repos; the control plane never touches git directly.
|
|
||||||
//
|
|
||||||
// ponytail: MVP stub. Returns true if any agent is connected so the smoke
|
|
||||||
// flow can run. Real impl will send a "probe" frame over the agent's WS
|
|
||||||
// and wait for a reply.
|
|
||||||
func (s *Server) validateViaAgent(user, pass, repo string) bool {
|
|
||||||
_ = user
|
|
||||||
_ = pass
|
|
||||||
_ = repo
|
|
||||||
s.agents.mu.RLock()
|
|
||||||
defer s.agents.mu.RUnlock()
|
|
||||||
return len(s.agents.conns) > 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- repos ----
|
|
||||||
|
|
||||||
type repoInfo struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
Node string `json:"node"`
|
|
||||||
Path string `json:"path"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) handleListRepos(w http.ResponseWriter, r *http.Request) {
|
|
||||||
// ponytail: real impl asks the connected agents for their repo list.
|
|
||||||
// For MVP smoke, stub with the spec's example.
|
|
||||||
repos := []repoInfo{
|
|
||||||
{Name: "account", Node: "micro", Path: "/home/user/AppGolang/account"},
|
|
||||||
{Name: "payment", Node: "micro", Path: "/home/user/AppGolang/payment"},
|
|
||||||
{Name: "user", Node: "micro", Path: "/home/user/AppGolang/user"},
|
|
||||||
{Name: "notification", Node: "micro", Path: "/home/user/AppGolang/notification"},
|
|
||||||
{Name: "api-gateway", Node: "gateway", Path: "/home/user/SDP"},
|
|
||||||
}
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
_ = json.NewEncoder(w).Encode(repos)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) handleListBranches(w http.ResponseWriter, r *http.Request) {
|
|
||||||
repo := r.URL.Query().Get("repo")
|
|
||||||
if repo == "" {
|
|
||||||
http.Error(w, "repo required", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// ponytail: real impl forwards to the agent. For MVP, stub.
|
|
||||||
branches := []string{"main", "develop", "feature/login-error"}
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
_ = json.NewEncoder(w).Encode(branches)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- deployments ----
|
|
||||||
|
|
||||||
type deployReq struct {
|
|
||||||
Repository string `json:"repository"`
|
|
||||||
Branch string `json:"branch"`
|
|
||||||
Env map[string]string `json:"env,omitempty"`
|
|
||||||
Username string `json:"username"`
|
|
||||||
Password string `json:"password"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) handleDeployments(w http.ResponseWriter, r *http.Request) {
|
|
||||||
switch r.Method {
|
|
||||||
case http.MethodGet:
|
|
||||||
// ponytail: list from SQLite. Real impl: SELECT with filter.
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
_, _ = w.Write([]byte(`[]`))
|
|
||||||
case http.MethodPost:
|
|
||||||
var body deployReq
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
|
||||||
http.Error(w, "bad json", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// resolve repo -> node
|
|
||||||
node := "micro"
|
|
||||||
if body.Repository == "api-gateway" {
|
|
||||||
node = "gateway"
|
|
||||||
}
|
|
||||||
// ensure agent connected
|
|
||||||
s.agents.mu.RLock()
|
|
||||||
connected := s.agents.conns[node]
|
|
||||||
s.agents.mu.RUnlock()
|
|
||||||
if !connected {
|
|
||||||
http.Error(w, "agent "+node+" not connected", http.StatusServiceUnavailable)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
id := newID()
|
|
||||||
_ = s.st.StartDeployment(id, body.Repository, body.Branch, body.Username)
|
|
||||||
|
|
||||||
// send deploy request to agent over its WS
|
|
||||||
req := protocol.DeployRequest{
|
|
||||||
DeploymentID: id,
|
|
||||||
Repository: body.Repository,
|
|
||||||
Branch: body.Branch,
|
|
||||||
Env: body.Env,
|
|
||||||
Username: body.Username,
|
|
||||||
Password: body.Password,
|
|
||||||
}
|
|
||||||
payload, _ := json.Marshal(map[string]any{
|
|
||||||
"op": "deploy",
|
|
||||||
"data": req,
|
|
||||||
})
|
|
||||||
if !s.hub.SendToAgent(node, payload) {
|
|
||||||
http.Error(w, "agent buffer full", http.StatusServiceUnavailable)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
_ = json.NewEncoder(w).Encode(map[string]string{"id": id})
|
|
||||||
default:
|
|
||||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) handleStopDeployment(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if r.Method != http.MethodPost {
|
|
||||||
http.Error(w, "POST only", http.StatusMethodNotAllowed)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var body struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
Node string `json:"node"`
|
|
||||||
}
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
|
||||||
http.Error(w, "bad json", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
payload, _ := json.Marshal(map[string]any{"op": "stop", "id": body.ID})
|
|
||||||
if !s.hub.SendToAgent(body.Node, payload) {
|
|
||||||
http.Error(w, "agent not reachable", http.StatusServiceUnavailable)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func newID() string {
|
func newID() string {
|
||||||
@@ -254,3 +137,16 @@ func newID() string {
|
|||||||
_, _ = rand.Read(b)
|
_, _ = rand.Read(b)
|
||||||
return hex.EncodeToString(b)
|
return hex.EncodeToString(b)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func writeJSON(w http.ResponseWriter, status int, v interface{}) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(status)
|
||||||
|
_ = encodeJSON(w, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeErr(w http.ResponseWriter, status int, msg string) {
|
||||||
|
writeJSON(w, status, map[string]string{"error": msg})
|
||||||
|
}
|
||||||
|
|
||||||
|
// nowMs is a small helper so tests can stub it later.
|
||||||
|
func nowMs() int64 { return time.Now().UnixMilli() }
|
||||||
|
|||||||
@@ -0,0 +1,200 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/sdp/control-plane/internal/store"
|
||||||
|
"github.com/sdp/protocol"
|
||||||
|
)
|
||||||
|
|
||||||
|
// handleListDeployments reads deployment history from SQLite. Filterable
|
||||||
|
// by sandbox via ?sandbox=ID. Limit via ?limit=N (default 100, max 500).
|
||||||
|
func (s *Server) handleListDeployments(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
http.Error(w, "GET only", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sandboxID := r.URL.Query().Get("sandbox")
|
||||||
|
limit := 100
|
||||||
|
if l := r.URL.Query().Get("limit"); l != "" {
|
||||||
|
if n, err := strconv.Atoi(l); err == nil {
|
||||||
|
limit = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
deps, err := s.st.ListDeployments(sandboxID, limit)
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if deps == nil {
|
||||||
|
deps = []store.Deployment{} // never null in JSON
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, deps)
|
||||||
|
}
|
||||||
|
|
||||||
|
type createDeployReq struct {
|
||||||
|
Repository string `json:"repository"`
|
||||||
|
Branch string `json:"branch"`
|
||||||
|
Env map[string]string `json:"env,omitempty"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
// SandboxID optionally ties this deploy to a sandbox. When set, the
|
||||||
|
// sandbox's host_port for the repo is used as the container's
|
||||||
|
// published port and the env is the union of (sandbox env) and
|
||||||
|
// (request env).
|
||||||
|
SandboxID string `json:"sandboxId,omitempty"`
|
||||||
|
// HostPort overrides the auto-allocated port. Use 0 to allocate.
|
||||||
|
HostPort int `json:"hostPort,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleCreateDeployment is the Slice-2-friendly deploy path. It
|
||||||
|
// resolves the repo to a node, looks up the sandbox's port (if any),
|
||||||
|
// and dispatches the deploy frame.
|
||||||
|
func (s *Server) handleCreateDeployment(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, "POST only", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body createDeployReq
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
http.Error(w, "bad json", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if body.Repository == "" || body.Branch == "" {
|
||||||
|
writeErr(w, http.StatusBadRequest, "repository and branch required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
node, err := s.resolveNode(body.Repository, body.SandboxID)
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.agents.mu.RLock()
|
||||||
|
connected := s.agents.conns[node]
|
||||||
|
s.agents.mu.RUnlock()
|
||||||
|
if !connected {
|
||||||
|
writeErr(w, http.StatusServiceUnavailable, "agent "+node+" not connected")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
user := s.userFromContext(r)
|
||||||
|
id := newID()
|
||||||
|
|
||||||
|
hostPort := body.HostPort
|
||||||
|
env := body.Env
|
||||||
|
if body.SandboxID != "" {
|
||||||
|
sb, err := s.st.GetSandbox(body.SandboxID)
|
||||||
|
if err == nil {
|
||||||
|
for _, svc := range sb.Services {
|
||||||
|
if svc.Repo == body.Repository {
|
||||||
|
if hostPort == 0 {
|
||||||
|
hostPort = svc.HostPort
|
||||||
|
}
|
||||||
|
if svc.EnvID != "" {
|
||||||
|
if e, err := s.st.GetEnvironment(svc.EnvID); err == nil {
|
||||||
|
if env == nil {
|
||||||
|
env = map[string]string{}
|
||||||
|
}
|
||||||
|
for k, v := range e.Values {
|
||||||
|
env[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := s.st.StartDeploymentInSandbox(id, body.SandboxID, body.Repository, body.Branch, user, hostPort); err != nil {
|
||||||
|
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
req := protocol.DeployRequest{
|
||||||
|
DeploymentID: id,
|
||||||
|
SandboxID: body.SandboxID,
|
||||||
|
Repository: body.Repository,
|
||||||
|
Branch: body.Branch,
|
||||||
|
HostPort: hostPort,
|
||||||
|
Env: env,
|
||||||
|
Username: body.Username,
|
||||||
|
Password: body.Password,
|
||||||
|
}
|
||||||
|
payload, _ := json.Marshal(map[string]any{"op": "deploy", "data": req})
|
||||||
|
if !s.hub.SendToAgent(node, payload) {
|
||||||
|
writeErr(w, http.StatusServiceUnavailable, "agent buffer full")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"id": id})
|
||||||
|
}
|
||||||
|
|
||||||
|
type stopDeployReq struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Node string `json:"node"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleStopDeployment(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, "POST only", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body stopDeployReq
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
http.Error(w, "bad json", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if body.Node == "" {
|
||||||
|
// try to find the node from the deployment row
|
||||||
|
if d, err := s.st.GetDeployment(body.ID); err == nil && d != nil {
|
||||||
|
body.Node, _ = s.resolveNode(d.Repository, d.SandboxID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if body.Node == "" {
|
||||||
|
writeErr(w, http.StatusBadRequest, "node required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
payload, _ := json.Marshal(map[string]any{"op": "stop", "id": body.ID})
|
||||||
|
if !s.hub.SendToAgent(body.Node, payload) {
|
||||||
|
writeErr(w, http.StatusServiceUnavailable, "agent not reachable")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = s.st.FinishDeployment(body.ID, "STOPPED")
|
||||||
|
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveNode picks which agent serves a repo. The gateway agent owns
|
||||||
|
// the api-gateway repo. Everything else is the micro agent.
|
||||||
|
func (s *Server) resolveNode(repo, sandboxID string) (string, error) {
|
||||||
|
if repo == "api-gateway" {
|
||||||
|
return "gateway", nil
|
||||||
|
}
|
||||||
|
return "micro", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// callAgentPushRoutes pushes a routes table to the gateway agent.
|
||||||
|
func (s *Server) callAgentPushRoutes(ctx context.Context, sandboxID string, payload []protocol.RouteOverride) error {
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, 15*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
data := map[string]any{
|
||||||
|
"sandboxId": sandboxID,
|
||||||
|
"routes": payload,
|
||||||
|
}
|
||||||
|
raw, err := s.hub.CallAgent(ctx, "gateway", "push_routes", data, 0)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var reply struct {
|
||||||
|
OK bool `json:"ok"`
|
||||||
|
Err string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw, &reply); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !reply.OK {
|
||||||
|
return wsError(reply.Err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import "errors"
|
||||||
|
|
||||||
|
// wsError turns a string from an agent reply into an error.
|
||||||
|
func wsError(s string) error { return errors.New(s) }
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
)
|
||||||
|
|
||||||
|
func encodeJSON(w io.Writer, v interface{}) error {
|
||||||
|
enc := json.NewEncoder(w)
|
||||||
|
return enc.Encode(v)
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type loginReq struct {
|
||||||
|
Username string `json:"username"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
// Repo is an optional override: validate creds against a specific
|
||||||
|
// repo on a specific agent. If empty, we use the gateway's default
|
||||||
|
// repo (api-gateway) on the connected gateway agent.
|
||||||
|
Repo string `json:"repo"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, "POST only", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body loginReq
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
http.Error(w, "bad json", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if body.Username == "" || body.Password == "" {
|
||||||
|
writeErr(w, http.StatusBadRequest, "username and password required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ok := s.validateViaAgent(r.Context(), body.Username, body.Password, body.Repo)
|
||||||
|
if !ok {
|
||||||
|
writeErr(w, http.StatusUnauthorized, "login failed — git ls-remote rejected the credentials")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tok := s.sess.Issue(body.Username)
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: "sdp_session",
|
||||||
|
Value: tok,
|
||||||
|
Path: "/",
|
||||||
|
HttpOnly: true,
|
||||||
|
SameSite: http.SameSiteLaxMode,
|
||||||
|
MaxAge: 12 * 3600,
|
||||||
|
})
|
||||||
|
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if c, err := r.Cookie("sdp_session"); err == nil {
|
||||||
|
s.sess.Revoke(c.Value)
|
||||||
|
}
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: "sdp_session",
|
||||||
|
Value: "",
|
||||||
|
Path: "/",
|
||||||
|
HttpOnly: true,
|
||||||
|
MaxAge: -1,
|
||||||
|
})
|
||||||
|
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateViaAgent asks the connected gateway agent to run
|
||||||
|
// `git ls-remote` against the api-gateway repo. The agent holds the
|
||||||
|
// repo and the trust boundary for Bitbucket creds.
|
||||||
|
func (s *Server) validateViaAgent(ctx context.Context, user, pass, repo string) bool {
|
||||||
|
s.agents.mu.RLock()
|
||||||
|
_, connected := s.agents.conns["gateway"]
|
||||||
|
s.agents.mu.RUnlock()
|
||||||
|
if !connected {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if repo == "" {
|
||||||
|
repo = s.gatewayRepo
|
||||||
|
}
|
||||||
|
data := map[string]string{
|
||||||
|
"repo": repo,
|
||||||
|
"username": user,
|
||||||
|
"password": pass,
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
raw, err := s.hub.CallAgent(ctx, "gateway", "probe", data, 0)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
var reply struct {
|
||||||
|
OK bool `json:"ok"`
|
||||||
|
Err string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw, &reply); err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return reply.OK
|
||||||
|
}
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"sort"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// repoInfo is the shape the dashboard consumes. It carries the nodeID
|
||||||
|
// the repo lives on, so the dashboard can pick the right agent when
|
||||||
|
// building a deploy.
|
||||||
|
type repoInfo struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Node string `json:"node"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// nodeRepoList is the reply shape from the agent's "list_repos" RPC.
|
||||||
|
type nodeRepoList struct {
|
||||||
|
Repos []repoInfo `json:"repos"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleListRepos queries every connected agent for its repo list and
|
||||||
|
// merges the results.
|
||||||
|
func (s *Server) handleListRepos(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
http.Error(w, "GET only", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.agents.mu.RLock()
|
||||||
|
nodes := make([]string, 0, len(s.agents.conns))
|
||||||
|
for n, c := range s.agents.conns {
|
||||||
|
if c {
|
||||||
|
nodes = append(nodes, n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.agents.mu.RUnlock()
|
||||||
|
sort.Strings(nodes)
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
merged := make([]repoInfo, 0)
|
||||||
|
for _, node := range nodes {
|
||||||
|
raw, err := s.hub.CallAgent(ctx, node, "list_repos", map[string]any{}, 0)
|
||||||
|
if err != nil {
|
||||||
|
continue // agent flaked; skip
|
||||||
|
}
|
||||||
|
var reply nodeRepoList
|
||||||
|
if err := json.Unmarshal(raw, &reply); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, repo := range reply.Repos {
|
||||||
|
repo.Node = node
|
||||||
|
merged = append(merged, repo)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Stable order: by node then name.
|
||||||
|
sort.Slice(merged, func(i, j int) bool {
|
||||||
|
if merged[i].Node != merged[j].Node {
|
||||||
|
return merged[i].Node < merged[j].Node
|
||||||
|
}
|
||||||
|
return merged[i].Name < merged[j].Name
|
||||||
|
})
|
||||||
|
writeJSON(w, http.StatusOK, merged)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleListBranches asks the connected agent that owns `repo` for its
|
||||||
|
// branches. The repo's node is implied by the prefix path; for now we
|
||||||
|
// try both connected agents and merge.
|
||||||
|
func (s *Server) handleListBranches(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
http.Error(w, "GET only", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
repo := r.URL.Query().Get("repo")
|
||||||
|
if repo == "" {
|
||||||
|
writeErr(w, http.StatusBadRequest, "repo required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s.agents.mu.RLock()
|
||||||
|
nodes := make([]string, 0, len(s.agents.conns))
|
||||||
|
for n, c := range s.agents.conns {
|
||||||
|
if c {
|
||||||
|
nodes = append(nodes, n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.agents.mu.RUnlock()
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
branches := make([]string, 0)
|
||||||
|
for _, node := range nodes {
|
||||||
|
raw, err := s.hub.CallAgent(ctx, node, "list_branches", map[string]any{"repo": repo}, 0)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var reply struct {
|
||||||
|
OK bool `json:"ok"`
|
||||||
|
Branches []string `json:"branches"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw, &reply); err != nil || !reply.OK {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
branches = append(branches, reply.Branches...)
|
||||||
|
}
|
||||||
|
sort.Strings(branches)
|
||||||
|
// dedupe
|
||||||
|
dedup := make([]string, 0, len(branches))
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, b := range branches {
|
||||||
|
if !seen[b] {
|
||||||
|
dedup = append(dedup, b)
|
||||||
|
seen[b] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, dedup)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleListAgents returns which agents are currently connected.
|
||||||
|
func (s *Server) handleListAgents(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
http.Error(w, "GET only", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.agents.mu.RLock()
|
||||||
|
defer s.agents.mu.RUnlock()
|
||||||
|
out := make([]map[string]string, 0, len(s.agents.conns))
|
||||||
|
for n, c := range s.agents.conns {
|
||||||
|
if c {
|
||||||
|
out = append(out, map[string]string{"node": n})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Slice(out, func(i, j int) bool { return out[i]["node"] < out[j]["node"] })
|
||||||
|
writeJSON(w, http.StatusOK, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleListRoutes proxies the list_routes RPC to the gateway agent.
|
||||||
|
// The dashboard uses this to show the live <key>_url map from the
|
||||||
|
// currently-checked-out branch's config.php.
|
||||||
|
func (s *Server) handleListRoutes(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
http.Error(w, "GET only", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
raw, err := s.hub.CallAgent(ctx, "gateway", "list_routes", map[string]any{}, 0)
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, http.StatusBadGateway, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write(raw)
|
||||||
|
}
|
||||||
@@ -0,0 +1,243 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/sdp/protocol"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Deployment is a deployment row joined with the current state and
|
||||||
|
// container info. Used by the dashboard's history list and the
|
||||||
|
// "active deployment" badge in the sandbox view.
|
||||||
|
type Deployment struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
SandboxID string `json:"sandboxId,omitempty"`
|
||||||
|
Repository string `json:"repository"`
|
||||||
|
Branch string `json:"branch"`
|
||||||
|
User string `json:"user"`
|
||||||
|
State string `json:"state"`
|
||||||
|
ContainerID string `json:"containerId,omitempty"`
|
||||||
|
HostPort int `json:"hostPort,omitempty"`
|
||||||
|
StartedAt int64 `json:"startedAt"`
|
||||||
|
CompletedAt int64 `json:"completedAt,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// StartDeployment records a new deployment row. Idempotent on id.
|
||||||
|
func (s *Store) StartDeployment(id, repo, branch, user string) error {
|
||||||
|
_, err := s.db.Exec(
|
||||||
|
`INSERT OR IGNORE INTO deployments(id, repository, branch, user, state, started_at) VALUES(?,?,?,?,?,?)`,
|
||||||
|
id, repo, branch, user, "QUEUED", time.Now().UnixMilli(),
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// StartDeploymentInSandbox records a new deployment tied to a sandbox.
|
||||||
|
// sandboxID and hostPort are nullable; empty string / 0 stores NULL.
|
||||||
|
func (s *Store) StartDeploymentInSandbox(id, sandboxID, repo, branch, user string, hostPort int) error {
|
||||||
|
var sb interface{}
|
||||||
|
if sandboxID != "" {
|
||||||
|
sb = sandboxID
|
||||||
|
}
|
||||||
|
var hp interface{}
|
||||||
|
if hostPort > 0 {
|
||||||
|
hp = hostPort
|
||||||
|
}
|
||||||
|
_, err := s.db.Exec(
|
||||||
|
`INSERT OR IGNORE INTO deployments(id, sandbox_id, repository, branch, user, state, host_port, started_at)
|
||||||
|
VALUES(?,?,?,?,?,?,?,?)`,
|
||||||
|
id, sb, repo, branch, user, "QUEUED", hp, time.Now().UnixMilli(),
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// FinishDeployment marks the final state. completed_at is set if state is terminal.
|
||||||
|
func (s *Store) FinishDeployment(id, state string) error {
|
||||||
|
_, err := s.db.Exec(
|
||||||
|
`UPDATE deployments SET state=?, completed_at=? WHERE id=?`,
|
||||||
|
state, time.Now().UnixMilli(), id,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetContainerID records the container id once a deploy is RUNNING.
|
||||||
|
func (s *Store) SetContainerID(id, containerID string) error {
|
||||||
|
_, err := s.db.Exec(`UPDATE deployments SET container_id=? WHERE id=?`, containerID, id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarkStage records a stage transition. ok=1 success, 0 failure.
|
||||||
|
func (s *Store) MarkStage(id, stage string, ok bool) error {
|
||||||
|
v := 0
|
||||||
|
if ok {
|
||||||
|
v = 1
|
||||||
|
}
|
||||||
|
_, err := s.db.Exec(
|
||||||
|
`INSERT INTO progress(deployment_id, stage, ok, at) VALUES(?,?,?,?)`,
|
||||||
|
id, stage, v, time.Now().UnixMilli(),
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// AppendEvent writes an event. Log lines go to .log; progress/status hit SQLite.
|
||||||
|
// The deployment's running state is also updated so /api/deployments/{id} can
|
||||||
|
// serve a snapshot without replaying the whole log.
|
||||||
|
func (s *Store) AppendEvent(e protocol.Event) error {
|
||||||
|
switch e.Kind {
|
||||||
|
case "log":
|
||||||
|
return s.appendLog(e)
|
||||||
|
case "status":
|
||||||
|
_, err := s.db.Exec(`UPDATE deployments SET state=? WHERE id=?`, e.State, e.DeploymentID)
|
||||||
|
return err
|
||||||
|
case "progress":
|
||||||
|
return s.MarkStage(e.DeploymentID, e.Stage, e.State != "FAILED")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) appendLog(e protocol.Event) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
f, ok := s.logs[e.DeploymentID]
|
||||||
|
if !ok {
|
||||||
|
path := filepath.Join(s.dir, "logs", e.DeploymentID+".log")
|
||||||
|
var err error
|
||||||
|
f, err = os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644)
|
||||||
|
if err != nil {
|
||||||
|
s.mu.Unlock()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
s.logs[e.DeploymentID] = f
|
||||||
|
}
|
||||||
|
s.mu.Unlock()
|
||||||
|
ts := time.UnixMilli(e.At).Format("15:04:05.000")
|
||||||
|
_, err := fmt.Fprintf(f, "%s %s\n", ts, e.Line)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// TailLogs returns the last n lines of a deployment's log file. Used by the
|
||||||
|
// dashboard on first connect to backfill.
|
||||||
|
func (s *Store) TailLogs(id string, n int) ([]string, error) {
|
||||||
|
path := filepath.Join(s.dir, "logs", id+".log")
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// ponytail: O(n) scan, fine for tail use. swap to a ring buffer if logs get huge.
|
||||||
|
var lines []string
|
||||||
|
start := 0
|
||||||
|
for i, b := range data {
|
||||||
|
if b == '\n' {
|
||||||
|
lines = append(lines, string(data[start:i]))
|
||||||
|
start = i + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if start < len(data) {
|
||||||
|
lines = append(lines, string(data[start:]))
|
||||||
|
}
|
||||||
|
if n > 0 && len(lines) > n {
|
||||||
|
lines = lines[len(lines)-n:]
|
||||||
|
}
|
||||||
|
return lines, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListDeployments returns deployments, newest first. If sandboxID is set,
|
||||||
|
// filters to that sandbox. limit caps the result.
|
||||||
|
func (s *Store) ListDeployments(sandboxID string, limit int) ([]Deployment, error) {
|
||||||
|
if limit <= 0 || limit > 500 {
|
||||||
|
limit = 100
|
||||||
|
}
|
||||||
|
var (
|
||||||
|
rows *sql.Rows
|
||||||
|
err error
|
||||||
|
)
|
||||||
|
if sandboxID == "" {
|
||||||
|
rows, err = s.db.Query(
|
||||||
|
`SELECT id, COALESCE(sandbox_id,''), repository, branch, COALESCE(user,''), state,
|
||||||
|
COALESCE(container_id,''), COALESCE(host_port,0), started_at, COALESCE(completed_at,0)
|
||||||
|
FROM deployments ORDER BY started_at DESC LIMIT ?`, limit)
|
||||||
|
} else {
|
||||||
|
rows, err = s.db.Query(
|
||||||
|
`SELECT id, COALESCE(sandbox_id,''), repository, branch, COALESCE(user,''), state,
|
||||||
|
COALESCE(container_id,''), COALESCE(host_port,0), started_at, COALESCE(completed_at,0)
|
||||||
|
FROM deployments WHERE sandbox_id=? ORDER BY started_at DESC LIMIT ?`, sandboxID, limit)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []Deployment
|
||||||
|
for rows.Next() {
|
||||||
|
var d Deployment
|
||||||
|
if err := rows.Scan(&d.ID, &d.SandboxID, &d.Repository, &d.Branch, &d.User, &d.State,
|
||||||
|
&d.ContainerID, &d.HostPort, &d.StartedAt, &d.CompletedAt); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, d)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// LatestDeploymentBySandboxService returns the most recent deployment for
|
||||||
|
// the (sandbox, repo) pair, or nil if none.
|
||||||
|
func (s *Store) LatestDeploymentBySandboxService(sandboxID, repo string) (*Deployment, error) {
|
||||||
|
row := s.db.QueryRow(
|
||||||
|
`SELECT id, COALESCE(sandbox_id,''), repository, branch, COALESCE(user,''), state,
|
||||||
|
COALESCE(container_id,''), COALESCE(host_port,0), started_at, COALESCE(completed_at,0)
|
||||||
|
FROM deployments WHERE sandbox_id=? AND repository=? ORDER BY started_at DESC LIMIT 1`,
|
||||||
|
sandboxID, repo)
|
||||||
|
var d Deployment
|
||||||
|
err := row.Scan(&d.ID, &d.SandboxID, &d.Repository, &d.Branch, &d.User, &d.State,
|
||||||
|
&d.ContainerID, &d.HostPort, &d.StartedAt, &d.CompletedAt)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &d, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDeployment returns one deployment by id, or nil if not found.
|
||||||
|
func (s *Store) GetDeployment(id string) (*Deployment, error) {
|
||||||
|
row := s.db.QueryRow(
|
||||||
|
`SELECT id, COALESCE(sandbox_id,''), repository, branch, COALESCE(user,''), state,
|
||||||
|
COALESCE(container_id,''), COALESCE(host_port,0), started_at, COALESCE(completed_at,0)
|
||||||
|
FROM deployments WHERE id=?`, id)
|
||||||
|
var d Deployment
|
||||||
|
err := row.Scan(&d.ID, &d.SandboxID, &d.Repository, &d.Branch, &d.User, &d.State,
|
||||||
|
&d.ContainerID, &d.HostPort, &d.StartedAt, &d.CompletedAt)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &d, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// marshalJSON is a small helper for the json column on environments.
|
||||||
|
func marshalJSON(v map[string]string) (string, error) {
|
||||||
|
b, err := json.Marshal(v)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return string(b), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func unmarshalJSON(s string) (map[string]string, error) {
|
||||||
|
if s == "" {
|
||||||
|
return map[string]string{}, nil
|
||||||
|
}
|
||||||
|
var m map[string]string
|
||||||
|
if err := json.Unmarshal([]byte(s), &m); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
@@ -1,19 +1,17 @@
|
|||||||
// Package store persists deployment progress in SQLite and log lines in
|
// Package store persists deployment progress and Slice-2 metadata in
|
||||||
// append-only .log files. The hot path is AppendEvent — agents emit a lot
|
// SQLite, and log lines in append-only .log files. The hot path is
|
||||||
// of these and the dashboard wants them live.
|
// AppendEvent — agents emit a lot of these and the dashboard wants them
|
||||||
|
// live. Slice 2 adds sandboxes, templates, environments, routes, and
|
||||||
|
// port allocations on top of the deployment rows.
|
||||||
package store
|
package store
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"fmt"
|
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
|
||||||
|
|
||||||
_ "modernc.org/sqlite"
|
_ "modernc.org/sqlite"
|
||||||
|
|
||||||
"github.com/sdp/protocol"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type Store struct {
|
type Store struct {
|
||||||
@@ -34,13 +32,22 @@ func Open(dir string) (*Store, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if _, err := db.Exec(`
|
if _, err := db.Exec(schema); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &Store{db: db, dir: dir, logs: make(map[string]*os.File)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const schema = `
|
||||||
CREATE TABLE IF NOT EXISTS deployments (
|
CREATE TABLE IF NOT EXISTS deployments (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
|
sandbox_id TEXT,
|
||||||
repository TEXT,
|
repository TEXT,
|
||||||
branch TEXT,
|
branch TEXT,
|
||||||
user TEXT,
|
user TEXT,
|
||||||
state TEXT,
|
state TEXT,
|
||||||
|
container_id TEXT,
|
||||||
|
host_port INTEGER,
|
||||||
started_at INTEGER,
|
started_at INTEGER,
|
||||||
completed_at INTEGER
|
completed_at INTEGER
|
||||||
);
|
);
|
||||||
@@ -50,11 +57,59 @@ func Open(dir string) (*Store, error) {
|
|||||||
ok INTEGER,
|
ok INTEGER,
|
||||||
at INTEGER
|
at INTEGER
|
||||||
);
|
);
|
||||||
`); err != nil {
|
|
||||||
return nil, err
|
CREATE TABLE IF NOT EXISTS sandboxes (
|
||||||
}
|
id TEXT PRIMARY KEY,
|
||||||
return &Store{db: db, dir: dir, logs: make(map[string]*os.File)}, nil
|
name TEXT UNIQUE NOT NULL,
|
||||||
}
|
gateway_branch TEXT,
|
||||||
|
gateway_env_id TEXT,
|
||||||
|
gateway_host_port INTEGER,
|
||||||
|
created_at INTEGER,
|
||||||
|
updated_at INTEGER
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS sandbox_services (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
sandbox_id TEXT NOT NULL,
|
||||||
|
repo TEXT NOT NULL,
|
||||||
|
branch TEXT,
|
||||||
|
env_id TEXT,
|
||||||
|
host_port INTEGER,
|
||||||
|
use_ocp INTEGER DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS templates (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT UNIQUE NOT NULL,
|
||||||
|
gateway_branch TEXT,
|
||||||
|
created_at INTEGER,
|
||||||
|
updated_at INTEGER
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS template_services (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
template_id TEXT NOT NULL,
|
||||||
|
repo TEXT NOT NULL,
|
||||||
|
branch TEXT,
|
||||||
|
env_id TEXT,
|
||||||
|
host_port INTEGER,
|
||||||
|
use_ocp INTEGER DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS environments (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT UNIQUE NOT NULL,
|
||||||
|
values_json TEXT NOT NULL,
|
||||||
|
created_at INTEGER,
|
||||||
|
updated_at INTEGER
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS routes (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
sandbox_id TEXT NOT NULL,
|
||||||
|
key TEXT NOT NULL,
|
||||||
|
value TEXT,
|
||||||
|
target_ocp INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
`
|
||||||
|
|
||||||
func (s *Store) Close() error {
|
func (s *Store) Close() error {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
@@ -64,98 +119,3 @@ func (s *Store) Close() error {
|
|||||||
}
|
}
|
||||||
return s.db.Close()
|
return s.db.Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
// StartDeployment records a new deployment row. Idempotent on id.
|
|
||||||
func (s *Store) StartDeployment(id, repo, branch, user string) error {
|
|
||||||
_, err := s.db.Exec(
|
|
||||||
`INSERT OR IGNORE INTO deployments(id, repository, branch, user, state, started_at) VALUES(?,?,?,?,?,?)`,
|
|
||||||
id, repo, branch, user, "QUEUED", time.Now().UnixMilli(),
|
|
||||||
)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// FinishDeployment marks the final state. completed_at is set if state is terminal.
|
|
||||||
func (s *Store) FinishDeployment(id, state string) error {
|
|
||||||
_, err := s.db.Exec(
|
|
||||||
`UPDATE deployments SET state=?, completed_at=? WHERE id=?`,
|
|
||||||
state, time.Now().UnixMilli(), id,
|
|
||||||
)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// MarkStage records a stage transition. ok=1 success, 0 failure.
|
|
||||||
func (s *Store) MarkStage(id, stage string, ok bool) error {
|
|
||||||
v := 0
|
|
||||||
if ok {
|
|
||||||
v = 1
|
|
||||||
}
|
|
||||||
_, err := s.db.Exec(
|
|
||||||
`INSERT INTO progress(deployment_id, stage, ok, at) VALUES(?,?,?,?)`,
|
|
||||||
id, stage, v, time.Now().UnixMilli(),
|
|
||||||
)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// AppendEvent writes an event. Log lines go to .log; progress/status hit SQLite.
|
|
||||||
// The deployment's running state is also updated so /api/deployments/{id} can
|
|
||||||
// serve a snapshot without replaying the whole log.
|
|
||||||
func (s *Store) AppendEvent(e protocol.Event) error {
|
|
||||||
switch e.Kind {
|
|
||||||
case "log":
|
|
||||||
return s.appendLog(e)
|
|
||||||
case "status":
|
|
||||||
_, err := s.db.Exec(`UPDATE deployments SET state=? WHERE id=?`, e.State, e.DeploymentID)
|
|
||||||
return err
|
|
||||||
case "progress":
|
|
||||||
return s.MarkStage(e.DeploymentID, e.Stage, e.State != "FAILED")
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Store) appendLog(e protocol.Event) error {
|
|
||||||
s.mu.Lock()
|
|
||||||
f, ok := s.logs[e.DeploymentID]
|
|
||||||
if !ok {
|
|
||||||
path := filepath.Join(s.dir, "logs", e.DeploymentID+".log")
|
|
||||||
var err error
|
|
||||||
f, err = os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644)
|
|
||||||
if err != nil {
|
|
||||||
s.mu.Unlock()
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
s.logs[e.DeploymentID] = f
|
|
||||||
}
|
|
||||||
s.mu.Unlock()
|
|
||||||
ts := time.UnixMilli(e.At).Format("15:04:05.000")
|
|
||||||
_, err := fmt.Fprintf(f, "%s %s\n", ts, e.Line)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// TailLogs returns the last n lines of a deployment's log file. Used by the
|
|
||||||
// dashboard on first connect to backfill.
|
|
||||||
func (s *Store) TailLogs(id string, n int) ([]string, error) {
|
|
||||||
path := filepath.Join(s.dir, "logs", id+".log")
|
|
||||||
data, err := os.ReadFile(path)
|
|
||||||
if err != nil {
|
|
||||||
if os.IsNotExist(err) {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
// ponytail: O(n) scan, fine for tail use. swap to a ring buffer if logs get huge.
|
|
||||||
var lines []string
|
|
||||||
start := 0
|
|
||||||
for i, b := range data {
|
|
||||||
if b == '\n' {
|
|
||||||
lines = append(lines, string(data[start:i]))
|
|
||||||
start = i + 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if start < len(data) {
|
|
||||||
lines = append(lines, string(data[start:]))
|
|
||||||
}
|
|
||||||
if n > 0 && len(lines) > n {
|
|
||||||
lines = lines[len(lines)-n:]
|
|
||||||
}
|
|
||||||
return lines, nil
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
package store_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/sdp/control-plane/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestSandboxRoundTrip exercises the Slice-2 CRUD path against a
|
||||||
|
// throwaway data directory. The build pipeline runs this; the
|
||||||
|
// real deployment uses the control plane's HTTP layer.
|
||||||
|
func TestSandboxRoundTrip(t *testing.T) {
|
||||||
|
dir, err := os.MkdirTemp("", "sdp-store-")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(dir)
|
||||||
|
|
||||||
|
st, err := store.Open(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open: %v", err)
|
||||||
|
}
|
||||||
|
defer st.Close()
|
||||||
|
|
||||||
|
// env
|
||||||
|
e := store.Environment{ID: "e1", Name: "dev", Values: map[string]string{"FOO": "bar"}}
|
||||||
|
if err := st.CreateEnvironment(e); err != nil {
|
||||||
|
t.Fatalf("env create: %v", err)
|
||||||
|
}
|
||||||
|
got, err := st.GetEnvironment("e1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("env get: %v", err)
|
||||||
|
}
|
||||||
|
if got.Values["FOO"] != "bar" {
|
||||||
|
t.Fatalf("env values: got %v", got.Values)
|
||||||
|
}
|
||||||
|
|
||||||
|
// sandbox
|
||||||
|
sb := store.Sandbox{ID: "s1", Name: "qa", GatewayBranch: "develop", GatewayHostPort: 8080,
|
||||||
|
Services: []store.SandboxService{
|
||||||
|
{ID: "s1-haven", SandboxID: "s1", Repo: "haven", Branch: "main", HostPort: 9001, UseOCP: false, EnvID: "e1"},
|
||||||
|
}}
|
||||||
|
if err := st.CreateSandbox(sb); err != nil {
|
||||||
|
t.Fatalf("sandbox create: %v", err)
|
||||||
|
}
|
||||||
|
gotsb, err := st.GetSandbox("s1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("sandbox get: %v", err)
|
||||||
|
}
|
||||||
|
if len(gotsb.Services) != 1 || gotsb.Services[0].Repo != "haven" {
|
||||||
|
t.Fatalf("sandbox services: %+v", gotsb.Services)
|
||||||
|
}
|
||||||
|
|
||||||
|
// template + clone
|
||||||
|
t1 := store.Template{ID: "t1", Name: "acct", GatewayBranch: "develop",
|
||||||
|
Services: []store.TemplateService{
|
||||||
|
{ID: "t1-haven", TemplateID: "t1", Repo: "haven", Branch: "feature/x", HostPort: 9001, UseOCP: false},
|
||||||
|
}}
|
||||||
|
if err := st.CreateTemplate(t1); err != nil {
|
||||||
|
t.Fatalf("tpl create: %v", err)
|
||||||
|
}
|
||||||
|
if err := st.CloneTemplateIntoSandbox("t1", store.Sandbox{ID: "s2", Name: "qa2"}); err != nil {
|
||||||
|
t.Fatalf("clone: %v", err)
|
||||||
|
}
|
||||||
|
cs, err := st.GetSandbox("s2")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("cloned get: %v", err)
|
||||||
|
}
|
||||||
|
if cs.GatewayBranch != "develop" || len(cs.Services) != 1 {
|
||||||
|
t.Fatalf("cloned content: %+v", cs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// routes
|
||||||
|
if err := st.SetRoutes("s1", []store.Route{
|
||||||
|
{ID: "r1", SandboxID: "s1", Key: "haven", Value: "http://172.18.136.92:9001"},
|
||||||
|
{ID: "r2", SandboxID: "s1", Key: "eredar", Value: "", TargetOCP: true},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("routes: %v", err)
|
||||||
|
}
|
||||||
|
rs, err := st.ListRoutes("s1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("routes list: %v", err)
|
||||||
|
}
|
||||||
|
if len(rs) != 2 {
|
||||||
|
t.Fatalf("routes count: %d", len(rs))
|
||||||
|
}
|
||||||
|
|
||||||
|
// deployment
|
||||||
|
if err := st.StartDeploymentInSandbox("d1", "s1", "haven", "main", "achmad", 9001); err != nil {
|
||||||
|
t.Fatalf("dep start: %v", err)
|
||||||
|
}
|
||||||
|
d, err := st.GetDeployment("d1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("dep get: %v", err)
|
||||||
|
}
|
||||||
|
if d.HostPort != 9001 || d.SandboxID != "s1" {
|
||||||
|
t.Fatalf("dep content: %+v", d)
|
||||||
|
}
|
||||||
|
if err := st.SetContainerID("d1", "container-abc"); err != nil {
|
||||||
|
t.Fatalf("container id: %v", err)
|
||||||
|
}
|
||||||
|
d, _ = st.GetDeployment("d1")
|
||||||
|
if d.ContainerID != "container-abc" {
|
||||||
|
t.Fatalf("container id roundtrip: %q", d.ContainerID)
|
||||||
|
}
|
||||||
|
deps, err := st.ListDeployments("s1", 10)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("deps list: %v", err)
|
||||||
|
}
|
||||||
|
if len(deps) != 1 {
|
||||||
|
t.Fatalf("deps count: %d", len(deps))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -59,6 +59,36 @@ func (h *Hub) AgentWS(st *store.Store, onConnect, onDisconnect func(nodeID strin
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
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
|
var e protocol.Event
|
||||||
if err := json.Unmarshal(raw, &e); err != nil {
|
if err := json.Unmarshal(raw, &e); err != nil {
|
||||||
log.Printf("agent %s: bad event: %v", nodeID, err)
|
log.Printf("agent %s: bad event: %v", nodeID, err)
|
||||||
|
|||||||
@@ -1,16 +1,24 @@
|
|||||||
// Package ws is the WebSocket fan-out for SDP. Two flows:
|
// Package ws is the WebSocket fan-out for SDP. Three flows:
|
||||||
//
|
//
|
||||||
// agent --(events)--> /ws/agent (one conn per agent)
|
// agent --(events)--> /ws/agent (one conn per agent)
|
||||||
// dashboard client --(subscribe)-> /ws/deployments/{id} (one conn per viewer)
|
// dashboard client --(subscribe)-> /ws/deployments/{id} (one conn per viewer)
|
||||||
|
// control plane --(RPC)----> /ws/agent (request/response, sync)
|
||||||
//
|
//
|
||||||
// On agent connect we record the agent's nodeID. On dashboard connect we
|
// On agent connect we record the agent's nodeID. On dashboard connect we
|
||||||
// register a subscriber for one deployment. Events are best-effort fanned out;
|
// register a subscriber for one deployment. Events are best-effort fanned out;
|
||||||
// a slow client is dropped, not allowed to backpressure the agent.
|
// a slow client is dropped, not allowed to backpressure the agent. The RPC
|
||||||
|
// flow is request/response with a per-call channel indexed by a unique id.
|
||||||
package ws
|
package ws
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
cryptorand "crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"sync"
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/gorilla/websocket"
|
"github.com/gorilla/websocket"
|
||||||
|
|
||||||
@@ -21,6 +29,7 @@ var upgrader = websocket.Upgrader{
|
|||||||
CheckOrigin: func(r *http.Request) bool { return true }, // ponytail: internal tool, allow all
|
CheckOrigin: func(r *http.Request) bool { return true }, // ponytail: internal tool, allow all
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Hub is the central registry of agent connections and dashboard subscribers.
|
||||||
type Hub struct {
|
type Hub struct {
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
|
|
||||||
@@ -29,12 +38,18 @@ type Hub struct {
|
|||||||
|
|
||||||
// one channel per connected agent; keyed by nodeID.
|
// one channel per connected agent; keyed by nodeID.
|
||||||
agents map[string]chan<- []byte // outbound to agent (deploy requests, etc.)
|
agents map[string]chan<- []byte // outbound to agent (deploy requests, etc.)
|
||||||
|
|
||||||
|
// pending RPCs keyed by request id. Each value is a channel that
|
||||||
|
// receives the agent's reply. The agent's inbound reader delivers
|
||||||
|
// replies here.
|
||||||
|
pending map[string]chan []byte
|
||||||
}
|
}
|
||||||
|
|
||||||
func New() *Hub {
|
func New() *Hub {
|
||||||
return &Hub{
|
return &Hub{
|
||||||
subs: make(map[string]map[chan protocol.Event]struct{}),
|
subs: make(map[string]map[chan protocol.Event]struct{}),
|
||||||
agents: make(map[string]chan<- []byte),
|
agents: make(map[string]chan<- []byte),
|
||||||
|
pending: make(map[string]chan []byte),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,7 +96,7 @@ func (h *Hub) Subscribe(deploymentID string) (chan protocol.Event, func()) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// RegisterAgent stores the outbound channel for a node. Used when the control
|
// RegisterAgent stores the outbound channel for a node. Used when the control
|
||||||
// plane wants to send something back to an agent (deploy requests, stop, etc.).
|
// plane wants to send something back to an agent (deploy requests, RPCs, etc.).
|
||||||
func (h *Hub) RegisterAgent(nodeID string, out chan<- []byte) {
|
func (h *Hub) RegisterAgent(nodeID string, out chan<- []byte) {
|
||||||
h.mu.Lock()
|
h.mu.Lock()
|
||||||
h.agents[nodeID] = out
|
h.agents[nodeID] = out
|
||||||
@@ -91,7 +106,19 @@ func (h *Hub) RegisterAgent(nodeID string, out chan<- []byte) {
|
|||||||
func (h *Hub) UnregisterAgent(nodeID string) {
|
func (h *Hub) UnregisterAgent(nodeID string) {
|
||||||
h.mu.Lock()
|
h.mu.Lock()
|
||||||
delete(h.agents, nodeID)
|
delete(h.agents, nodeID)
|
||||||
|
// also fail any pending RPCs.
|
||||||
|
toFail := make([]chan []byte, 0, len(h.pending))
|
||||||
|
for _, ch := range h.pending {
|
||||||
|
toFail = append(toFail, ch)
|
||||||
|
}
|
||||||
|
h.pending = make(map[string]chan []byte)
|
||||||
h.mu.Unlock()
|
h.mu.Unlock()
|
||||||
|
for _, ch := range toFail {
|
||||||
|
select {
|
||||||
|
case ch <- nil:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// SendToAgent best-effort sends a payload to a connected agent. Returns false
|
// SendToAgent best-effort sends a payload to a connected agent. Returns false
|
||||||
@@ -110,3 +137,74 @@ func (h *Hub) SendToAgent(nodeID string, payload []byte) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CallAgent is a request/response round-trip over the agent's WS. It sends
|
||||||
|
// {op, id, data} and waits for a {op:"reply", id, ...} back. The raw
|
||||||
|
// reply JSON is returned; the caller unmarshals into the concrete type.
|
||||||
|
//
|
||||||
|
// Errors: ErrAgentOffline, ErrRPCTimeout, or a delivery failure.
|
||||||
|
func (h *Hub) CallAgent(ctx context.Context, nodeID, op string, data interface{}, timeout time.Duration) ([]byte, error) {
|
||||||
|
id := newRPCID()
|
||||||
|
reply := make(chan []byte, 1)
|
||||||
|
|
||||||
|
h.mu.Lock()
|
||||||
|
h.pending[id] = reply
|
||||||
|
h.mu.Unlock()
|
||||||
|
defer func() {
|
||||||
|
h.mu.Lock()
|
||||||
|
delete(h.pending, id)
|
||||||
|
h.mu.Unlock()
|
||||||
|
}()
|
||||||
|
|
||||||
|
frame := map[string]interface{}{"op": op, "id": id, "data": data}
|
||||||
|
payload, err := json.Marshal(frame)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !h.SendToAgent(nodeID, payload) {
|
||||||
|
return nil, ErrAgentOffline
|
||||||
|
}
|
||||||
|
if timeout <= 0 {
|
||||||
|
timeout = 10 * time.Second
|
||||||
|
}
|
||||||
|
timer := time.NewTimer(timeout)
|
||||||
|
defer timer.Stop()
|
||||||
|
select {
|
||||||
|
case raw := <-reply:
|
||||||
|
if raw == nil {
|
||||||
|
return nil, ErrAgentOffline
|
||||||
|
}
|
||||||
|
return raw, nil
|
||||||
|
case <-timer.C:
|
||||||
|
return nil, ErrRPCTimeout
|
||||||
|
case <-ctx.Done():
|
||||||
|
return nil, ctx.Err()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeliverAgentReply is called from the agent's inbound reader when a
|
||||||
|
// frame with op="reply" arrives. It looks up the pending call by id and
|
||||||
|
// hands the payload over.
|
||||||
|
func (h *Hub) DeliverAgentReply(id string, raw []byte) {
|
||||||
|
h.mu.RLock()
|
||||||
|
ch, ok := h.pending[id]
|
||||||
|
h.mu.RUnlock()
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case ch <- raw:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrAgentOffline = errors.New("agent offline")
|
||||||
|
ErrRPCTimeout = errors.New("rpc timeout")
|
||||||
|
)
|
||||||
|
|
||||||
|
func newRPCID() string {
|
||||||
|
var b [8]byte
|
||||||
|
_, _ = cryptorand.Read(b[:])
|
||||||
|
return hex.EncodeToString(b[:])
|
||||||
|
}
|
||||||
|
|||||||
@@ -20,8 +20,10 @@ type Event struct {
|
|||||||
// Credentials are passed per-operation; agents MUST NOT log or persist them.
|
// Credentials are passed per-operation; agents MUST NOT log or persist them.
|
||||||
type DeployRequest struct {
|
type DeployRequest struct {
|
||||||
DeploymentID string `json:"deploymentId"`
|
DeploymentID string `json:"deploymentId"`
|
||||||
|
SandboxID string `json:"sandboxId,omitempty"` // owning sandbox (Slice 2)
|
||||||
Repository string `json:"repository"` // name from agent's repo config
|
Repository string `json:"repository"` // name from agent's repo config
|
||||||
Branch string `json:"branch"`
|
Branch string `json:"branch"`
|
||||||
|
HostPort int `json:"hostPort,omitempty"` // host port to bind the container to
|
||||||
Env map[string]string `json:"env,omitempty"` // injected into container
|
Env map[string]string `json:"env,omitempty"` // injected into container
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
Password string `json:"password"`
|
Password string `json:"password"`
|
||||||
@@ -33,3 +35,23 @@ type DeployResponse struct {
|
|||||||
OK bool `json:"ok"`
|
OK bool `json:"ok"`
|
||||||
Error string `json:"error,omitempty"`
|
Error string `json:"error,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RepoInfo describes one repository the agent knows about.
|
||||||
|
type RepoInfo struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
// DefaultBranch is best-effort; empty if the repo is empty or unreadable.
|
||||||
|
DefaultBranch string `json:"defaultBranch,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RouteOverride is a single "<service>_url" line the gateway agent should
|
||||||
|
// rewrite in the API gateway's config.php. The key is the PHP array key
|
||||||
|
// (e.g. "haven_url"); the value is the new URL (e.g.
|
||||||
|
// "http://172.18.136.92:9001"). TargetOCP=true means "leave it alone /
|
||||||
|
// point back at the OCP URL"; in that case the agent should restore the
|
||||||
|
// original value from its snapshot.
|
||||||
|
type RouteOverride struct {
|
||||||
|
Key string `json:"key"` // e.g. "haven_url"
|
||||||
|
Value string `json:"value"` // new URL, e.g. "http://172.18.136.92:9001"
|
||||||
|
TargetOCP bool `json:"targetOcp"` // if true, restore OCP URL from snapshot
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user