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:
Achmad
2026-06-24 03:58:53 +00:00
parent 2bc3ff73a2
commit 55d7705c63
17 changed files with 1244 additions and 317 deletions
+157
View File
@@ -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)
}