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:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user