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}.
158 lines
4.2 KiB
Go
158 lines
4.2 KiB
Go
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)
|
|
}
|