Files
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

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
}