55d7705c63
- 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}.
201 lines
5.6 KiB
Go
201 lines
5.6 KiB
Go
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
|
|
}
|