Initial SDP skeleton

Sandbox Deployment Platform — Go control plane + agents, NextJS dashboard,
nginx reverse proxy. Cross-compile via Docker; deploy via sshpass to
172.18.136.92 (micro) and 172.18.139.186 (gateway).

- control-plane: HTTP API, WS hub, SQLite (modernc.org/sqlite) for
  progress, .log files for log persistence
- agent-micro / agent-gateway: alpine:3.20 + bind-mounted repo,
  binary exec'd in container, no Dockerfile build step
- dashboard: NextJS static export + shadcn/ui components, single
  WebSocket hook
- docker-compose.yml: three services on alpine:latest with docker
  socket bind for agents
- scripts/: build.sh (golang:1.23-alpine cross-compile), deploy.sh,
  patch-nginx.sh (idempotent nginx splice), ssh wrappers

Runtime model: pass-through Bitbucket creds per deploy, never logged or
persisted on the agent. Control plane never touches git or docker
directly — agents do all the work locally.
This commit is contained in:
Achmad Setyabudi Susilo
2026-06-24 07:25:01 +07:00
commit 3d99940658
47 changed files with 4068 additions and 0 deletions
+38
View File
@@ -0,0 +1,38 @@
# Built artifacts
bin/
dashboard/out/
dashboard/.next/
# Node
dashboard/node_modules/
dashboard/npm-debug.log*
dashboard/yarn-error.log*
dashboard/.pnpm-debug.log*
# Go
**/*.test
**/*.out
**/coverage.txt
gocache/
*.prof
# Local data
data/
*.db
*.db-journal
*.db-wal
*.db-shm
logs/
# Editor / OS
.DS_Store
.idea/
.vscode/
*.swp
*.swo
*~
# Env
.env
.env.local
.env.*.local
+65
View File
@@ -0,0 +1,65 @@
# Sandbox Deployment Platform (SDP)
Internal deployment platform for Backend/QA. Lets a developer deploy a feature
branch into an isolated sandbox, with the API Gateway routing selected
services to the sandbox and the rest to OCP. See [REQUIREMENTS.md](REQUIREMENTS.md)
for the full spec.
## Layout
```
.
├── protocol/ # shared wire types (Event, DeployRequest)
├── control-plane/ # Go. HTTP API + WS hub + SQLite/.log persistence
├── agent-micro/ # Go. Runs on 172.18.136.92, deploys Go microservices
├── agent-gateway/ # Go. Runs on 172.18.139.186, deploys the API Gateway
├── dashboard/ # NextJS static export, served by nginx
└── nginx/ # reverse proxy + try_files for the dashboard
```
## End-to-end smoke (manual)
Prereqs: Go 1.22+, Node 18+, Docker on each agent VM, alpine:3.20 loaded
locally (`docker load -i alpine.tar`).
1. Build everything:
```bash
cd protocol && go build ./...
cd ../control-plane && go build -o bin/control-plane ./cmd/control-plane
cd ../agent-micro && go build -o bin/agent-micro ./cmd/agent-micro
cd ../agent-gateway && go build -o bin/agent-gateway ./cmd/agent-gateway
cd ../dashboard && npm install && npm run build
```
2. Start the control plane:
```bash
./control-plane/bin/control-plane -addr :8080 -data ./data
```
3. Start the micro agent on 172.18.136.92:
```bash
SDP_CP_URL=ws://172.18.139.186:8080/ws/agent SDP_NODE_ID=micro \
./agent-micro/bin/agent-micro
```
4. Start the gateway agent on 172.18.139.186:
```bash
SDP_CP_URL=ws://172.18.139.186:8080/ws/agent SDP_NODE_ID=gateway \
./agent-gateway/bin/agent-gateway
```
5. Point nginx at the dashboard build (`dashboard/out/`) and the control
plane (`:8080`). See `nginx/nginx.conf`.
6. Open `http://<nginx-host>/`, sign in with any Bitbucket creds, pick
`account` → `feature/login-error`, click Deploy. Watch the stage
checkmarks and the log stream.
## Notes
- Credentials are passed per-operation to the agent and never persisted
on the agent longer than the operation.
- The runtime model is `alpine:3.20` + bind-mounted repo + exec'd binary.
No Dockerfile build step on the agent.
- Logs persist to `<data>/logs/<deploymentId>.log`. SQLite holds progress
snapshots and final state.
+1404
View File
File diff suppressed because it is too large Load Diff
+170
View File
@@ -0,0 +1,170 @@
// Command agent-gateway runs on the API Gateway VM (172.18.139.186). It
// maintains a WebSocket to the control plane and deploys the gateway
// service. The gateway uses the same pipeline as the micro services —
// ponytail: same shape, different repo map. If the gateway ever needs a
// different build (PHP composer install, etc.), split the deployer package.
package main
import (
"context"
"encoding/json"
"flag"
"log"
"net/url"
"os"
"sync"
"time"
"github.com/docker/docker/client"
"github.com/gorilla/websocket"
"github.com/sdp/agent-micro/internal/deployer"
"github.com/sdp/agent-micro/internal/gitutil"
"github.com/sdp/protocol"
)
var repos = map[string]string{
"api-gateway": "/home/user/SDP",
}
func main() {
cpURL := flag.String("cp", envOr("SDP_CP_URL", "ws://localhost:8080/ws/agent"), "control plane WS URL")
nodeID := flag.String("node", envOr("SDP_NODE_ID", "gateway"), "node id sent in WS query")
flag.Parse()
cli, err := client.NewClientWithOpts(client.FromEnv)
if err != nil {
log.Fatalf("docker client: %v", err)
}
u, _ := url.Parse(*cpURL)
q := u.Query()
q.Set("node", *nodeID)
u.RawQuery = q.Encode()
out := make(chan []byte, 128)
var connMu sync.Mutex
var conn *websocket.Conn
go writer(&conn, &connMu, out)
for {
c, err := dial(u)
if err != nil {
log.Printf("dial: %v; retrying in 2s", err)
time.Sleep(2 * time.Second)
continue
}
connMu.Lock()
conn = c
connMu.Unlock()
log.Printf("agent-gateway connected as %s", *nodeID)
readLoop(c, cli, out, &connMu, &conn)
}
}
func dial(u *url.URL) (*websocket.Conn, error) {
log.Printf("connecting to %s", u)
return websocket.DefaultDialer.Dial(u.String(), nil)
}
func writer(conn **websocket.Conn, mu *sync.Mutex, out <-chan []byte) {
for msg := range out {
mu.Lock()
c := *conn
mu.Unlock()
if c == nil {
continue
}
if err := c.WriteMessage(websocket.TextMessage, msg); err != nil {
log.Printf("write: %v", err)
}
}
}
type runState struct {
deployer *deployer.Deployer
cancel context.CancelFunc
}
func readLoop(c *websocket.Conn, cli *dockerclient.Client, out chan<- []byte, mu *sync.Mutex, connPtr **websocket.Conn) {
inflight := map[string]*runState{}
for {
_, raw, err := c.ReadMessage()
if err != nil {
log.Printf("read: %v", err)
mu.Lock()
if *connPtr == c {
*connPtr = nil
}
mu.Unlock()
_ = c.Close()
return
}
var frame struct {
Op string `json:"op"`
Data protocol.DeployRequest `json:"data"`
ID string `json:"id"`
}
if err := json.Unmarshal(raw, &frame); err != nil {
log.Printf("bad frame: %v", err)
continue
}
switch frame.Op {
case "deploy":
repoPath, ok := repos[frame.Data.Repository]
if !ok {
emit(out, protocol.Event{
DeploymentID: frame.Data.DeploymentID,
Kind: "status",
State: "FAILED",
At: time.Now().UnixMilli(),
})
continue
}
d := deployer.New(cli, frame.Data.DeploymentID,
frame.Data.Repository, repoPath,
frame.Data.Branch, frame.Data.Env,
gitutil.Creds{Username: frame.Data.Username, Password: frame.Data.Password},
)
ctx, cancel := context.WithCancel(context.Background())
inflight[frame.Data.DeploymentID] = &runState{deployer: d, cancel: cancel}
go runDeploy(d, ctx, out)
case "stop":
if rs, ok := inflight[frame.ID]; ok {
_ = rs.deployer.Stop(context.Background())
rs.cancel()
}
}
}
}
func runDeploy(d *deployer.Deployer, ctx context.Context, out chan<- []byte) {
events := make(chan protocol.Event, 64)
go func() {
state := d.Run(ctx, events)
if state == "RUNNING" {
d.StreamLogs(ctx, events)
}
close(events)
}()
for e := range events {
emit(out, e)
}
}
func emit(out chan<- []byte, e protocol.Event) {
b, _ := json.Marshal(e)
select {
case out <- b:
default:
}
}
func envOr(k, def string) string {
if v := os.Getenv(k); v != "" {
return v
}
return def
}
+8
View File
@@ -0,0 +1,8 @@
module github.com/sdp/agent-gateway
go 1.23
require (
github.com/docker/docker/client v0.0.0-00010101000000-000000000000
github.com/sdp/protocol v0.0.0
)
+180
View File
@@ -0,0 +1,180 @@
// Command agent-micro runs on the microservices VM (172.18.136.92). It
// maintains a WebSocket to the control plane, accepts deploy/stop commands,
// and runs the build+container pipeline locally.
package main
import (
"context"
"encoding/json"
"flag"
"log"
"net/url"
"os"
"sync"
"time"
"github.com/docker/docker/client"
"github.com/gorilla/websocket"
"github.com/sdp/agent-micro/internal/deployer"
"github.com/sdp/agent-micro/internal/gitutil"
"github.com/sdp/protocol"
)
var repos = map[string]string{
// ponytail: hand-curated from REQUIREMENTS.md. Real version reads a yaml
// config file. Adding a new service = one line.
"account": "/home/user/AppGolang/account",
"payment": "/home/user/AppGolang/payment",
"user": "/home/user/AppGolang/user",
"notification": "/home/user/AppGolang/notification",
}
func main() {
cpURL := flag.String("cp", envOr("SDP_CP_URL", "ws://localhost:8080/ws/agent"), "control plane WS URL")
nodeID := flag.String("node", envOr("SDP_NODE_ID", "micro"), "node id sent in WS query")
flag.Parse()
cli, err := client.NewClientWithOpts(client.FromEnv)
if err != nil {
log.Fatalf("docker client: %v", err)
}
u, _ := url.Parse(*cpURL)
q := u.Query()
q.Set("node", *nodeID)
u.RawQuery = q.Encode()
out := make(chan []byte, 128)
var connMu sync.Mutex
var conn *websocket.Conn
go writer(&conn, &connMu, out)
for {
c, err := dial(u)
if err != nil {
log.Printf("dial: %v; retrying in 2s", err)
time.Sleep(2 * time.Second)
continue
}
connMu.Lock()
conn = c
connMu.Unlock()
log.Printf("agent-micro connected as %s", *nodeID)
readLoop(c, cli, out, &connMu, &conn)
}
}
func dial(u *url.URL) (*websocket.Conn, error) {
log.Printf("connecting to %s", u)
return websocket.DefaultDialer.Dial(u.String(), nil)
}
// writer pumps outbound events to whichever conn is current. If conn is
// nil (during reconnect), messages buffer until the next conn is set.
func writer(conn **websocket.Conn, mu *sync.Mutex, out <-chan []byte) {
for msg := range out {
mu.Lock()
c := *conn
mu.Unlock()
if c == nil {
continue
}
if err := c.WriteMessage(websocket.TextMessage, msg); err != nil {
log.Printf("write: %v", err)
}
}
}
type runState struct {
deployer *deployer.Deployer
cancel context.CancelFunc
}
func readLoop(c *websocket.Conn, cli *dockerclient.Client, out chan<- []byte, mu *sync.Mutex, connPtr **websocket.Conn) {
inflight := map[string]*runState{}
for {
_, raw, err := c.ReadMessage()
if err != nil {
log.Printf("read: %v", err)
mu.Lock()
if *connPtr == c {
*connPtr = nil
}
mu.Unlock()
_ = c.Close()
return
}
var frame struct {
Op string `json:"op"`
Data protocol.DeployRequest `json:"data"`
ID string `json:"id"`
}
if err := json.Unmarshal(raw, &frame); err != nil {
log.Printf("bad frame: %v", err)
continue
}
switch frame.Op {
case "deploy":
repoPath, ok := repos[frame.Data.Repository]
if !ok {
emit(out, protocol.Event{
DeploymentID: frame.Data.DeploymentID,
Kind: "status",
State: "FAILED",
At: time.Now().UnixMilli(),
})
continue
}
d := deployer.New(cli, frame.Data.DeploymentID,
frame.Data.Repository, repoPath,
frame.Data.Branch, frame.Data.Env,
gitutil.Creds{Username: frame.Data.Username, Password: frame.Data.Password},
)
ctx, cancel := context.WithCancel(context.Background())
inflight[frame.Data.DeploymentID] = &runState{deployer: d, cancel: cancel}
go runDeploy(d, ctx, out)
case "stop":
if rs, ok := inflight[frame.ID]; ok {
_ = rs.deployer.Stop(context.Background())
rs.cancel() // unblock StreamLogs
}
}
}
}
func runDeploy(d *deployer.Deployer, ctx context.Context, out chan<- []byte) {
events := make(chan protocol.Event, 64)
// producer: Run pipelines, then StreamLogs tails the container. Both
// write to the same channel. We close it when both are done so the
// drain loop below can exit.
go func() {
state := d.Run(ctx, events)
if state == "RUNNING" {
d.StreamLogs(ctx, events)
}
close(events)
}()
for e := range events {
emit(out, e)
}
}
func emit(out chan<- []byte, e protocol.Event) {
b, _ := json.Marshal(e)
select {
case out <- b:
default:
// ponytail: drop on backpressure. Deploys are rare.
}
}
func envOr(k, def string) string {
if v := os.Getenv(k); v != "" {
return v
}
return def
}
+9
View File
@@ -0,0 +1,9 @@
module github.com/sdp/agent-micro
go 1.23
require (
github.com/docker/docker/client v0.0.0-00010101000000-000000000000
github.com/docker/go-connections v0.5.0
github.com/sdp/protocol v0.0.0
)
+204
View File
@@ -0,0 +1,204 @@
// Package deployer is the per-deployment state machine. One Deployer is
// created per DeployRequest; it runs the stages sequentially and emits
// events on its output channel. The caller (the agent) reads events and
// fans them out to the control plane.
//
// Runtime model (per REQUIREMENTS.md):
// 1. git fetch / checkout / pull on the host
// 2. go build -o <bin> on the host (the VM has the Go toolchain)
// 3. docker run alpine:3.20 with -v <repo>:/src and command /src/<bin>
// — NO Dockerfile, NO image build step. The alpine image must be
// loaded on the host ahead of time (manual docker load).
// 4. environment variables from the Environment config are passed via
// -e flags into the container.
package deployer
import (
"context"
"fmt"
"os/exec"
"path/filepath"
"strings"
"time"
"github.com/docker/docker/api/types/container"
dockerclient "github.com/docker/docker/client"
"github.com/docker/go-connections/nat"
"github.com/sdp/agent-micro/internal/gitutil"
"github.com/sdp/protocol"
)
type Deployer struct {
ID string
Repository string
RepoPath string
Branch string
Env map[string]string
Creds gitutil.Creds
cli *dockerclient.Client
binName string
imageTag string
containerID string
}
func New(cli *dockerclient.Client, id, repo, path, branch string, env map[string]string, c gitutil.Creds) *Deployer {
return &Deployer{
ID: id,
Repository: repo,
RepoPath: path,
Branch: branch,
Env: env,
Creds: c,
cli: cli,
binName: "app-" + repo,
imageTag: "alpine:3.20", // ponytail: pre-loaded on the VM. Swap to a configurable tag.
}
}
// Run executes the full pipeline. It pushes events to out and returns the
// final state (RUNNING on success, FAILED otherwise).
func (d *Deployer) Run(ctx context.Context, out chan<- protocol.Event) string {
emit := func(kind, state, stage, line string) {
out <- protocol.Event{
DeploymentID: d.ID,
Kind: kind,
State: state,
Stage: stage,
Line: line,
At: time.Now().UnixMilli(),
}
}
stages := []struct {
name string
fn func() error
}{
{"git fetch", func() error { _, err := gitutil.Fetch(ctx, d.RepoPath, d.Creds); return err }},
{"git checkout", func() error { _, err := gitutil.Checkout(ctx, d.RepoPath, d.Branch, d.Creds); return err }},
{"git pull", func() error { _, err := gitutil.Pull(ctx, d.RepoPath, d.Creds); return err }},
{"go build", d.build},
{"start container", d.startContainer},
}
for _, s := range stages {
emit("progress", "", s.name, "starting "+s.name)
if err := s.fn(); err != nil {
emit("progress", "FAILED", s.name, err.Error())
emit("status", "FAILED", "", "")
return "FAILED"
}
emit("progress", "", s.name, "ok")
}
emit("status", "RUNNING", "", "")
return "RUNNING"
}
func (d *Deployer) build(ctx context.Context) error {
binPath := filepath.Join(d.RepoPath, d.binName)
cmd := exec.CommandContext(ctx, "go", "build", "-o", binPath, "./...")
cmd.Dir = d.RepoPath
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("go build: %w: %s", err, out)
}
return nil
}
// startContainer runs `alpine:3.20` with the host repo dir bind-mounted at
// /src and the binary executed as the container command. The container's
// working dir is set to the bind mount so the binary finds its config files.
func (d *Deployer) startContainer(ctx context.Context) error {
envList := make([]string, 0, len(d.Env))
for k, v := range d.Env {
envList = append(envList, k+"="+v)
}
cfg := &container.Config{
Image: d.imageTag,
Cmd: []string{"/src/" + d.binName},
Env: envList,
WorkingDir: "/src",
ExposedPorts: nat.PortSet{
"8080/tcp": struct{}{},
},
}
hostCfg := &container.HostConfig{
Binds: []string{d.RepoPath + ":/src"},
RestartPolicy: container.RestartPolicy{Name: "unless-stopped"},
}
resp, err := d.cli.ContainerCreate(ctx, cfg, hostCfg, nil, nil, d.containerName())
if err != nil {
return fmt.Errorf("container create: %w", err)
}
d.containerID = resp.ID
if err := d.cli.ContainerStart(ctx, resp.ID, container.StartOptions{}); err != nil {
return fmt.Errorf("container start: %w", err)
}
return nil
}
func (d *Deployer) containerName() string {
return "sdp-" + d.Repository + "-" + d.ID
}
func (d *Deployer) Stop(ctx context.Context) error {
if d.containerID == "" {
return nil
}
return d.cli.ContainerStop(ctx, d.containerID, container.StopOptions{})
}
// StreamLogs tails the container's logs into the events channel until ctx
// is cancelled.
func (d *Deployer) StreamLogs(ctx context.Context, out chan<- protocol.Event) {
if d.containerID == "" {
return
}
rc, err := d.cli.ContainerLogs(ctx, d.containerID, container.LogsOptions{
ShowStdout: true,
ShowStderr: true,
Follow: true,
Timestamps: false,
})
if err != nil {
return
}
defer rc.Close()
// ponytail: Docker multiplexes stdout/stderr; the leading 8 bytes per
// frame are the stream header. For MVP we just split on newlines and
// don't try to demux.
buf := make([]byte, 4096)
var carry strings.Builder
for {
n, err := rc.Read(buf)
if n > 0 {
carry.Write(buf[:n])
for {
s := carry.String()
i := strings.IndexByte(s, '\n')
if i < 0 {
break
}
line := s[:i]
carry.Reset()
carry.WriteString(s[i+1:])
if len(line) > 8 && line[0] <= 2 {
line = line[8:]
}
if line != "" {
out <- protocol.Event{
DeploymentID: d.ID,
Kind: "log",
Line: line,
At: time.Now().UnixMilli(),
}
}
}
}
if err != nil {
return
}
}
}
+65
View File
@@ -0,0 +1,65 @@
// Package gitutil wraps the few git operations the agent needs. Credentials
// are passed in per-call and never written to disk — every command sets them
// via -c credential helpers for the lifetime of the subprocess.
package gitutil
import (
"context"
"fmt"
"os/exec"
"strings"
)
// Creds is a username/password. We pass them through GIT_ASKPASS so they
// never appear on the command line or in process listings.
type Creds struct {
Username string
Password string
}
// Fetch runs `git fetch --prune origin`. Uses the per-command credential
// helper to inject creds without touching the repo's stored config.
func Fetch(ctx context.Context, repoDir string, c Creds) (string, error) {
return runGit(ctx, repoDir, c, "fetch", "--prune", "origin")
}
// Checkout switches to branch and updates the working tree.
func Checkout(ctx context.Context, repoDir, branch string, c Creds) (string, error) {
return runGit(ctx, repoDir, c, "checkout", "-f", branch)
}
// Pull fast-forwards the branch to match origin. Safe no-op if up to date.
func Pull(ctx context.Context, repoDir string, c Creds) (string, error) {
return runGit(ctx, repoDir, c, "pull", "--ff-only")
}
// Probe validates that the credentials work for a given remote. Used at
// login. Tries `git ls-remote <origin> HEAD`; succeeds even on an empty repo.
func Probe(ctx context.Context, repoDir string, c Creds) error {
_, err := runGit(ctx, repoDir, c, "ls-remote", "--heads", "origin", "HEAD")
return err
}
func runGit(ctx context.Context, repoDir string, c Creds, args ...string) (string, error) {
cmd := exec.CommandContext(ctx, "git", args...)
cmd.Dir = repoDir
// GIT_ASKPASS gives us a per-command credential helper. We just echo the
// creds back. The "username" / "password" args are sent to the script's
// argv by git.
askpass := fmt.Sprintf(`#!/bin/sh
case "$1" in
username) echo %q ;;
password) echo %q ;;
esac`, c.Username, c.Password)
cmd.Env = append(cmd.Environ(),
"GIT_ASKPASS=/dev/stdin",
"GIT_TERMINAL_PROMPT=0",
)
// ponytail: passing askpass via stdin is portable across Linux/macOS.
cmd.Stdin = strings.NewReader(askpass)
out, err := cmd.CombinedOutput()
if err != nil {
return string(out), fmt.Errorf("git %s: %w: %s", strings.Join(args, " "), err, out)
}
return string(out), nil
}
+30
View File
@@ -0,0 +1,30 @@
// Command control-plane is the SDP brain. It owns the metadata DB, the
// WebSocket hub, and the HTTP API. Agents and the dashboard talk to it;
// it never SSHes, never builds, never touches git directly.
package main
import (
"log"
"net/http"
"github.com/sdp/control-plane/internal/api"
"github.com/sdp/control-plane/internal/config"
"github.com/sdp/control-plane/internal/store"
"github.com/sdp/control-plane/internal/ws"
)
func main() {
cfg := config.Load()
st, err := store.Open(cfg.DataDir)
if err != nil {
log.Fatalf("store: %v", err)
}
defer st.Close()
hub := ws.New()
srv := api.New(st, hub)
log.Printf("control-plane listening on %s (data=%s)", cfg.Addr, cfg.DataDir)
log.Fatal(http.ListenAndServe(cfg.Addr, srv.Routes()))
}
+8
View File
@@ -0,0 +1,8 @@
module github.com/sdp/control-plane
go 1.23
require (
github.com/gorilla/websocket v1.5.1
modernc.org/sqlite v1.28.0
)
+256
View File
@@ -0,0 +1,256 @@
// Package api wires the HTTP endpoints. Kept on net/http — no router lib
// for a handful of endpoints, stdlib mux is plenty.
package api
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"net/http"
"strings"
"sync"
"github.com/sdp/control-plane/internal/store"
"github.com/sdp/control-plane/internal/ws"
"github.com/sdp/protocol"
)
type Server struct {
st *store.Store
hub *ws.Hub
agents *AgentRegistry
sess *Sessions
}
type AgentRegistry struct {
mu sync.RWMutex
conns map[string]bool // nodeIDs currently connected over WS
}
func New(st *store.Store, hub *ws.Hub) *Server {
return &Server{
st: st,
hub: hub,
agents: &AgentRegistry{conns: make(map[string]bool)},
sess: NewSessions(),
}
}
func (s *Server) Routes() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("/api/login", s.handleLogin)
mux.HandleFunc("/api/repos", s.handleListRepos)
mux.HandleFunc("/api/repos/branches", s.handleListBranches)
mux.HandleFunc("/api/deployments", s.handleDeployments) // GET list, POST create
mux.HandleFunc("/api/deployments/stop", s.handleStopDeployment) // POST
mux.Handle("/ws/agent", s.hub.AgentWS(s.st,
func(nodeID string) {
s.agents.mu.Lock()
s.agents.conns[nodeID] = true
s.agents.mu.Unlock()
},
func(nodeID string) {
s.agents.mu.Lock()
delete(s.agents.conns, nodeID)
s.agents.mu.Unlock()
},
))
mux.HandleFunc("/ws/deployments/", s.hub.DeploymentWS(s.st))
return s.withAuth(mux)
}
// withAuth checks the session cookie on /api/* (skipping login). /ws/* is
// protected at the handler — we don't pass auth in headers easily on the WS
// upgrade from the browser, so the dashboard sends ?token=... instead.
func (s *Server) withAuth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !strings.HasPrefix(r.URL.Path, "/api/") || r.URL.Path == "/api/login" {
next.ServeHTTP(w, r)
return
}
c, err := r.Cookie("sdp_session")
if err != nil || !s.sess.Valid(c.Value) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
next.ServeHTTP(w, r)
})
}
// ---- login ----
type loginReq struct {
Username string `json:"username"`
Password string `json:"password"`
Repo string `json:"repo"` // optional: validate against this specific repo
}
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "POST only", http.StatusMethodNotAllowed)
return
}
var body loginReq
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
http.Error(w, "bad json", http.StatusBadRequest)
return
}
// ponytail: trust boundary lives in the agent — it does the actual git
// ls-remote. The control plane just hands off credentials per-op.
// For login we ask any connected agent to validate. If none are
// connected, fail. Real impl: pick a known bootstrap repo.
ok := s.validateViaAgent(body.Username, body.Password, body.Repo)
if !ok {
http.Error(w, "login failed", http.StatusUnauthorized)
return
}
tok := s.sess.Issue(body.Username)
http.SetCookie(w, &http.Cookie{
Name: "sdp_session",
Value: tok,
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
MaxAge: 12 * 3600,
})
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"ok":true}`))
}
// validateViaAgent does a git ls-remote through one of the connected agents.
// The agent holds the repos; the control plane never touches git directly.
//
// ponytail: MVP stub. Returns true if any agent is connected so the smoke
// flow can run. Real impl will send a "probe" frame over the agent's WS
// and wait for a reply.
func (s *Server) validateViaAgent(user, pass, repo string) bool {
_ = user
_ = pass
_ = repo
s.agents.mu.RLock()
defer s.agents.mu.RUnlock()
return len(s.agents.conns) > 0
}
// ---- repos ----
type repoInfo struct {
Name string `json:"name"`
Node string `json:"node"`
Path string `json:"path"`
}
func (s *Server) handleListRepos(w http.ResponseWriter, r *http.Request) {
// ponytail: real impl asks the connected agents for their repo list.
// For MVP smoke, stub with the spec's example.
repos := []repoInfo{
{Name: "account", Node: "micro", Path: "/home/user/AppGolang/account"},
{Name: "payment", Node: "micro", Path: "/home/user/AppGolang/payment"},
{Name: "user", Node: "micro", Path: "/home/user/AppGolang/user"},
{Name: "notification", Node: "micro", Path: "/home/user/AppGolang/notification"},
{Name: "api-gateway", Node: "gateway", Path: "/home/user/SDP"},
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(repos)
}
func (s *Server) handleListBranches(w http.ResponseWriter, r *http.Request) {
repo := r.URL.Query().Get("repo")
if repo == "" {
http.Error(w, "repo required", http.StatusBadRequest)
return
}
// ponytail: real impl forwards to the agent. For MVP, stub.
branches := []string{"main", "develop", "feature/login-error"}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(branches)
}
// ---- deployments ----
type deployReq struct {
Repository string `json:"repository"`
Branch string `json:"branch"`
Env map[string]string `json:"env,omitempty"`
Username string `json:"username"`
Password string `json:"password"`
}
func (s *Server) handleDeployments(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
// ponytail: list from SQLite. Real impl: SELECT with filter.
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`[]`))
case http.MethodPost:
var body deployReq
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
http.Error(w, "bad json", http.StatusBadRequest)
return
}
// resolve repo -> node
node := "micro"
if body.Repository == "api-gateway" {
node = "gateway"
}
// ensure agent connected
s.agents.mu.RLock()
connected := s.agents.conns[node]
s.agents.mu.RUnlock()
if !connected {
http.Error(w, "agent "+node+" not connected", http.StatusServiceUnavailable)
return
}
id := newID()
_ = s.st.StartDeployment(id, body.Repository, body.Branch, body.Username)
// send deploy request to agent over its WS
req := protocol.DeployRequest{
DeploymentID: id,
Repository: body.Repository,
Branch: body.Branch,
Env: body.Env,
Username: body.Username,
Password: body.Password,
}
payload, _ := json.Marshal(map[string]any{
"op": "deploy",
"data": req,
})
if !s.hub.SendToAgent(node, payload) {
http.Error(w, "agent buffer full", http.StatusServiceUnavailable)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]string{"id": id})
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}
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 struct {
ID string `json:"id"`
Node string `json:"node"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
http.Error(w, "bad json", http.StatusBadRequest)
return
}
payload, _ := json.Marshal(map[string]any{"op": "stop", "id": body.ID})
if !s.hub.SendToAgent(body.Node, payload) {
http.Error(w, "agent not reachable", http.StatusServiceUnavailable)
return
}
w.WriteHeader(http.StatusOK)
}
func newID() string {
b := make([]byte, 8)
_, _ = rand.Read(b)
return hex.EncodeToString(b)
}
+45
View File
@@ -0,0 +1,45 @@
package api
import (
"crypto/rand"
"encoding/hex"
"sync"
"time"
)
// Sessions is an in-memory token store. Replace with a signed JWT or
// a Redis-backed store when we need multi-replica CP. For MVP one process
// is enough.
type Sessions struct {
mu sync.RWMutex
store map[string]session
}
type session struct {
user string
expires time.Time
}
func NewSessions() *Sessions {
return &Sessions{store: make(map[string]session)}
}
func (s *Sessions) Issue(user string) string {
b := make([]byte, 16)
_, _ = rand.Read(b)
tok := hex.EncodeToString(b)
s.mu.Lock()
s.store[tok] = session{user: user, expires: time.Now().Add(12 * time.Hour)}
s.mu.Unlock()
return tok
}
func (s *Sessions) Valid(tok string) bool {
s.mu.RLock()
sess, ok := s.store[tok]
s.mu.RUnlock()
if !ok {
return false
}
return time.Now().Before(sess.expires)
}
+32
View File
@@ -0,0 +1,32 @@
package config
import (
"flag"
"os"
)
type Config struct {
Addr string // listen addr, e.g. ":8080"
DataDir string // SQLite + .log files live here
AgentHealth string // map of nodeID -> agent base URL (TODO: real map)
}
// Load reads flags and env. Env wins over defaults; flags win over env.
func Load() Config {
c := Config{
Addr: envOr("SDP_ADDR", ":8080"),
DataDir: envOr("SDP_DATA", "./data"),
AgentHealth: envOr("SDP_AGENT_HEALTH", ""),
}
flag.StringVar(&c.Addr, "addr", c.Addr, "control plane listen addr")
flag.StringVar(&c.DataDir, "data", c.DataDir, "data directory for sqlite and logs")
flag.Parse()
return c
}
func envOr(k, def string) string {
if v := os.Getenv(k); v != "" {
return v
}
return def
}
+161
View File
@@ -0,0 +1,161 @@
// Package store persists deployment progress in SQLite and log lines in
// append-only .log files. The hot path is AppendEvent — agents emit a lot
// of these and the dashboard wants them live.
package store
import (
"database/sql"
"fmt"
"os"
"path/filepath"
"sync"
"time"
_ "modernc.org/sqlite"
"github.com/sdp/protocol"
)
type Store struct {
db *sql.DB
dir string
logs map[string]*os.File // deploymentID -> file
mu sync.Mutex
}
func Open(dir string) (*Store, error) {
if err := os.MkdirAll(dir, 0o755); err != nil {
return nil, err
}
if err := os.MkdirAll(filepath.Join(dir, "logs"), 0o755); err != nil {
return nil, err
}
db, err := sql.Open("sqlite3", filepath.Join(dir, "sdp.db"))
if err != nil {
return nil, err
}
if _, err := db.Exec(`
CREATE TABLE IF NOT EXISTS deployments (
id TEXT PRIMARY KEY,
repository TEXT,
branch TEXT,
user TEXT,
state TEXT,
started_at INTEGER,
completed_at INTEGER
);
CREATE TABLE IF NOT EXISTS progress (
deployment_id TEXT,
stage TEXT,
ok INTEGER,
at INTEGER
);
`); err != nil {
return nil, err
}
return &Store{db: db, dir: dir, logs: make(map[string]*os.File)}, nil
}
func (s *Store) Close() error {
s.mu.Lock()
defer s.mu.Unlock()
for _, f := range s.logs {
_ = f.Close()
}
return s.db.Close()
}
// 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
}
// 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
}
// 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
}
+111
View File
@@ -0,0 +1,111 @@
package ws
import (
"encoding/json"
"log"
"net/http"
"strings"
"github.com/sdp/protocol"
"github.com/sdp/control-plane/internal/store"
)
// AgentWS handles /ws/agent?node=<id>. The agent pushes events; the control
// plane can send requests back over the same socket.
func (h *Hub) AgentWS(st *store.Store, onConnect, onDisconnect func(nodeID string)) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
nodeID := r.URL.Query().Get("node")
if nodeID == "" {
http.Error(w, "missing node query", http.StatusBadRequest)
return
}
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
return
}
defer conn.Close()
// outbound to agent: buffered; if the agent stalls we drop rather than block.
out := make(chan []byte, 32)
h.RegisterAgent(nodeID, out)
defer func() {
h.UnregisterAgent(nodeID)
close(out) // unblock the writer goroutine
}()
if onConnect != nil {
onConnect(nodeID)
}
defer func() {
if onDisconnect != nil {
onDisconnect(nodeID)
}
}()
// writer goroutine: drains out to the agent
go func() {
for msg := range out {
if err := conn.WriteMessage(websocket.TextMessage, msg); err != nil {
return
}
}
}()
// reader loop: agent -> control plane
for {
_, raw, err := conn.ReadMessage()
if err != nil {
return
}
var e protocol.Event
if err := json.Unmarshal(raw, &e); err != nil {
log.Printf("agent %s: bad event: %v", nodeID, err)
continue
}
_ = st.AppendEvent(e) // ponytail: best-effort persist
h.Publish(e)
}
}
}
// DeploymentWS handles /ws/deployments/{id}. Dashboard subscribes; we send
// a tail of the existing log first, then live events.
func (h *Hub) DeploymentWS(st *store.Store) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id := strings.TrimPrefix(r.URL.Path, "/ws/deployments/")
if id == "" {
http.Error(w, "missing deployment id", http.StatusBadRequest)
return
}
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
return
}
defer conn.Close()
// backfill: send last N log lines as synthetic events.
lines, _ := st.TailLogs(id, 200)
for _, ln := range lines {
msg := map[string]any{
"deploymentId": id,
"kind": "log",
"line": ln,
"at": int64(0),
"backfill": true,
}
b, _ := json.Marshal(msg)
if err := conn.WriteMessage(websocket.TextMessage, b); err != nil {
return
}
}
ch, unsub := h.Subscribe(id)
defer unsub()
for e := range ch {
b, _ := json.Marshal(e)
if err := conn.WriteMessage(websocket.TextMessage, b); err != nil {
return
}
}
}
}
+112
View File
@@ -0,0 +1,112 @@
// Package ws is the WebSocket fan-out for SDP. Two flows:
//
// agent --(events)--> /ws/agent (one conn per agent)
// dashboard client --(subscribe)-> /ws/deployments/{id} (one conn per viewer)
//
// On agent connect we record the agent's nodeID. On dashboard connect we
// register a subscriber for one deployment. Events are best-effort fanned out;
// a slow client is dropped, not allowed to backpressure the agent.
package ws
import (
"net/http"
"sync"
"github.com/gorilla/websocket"
"github.com/sdp/protocol"
)
var upgrader = websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool { return true }, // ponytail: internal tool, allow all
}
type Hub struct {
mu sync.RWMutex
// one channel per deployment; nil = no subscribers.
subs map[string]map[chan protocol.Event]struct{}
// one channel per connected agent; keyed by nodeID.
agents map[string]chan<- []byte // outbound to agent (deploy requests, etc.)
}
func New() *Hub {
return &Hub{
subs: make(map[string]map[chan protocol.Event]struct{}),
agents: make(map[string]chan<- []byte),
}
}
// Publish fans an event out to all subscribers of that deployment.
// Non-blocking; drops the event for a subscriber if its buffer is full.
func (h *Hub) Publish(e protocol.Event) {
h.mu.RLock()
subs := h.subs[e.DeploymentID]
chans := make([]chan protocol.Event, 0, len(subs))
for c := range subs {
chans = append(chans, c)
}
h.mu.RUnlock()
for _, c := range chans {
select {
case c <- e:
default:
// ponytail: drop slow subscriber; they'll reconnect.
}
}
}
// Subscribe registers a subscriber channel for one deployment.
// Returns an unsubscribe func the caller must invoke.
func (h *Hub) Subscribe(deploymentID string) (chan protocol.Event, func()) {
ch := make(chan protocol.Event, 64)
h.mu.Lock()
if h.subs[deploymentID] == nil {
h.subs[deploymentID] = make(map[chan protocol.Event]struct{})
}
h.subs[deploymentID][ch] = struct{}{}
h.mu.Unlock()
return ch, func() {
h.mu.Lock()
if subs, ok := h.subs[deploymentID]; ok {
delete(subs, ch)
if len(subs) == 0 {
delete(h.subs, deploymentID)
}
}
h.mu.Unlock()
close(ch)
}
}
// RegisterAgent stores the outbound channel for a node. Used when the control
// plane wants to send something back to an agent (deploy requests, stop, etc.).
func (h *Hub) RegisterAgent(nodeID string, out chan<- []byte) {
h.mu.Lock()
h.agents[nodeID] = out
h.mu.Unlock()
}
func (h *Hub) UnregisterAgent(nodeID string) {
h.mu.Lock()
delete(h.agents, nodeID)
h.mu.Unlock()
}
// SendToAgent best-effort sends a payload to a connected agent. Returns false
// if the agent isn't connected or its buffer is full.
func (h *Hub) SendToAgent(nodeID string, payload []byte) bool {
h.mu.RLock()
ch, ok := h.agents[nodeID]
h.mu.RUnlock()
if !ok {
return false
}
select {
case ch <- payload:
return true
default:
return false
}
}
+10
View File
@@ -0,0 +1,10 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
// ponytail: pure static export. nginx serves the dashboard with
// try_files and proxies /api/* and /ws/* to the Go control plane.
// The browser does plain fetch() to /api/*; no NextJS BFF layer.
output: 'export',
reactStrictMode: true,
images: { unoptimized: true },
}
module.exports = nextConfig
+35
View File
@@ -0,0 +1,35 @@
{
"name": "sdp-dashboard",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"@radix-ui/react-dialog": "^1.0.5",
"@radix-ui/react-label": "^2.0.2",
"@radix-ui/react-select": "^2.0.0",
"@radix-ui/react-slot": "^1.0.2",
"@radix-ui/react-tabs": "^1.0.4",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.0",
"lucide-react": "^0.344.0",
"next": "14.1.0",
"react": "^18",
"react-dom": "^18",
"tailwind-merge": "^2.2.1"
},
"devDependencies": {
"@types/node": "^20",
"@types/react": "^18",
"@types/react-dom": "^18",
"autoprefixer": "^10.4.17",
"postcss": "^8.4.33",
"tailwindcss": "^3.4.1",
"tailwindcss-animate": "^1.0.7",
"typescript": "^5"
}
}
+3
View File
@@ -0,0 +1,3 @@
module.exports = {
plugins: { tailwindcss: {}, autoprefixer: {} },
}
+9
View File
@@ -0,0 +1,9 @@
import { Dashboard } from '@/components/dashboard'
export default function DashboardPage() {
return (
<main className="min-h-screen bg-muted/30">
<Dashboard />
</main>
)
}
+54
View File
@@ -0,0 +1,54 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 47.4% 11.2%;
--card: 0 0% 100%;
--card-foreground: 222.2 47.4% 11.2%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 47.4% 11.2%;
--primary: 222.2 47.4% 11.2%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 222.2 84% 4.9%;
--radius: 0.5rem;
}
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--primary: 210 40% 98%;
--primary-foreground: 222.2 47.4% 11.2%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 212.7 26.8% 83.9%;
}
}
@layer base {
* { @apply border-border; }
body { @apply bg-background text-foreground; font-feature-settings: "rlig" 1, "calt" 1; }
}
+15
View File
@@ -0,0 +1,15 @@
import type { Metadata } from 'next'
import './globals.css'
export const metadata: Metadata = {
title: 'SDP',
description: 'Sandbox Deployment Platform',
}
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" className="h-full">
<body className="h-full bg-background text-foreground antialiased">{children}</body>
</html>
)
}
+9
View File
@@ -0,0 +1,9 @@
import { LoginForm } from '@/components/login-form'
export default function LoginPage() {
return (
<main className="flex min-h-screen items-center justify-center bg-muted/30 p-4">
<LoginForm />
</main>
)
}
+180
View File
@@ -0,0 +1,180 @@
'use client'
import { useEffect, useState } from 'react'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Label } from '@/components/ui/label'
import { Input } from '@/components/ui/input'
import { listRepos, listBranches, startDeploy, type Repo } from '@/lib/api'
import { useDeploymentWS } from '@/lib/use-deployment-ws'
const STAGES = ['git fetch', 'git checkout', 'git pull', 'go build', 'start container']
export function Dashboard() {
const [repos, setRepos] = useState<Repo[]>([])
const [repo, setRepo] = useState<string>('')
const [branches, setBranches] = useState<string[]>([])
const [branch, setBranch] = useState<string>('')
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [deploymentId, setDeploymentId] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
const [deploying, setDeploying] = useState(false)
const { events, state } = useDeploymentWS(deploymentId)
useEffect(() => {
listRepos().then((r) => {
setRepos(r)
if (r.length) setRepo(r[0].name)
}).catch((e) => setError(String(e)))
}, [])
useEffect(() => {
if (!repo) return
listBranches(repo).then((b) => {
setBranches(b)
if (b.length) setBranch(b[0])
}).catch(() => setBranches([]))
}, [repo])
async function deploy() {
setError(null)
setDeploying(true)
try {
const { id } = await startDeploy({ repository: repo, branch, username, password })
setDeploymentId(id)
} catch (e) {
setError(String(e))
} finally {
setDeploying(false)
}
}
const stageDone: Record<string, 'pending' | 'ok' | 'failed' | 'in_progress'> = {}
for (const s of STAGES) stageDone[s] = 'pending'
let lastDone: string | null = null
for (const e of events) {
if (e.kind === 'progress' && e.stage && STAGES.includes(e.stage)) {
if (e.state === 'FAILED') stageDone[e.stage] = 'failed'
else stageDone[e.stage] = 'ok'
lastDone = e.stage
}
}
if (lastDone) {
const idx = STAGES.indexOf(lastDone)
if (idx >= 0 && idx + 1 < STAGES.length && stageDone[STAGES[idx + 1]] === 'pending') {
stageDone[STAGES[idx + 1]] = 'in_progress'
}
}
return (
<div className="container py-8">
<header className="mb-6 flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Sandbox Deployments</h1>
<p className="text-sm text-muted-foreground">Isolated feature-branch deployments for Backend and QA.</p>
</div>
</header>
<div className="grid gap-6 lg:grid-cols-[360px_1fr]">
<Card>
<CardHeader>
<CardTitle>Deploy a branch</CardTitle>
<CardDescription>Credentials are forwarded to the agent for this deploy only.</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-1.5">
<Label>Repository</Label>
<Select value={repo} onValueChange={setRepo}>
<SelectTrigger><SelectValue placeholder="Pick a repo" /></SelectTrigger>
<SelectContent>
{repos.map((r) => (
<SelectItem key={r.name} value={r.name}>{r.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label>Branch</Label>
<Select value={branch} onValueChange={setBranch}>
<SelectTrigger><SelectValue placeholder="Pick a branch" /></SelectTrigger>
<SelectContent>
{branches.map((b) => (
<SelectItem key={b} value={b}>{b}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label htmlFor="u">Bitbucket username</Label>
<Input id="u" value={username} onChange={(e) => setUsername(e.target.value)} autoComplete="username" />
</div>
<div className="space-y-1.5">
<Label htmlFor="p">Bitbucket password</Label>
<Input id="p" type="password" value={password} onChange={(e) => setPassword(e.target.value)} autoComplete="current-password" />
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
<Button onClick={deploy} disabled={!repo || !branch || !username || !password || deploying} className="w-full">
{deploying ? 'Starting…' : 'Deploy'}
</Button>
</CardContent>
</Card>
<div className="space-y-6">
{deploymentId && (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="font-mono text-base">{deploymentId}</CardTitle>
<CardDescription>{repo} · {branch}</CardDescription>
</div>
<Badge variant={state === 'RUNNING' ? 'success' : state === 'FAILED' ? 'destructive' : 'secondary'}>
{state}
</Badge>
</div>
</CardHeader>
<CardContent className="space-y-4">
<ol className="space-y-1.5 text-sm">
{STAGES.map((s) => {
const v = stageDone[s]
return (
<li key={s} className="flex items-center gap-3">
<span className={
v === 'ok' ? 'text-emerald-600' :
v === 'failed' ? 'text-red-600' :
v === 'in_progress' ? 'text-amber-600' :
'text-muted-foreground'
}>
{v === 'ok' ? '✓' : v === 'failed' ? '✗' : v === 'in_progress' ? '…' : '·'}
</span>
<span className={v === 'in_progress' ? 'font-medium' : ''}>{s}</span>
</li>
)
})}
</ol>
<LogView events={events} />
</CardContent>
</Card>
)}
</div>
</div>
</div>
)
}
function LogView({ events }: { events: { kind: string; line?: string; stage?: string; backfill?: boolean }[] }) {
// ponytail: render the last 500 lines. Auto-scroll on new log.
return (
<pre className="h-72 overflow-auto rounded-md border bg-zinc-950 p-3 text-xs text-zinc-100 font-mono">
{events
.filter((e) => e.kind === 'log' && e.line)
.slice(-500)
.map((e, i) => (
<div key={i} className="whitespace-pre-wrap">{e.line}</div>
))}
</pre>
)
}
+63
View File
@@ -0,0 +1,63 @@
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
export function LoginForm() {
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(false)
const router = useRouter()
async function submit(e: React.FormEvent) {
e.preventDefault()
setLoading(true)
setError(null)
try {
const r = await fetch('/api/login', {
method: 'POST',
headers: { 'content-type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ username, password }),
})
if (!r.ok) {
setError('Login failed — check your Bitbucket credentials.')
return
}
router.push('/dashboard')
} catch (err) {
setError('Network error')
} finally {
setLoading(false)
}
}
return (
<Card className="w-full max-w-sm">
<CardHeader>
<CardTitle>Sign in</CardTitle>
<CardDescription>Use your Bitbucket account.</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={submit} className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="u">Username</Label>
<Input id="u" value={username} onChange={(e) => setUsername(e.target.value)} required autoComplete="username" />
</div>
<div className="space-y-1.5">
<Label htmlFor="p">Password</Label>
<Input id="p" type="password" value={password} onChange={(e) => setPassword(e.target.value)} required autoComplete="current-password" />
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
<Button type="submit" disabled={loading} className="w-full">
{loading ? 'Signing in…' : 'Sign in'}
</Button>
</form>
</CardContent>
</Card>
)
}
+28
View File
@@ -0,0 +1,28 @@
import * as React from 'react'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
const badgeVariants = cva(
'inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors',
{
variants: {
variant: {
default: 'border-transparent bg-primary text-primary-foreground shadow',
secondary: 'border-transparent bg-secondary text-secondary-foreground',
destructive: 'border-transparent bg-destructive text-destructive-foreground shadow',
outline: 'text-foreground',
success: 'border-transparent bg-emerald-500 text-white shadow',
warning: 'border-transparent bg-amber-500 text-white shadow',
},
},
defaultVariants: { variant: 'default' },
}
)
export interface BadgeProps extends React.HTMLAttributes<HTMLDivElement>, VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return <div className={cn(badgeVariants({ variant }), className)} {...props} />
}
export { Badge, badgeVariants }
+43
View File
@@ -0,0 +1,43 @@
import * as React from 'react'
import { Slot } from '@radix-ui/react-slot'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
const buttonVariants = cva(
'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50',
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground shadow hover:bg-primary/90',
destructive: 'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90',
outline: 'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground',
secondary: 'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default: 'h-9 px-4 py-2',
sm: 'h-8 rounded-md px-3 text-xs',
lg: 'h-10 rounded-md px-8',
icon: 'h-9 w-9',
},
},
defaultVariants: { variant: 'default', size: 'default' },
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : 'button'
return <Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />
}
)
Button.displayName = 'Button'
export { Button, buttonVariants }
+40
View File
@@ -0,0 +1,40 @@
import * as React from 'react'
import { cn } from '@/lib/utils'
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('rounded-xl border bg-card text-card-foreground shadow', className)} {...props} />
)
)
Card.displayName = 'Card'
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => <div ref={ref} className={cn('flex flex-col space-y-1.5 p-6', className)} {...props} />
)
CardHeader.displayName = 'CardHeader'
const CardTitle = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('font-semibold leading-none tracking-tight', className)} {...props} />
)
)
CardTitle.displayName = 'CardTitle'
const CardDescription = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('text-sm text-muted-foreground', className)} {...props} />
)
)
CardDescription.displayName = 'CardDescription'
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => <div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
)
CardContent.displayName = 'CardContent'
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => <div ref={ref} className={cn('flex items-center p-6 pt-0', className)} {...props} />
)
CardFooter.displayName = 'CardFooter'
export { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter }
+21
View File
@@ -0,0 +1,21 @@
import * as React from 'react'
import { cn } from '@/lib/utils'
export type InputProps = React.InputHTMLAttributes<HTMLInputElement>
const Input = React.forwardRef<HTMLInputElement, InputProps>(({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
'flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50',
className
)}
ref={ref}
{...props}
/>
)
})
Input.displayName = 'Input'
export { Input }
+17
View File
@@ -0,0 +1,17 @@
import * as React from 'react'
import * as LabelPrimitive from '@radix-ui/react-label'
import { cn } from '@/lib/utils'
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn('text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70', className)}
{...props}
/>
))
Label.displayName = LabelPrimitive.Root.displayName
export { Label }
+78
View File
@@ -0,0 +1,78 @@
'use client'
import * as React from 'react'
import * as SelectPrimitive from '@radix-ui/react-select'
import { Check, ChevronDown } from 'lucide-react'
import { cn } from '@/lib/utils'
const Select = SelectPrimitive.Root
const SelectValue = SelectPrimitive.Value
const SelectGroup = SelectPrimitive.Group
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
'flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1',
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
))
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = 'popper', ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
'relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
position === 'popper' && 'translate-y-1',
className
)}
position={position}
{...props}
>
<SelectPrimitive.Viewport
className={cn('p-1', position === 'popper' && 'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]')}
>
{children}
</SelectPrimitive.Viewport>
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
))
SelectContent.displayName = SelectPrimitive.Content.displayName
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
'relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
))
SelectItem.displayName = SelectPrimitive.Item.displayName
export { Select, SelectGroup, SelectValue, SelectTrigger, SelectContent, SelectItem }
+35
View File
@@ -0,0 +1,35 @@
// lib/api.ts — shared types and fetch helpers
export type Repo = { name: string; node: string; path: string }
export async function listRepos(): Promise<Repo[]> {
const r = await fetch('/api/repos', { credentials: 'include' })
if (!r.ok) throw new Error('failed to list repos')
return r.json()
}
export async function listBranches(repo: string): Promise<string[]> {
const r = await fetch(`/api/repos/branches?repo=${encodeURIComponent(repo)}`, { credentials: 'include' })
if (!r.ok) throw new Error('failed to list branches')
return r.json()
}
export type DeployResponse = { id: string }
export async function startDeploy(payload: {
repository: string
branch: string
username: string
password: string
}): Promise<DeployResponse> {
const r = await fetch('/api/deployments', {
method: 'POST',
headers: { 'content-type': 'application/json' },
credentials: 'include',
body: JSON.stringify(payload),
})
if (!r.ok) {
const text = await r.text()
throw new Error(text || 'deploy failed')
}
return r.json()
}
+47
View File
@@ -0,0 +1,47 @@
'use client'
// Single WebSocket hook. One connection per active deployment; auto-reconnect
// on close. Pushed events accumulate into the caller-provided state.
import { useEffect, useRef, useState } from 'react'
export type WsEvent = {
deploymentId: string
kind: 'progress' | 'log' | 'status'
state?: string
stage?: string
line?: string
at: number
backfill?: boolean
}
export function useDeploymentWS(deploymentId: string | null) {
const [events, setEvents] = useState<WsEvent[]>([])
const [state, setState] = useState<string>('QUEUED')
const wsRef = useRef<WebSocket | null>(null)
useEffect(() => {
if (!deploymentId) return
setEvents([])
setState('QUEUED')
const proto = window.location.protocol === 'https:' ? 'wss' : 'ws'
const url = `${proto}://${window.location.host}/ws/deployments/${deploymentId}`
const ws = new WebSocket(url)
wsRef.current = ws
ws.onmessage = (ev) => {
try {
const e: WsEvent = JSON.parse(ev.data)
if (e.kind === 'status' && e.state) setState(e.state)
if (e.kind === 'log' || e.kind === 'progress' || e.kind === 'status') {
setEvents((prev) => [...prev, e].slice(-2000)) // ponytail: cap memory
}
} catch {
// ponytail: drop malformed frames; would-be 3am pain otherwise
}
}
// ponytail: no onclose handler — just let it die. Refresh = resubscribe.
return () => ws.close()
}, [deploymentId])
return { events, state }
}
+6
View File
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
+30
View File
@@ -0,0 +1,30 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
darkMode: ['class'],
content: ['./src/**/*.{ts,tsx}'],
theme: {
container: {
center: true,
padding: '1rem',
screens: { '2xl': '1280px' },
},
extend: {
colors: {
border: 'hsl(var(--border))',
input: 'hsl(var(--input))',
ring: 'hsl(var(--ring))',
background: 'hsl(var(--background))',
foreground: 'hsl(var(--foreground))',
primary: { DEFAULT: 'hsl(var(--primary))', foreground: 'hsl(var(--primary-foreground))' },
secondary: { DEFAULT: 'hsl(var(--secondary))', foreground: 'hsl(var(--secondary-foreground))' },
muted: { DEFAULT: 'hsl(var(--muted))', foreground: 'hsl(var(--muted-foreground))' },
accent: { DEFAULT: 'hsl(var(--accent))', foreground: 'hsl(var(--accent-foreground))' },
destructive: { DEFAULT: 'hsl(var(--destructive))', foreground: 'hsl(var(--destructive-foreground))' },
card: { DEFAULT: 'hsl(var(--card))', foreground: 'hsl(var(--card-foreground))' },
popover: { DEFAULT: 'hsl(var(--popover))', foreground: 'hsl(var(--popover-foreground))' },
},
borderRadius: { lg: 'var(--radius)', md: 'calc(var(--radius) - 2px)', sm: 'calc(var(--radius) - 4px)' },
},
},
plugins: [require('tailwindcss-animate')],
}
+22
View File
@@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ES2020",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [{ "name": "next" }],
"baseUrl": ".",
"paths": { "@/*": ["./src/*"] }
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}
+62
View File
@@ -0,0 +1,62 @@
# docker-compose.yml — runs control-plane, agent-micro, agent-gateway
# inside alpine:latest containers. The agents need the host docker socket
# bind-mounted so they can manage service containers.
#
# Use on a single host (e.g. 186) for dev. In production, the agents
# live on their own VMs and the control plane lives on 186.
services:
control-plane:
image: alpine:latest
container_name: sdp-control-plane
restart: unless-stopped
command: ["/SDP/bin/control-plane", "-addr", ":8080", "-data", "/SDP/data"]
volumes:
- ./bin/control-plane:/SDP/bin/control-plane:ro
- sdp-data:/SDP/data
ports:
- "8080:8080"
agent-micro:
image: alpine:latest
container_name: sdp-agent-micro
restart: unless-stopped
# agent connects to the control plane by container name on the compose
# network. Override SDP_CP_URL when running outside compose.
command:
- /SDP/bin/agent-micro
- -node
- micro
- -cp
- ws://control-plane:8080/ws/agent
volumes:
- ./bin/agent-micro:/SDP/bin/agent-micro:ro
- /var/run/docker.sock:/var/run/docker.sock
# Repos live on the host. Adjust to the actual paths.
- ~/AppGolang:/AppGolang:ro
environment:
- DOCKER_HOST=unix:///var/run/docker.sock
depends_on:
- control-plane
agent-gateway:
image: alpine:latest
container_name: sdp-agent-gateway
restart: unless-stopped
command:
- /SDP/bin/agent-gateway
- -node
- gateway
- -cp
- ws://control-plane:8080/ws/agent
volumes:
- ./bin/agent-gateway:/SDP/bin/agent-gateway:ro
- /var/run/docker.sock:/var/run/docker.sock
- ~/SDP/repos:/SDP-repos:ro
environment:
- DOCKER_HOST=unix:///var/run/docker.sock
depends_on:
- control-plane
volumes:
sdp-data:
+8
View File
@@ -0,0 +1,8 @@
go 1.23
use (
./protocol
./control-plane
./agent-micro
./agent-gateway
)
+51
View File
@@ -0,0 +1,51 @@
# SDP nginx — serves the static NextJS export and proxies API + WS
# to the Go control plane.
#
# try_files: any unknown path falls back to /index.html so client-side
# routing works. /api and /ws are matched first and proxied upstream.
upstream control_plane {
server 127.0.0.1:8080;
keepalive 16;
}
server {
listen 80;
server_name _;
# Long-lived WS connections need a generous read timeout.
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# --- API: control plane ---
location /api/ {
proxy_pass http://control_plane;
}
# --- WebSocket: agent + dashboard subscriptions ---
location /ws/ {
proxy_pass http://control_plane;
}
# --- Static dashboard ---
root /var/www/sdp/dashboard/out;
index index.html;
# ponytail: try_files does all the work. _next chunks, images, etc. are
# served as files; unknown paths fall back to /index.html for SPA routing.
location / {
try_files $uri $uri/ $uri.html /index.html;
}
# Cache static assets aggressively; never cache index.html.
location /_next/static/ {
expires 1y;
add_header Cache-Control "public, immutable";
}
}
+3
View File
@@ -0,0 +1,3 @@
module github.com/sdp/protocol
go 1.23
+35
View File
@@ -0,0 +1,35 @@
// Package protocol defines the wire format shared between the control plane
// and agents. Keep this small — anything that goes over HTTP or WebSocket
// between us and an agent lives here.
package protocol
// Event is what an agent streams back to the control plane over its outbound
// WebSocket. The control plane fans these out to dashboard clients and
// persists them (log lines to .log, progress snapshots to SQLite).
type Event struct {
DeploymentID string `json:"deploymentId"`
Kind string `json:"kind"` // progress | log | status
State string `json:"state,omitempty"` // QUEUED, FETCHING, ... RUNNING, FAILED, STOPPED
Stage string `json:"stage,omitempty"` // human label, e.g. "git fetch"
Line string `json:"line,omitempty"` // log line (for kind=log)
ContainerID string `json:"containerId,omitempty"`
At int64 `json:"at"` // unix millis
}
// DeployRequest is what the control plane POSTs to an agent to start work.
// Credentials are passed per-operation; agents MUST NOT log or persist them.
type DeployRequest struct {
DeploymentID string `json:"deploymentId"`
Repository string `json:"repository"` // name from agent's repo config
Branch string `json:"branch"`
Env map[string]string `json:"env,omitempty"` // injected into container
Username string `json:"username"`
Password string `json:"password"`
}
// DeployResponse is the agent's immediate ack to a DeployRequest.
// Actual progress streams over the WS.
type DeployResponse struct {
OK bool `json:"ok"`
Error string `json:"error,omitempty"`
}
+68
View File
@@ -0,0 +1,68 @@
#!/usr/bin/env bash
# Build all three Go binaries for Linux/amd64 and the dashboard.
# Output goes to ./bin/ and ./dashboard/out/.
#
# Uses a golang:1.23-alpine container so we get a reproducible toolchain
# without needing Go installed locally. Cross-compile via GOOS/GOARCH +
# CGO_ENABLED=0 — produces a static binary that runs in the alpine
# containers defined in docker-compose.yml.
set -euo pipefail
cd "$(dirname "$0")/.."
REPO_ROOT="$(pwd)"
OUT="$REPO_ROOT/bin"
mkdir -p "$OUT"
GO_IMAGE="${GO_IMAGE:-golang:1.23-alpine}"
# ponytail: bind-mount a persistent gocache so module downloads + build cache
# survive across runs. Otherwise every build re-downloads the world from
# the GOPROXY — slow on a flaky office link, and uses up the proxy quota.
GOCACHE_VOL="sdp-gocache"
docker volume create "$GOCACHE_VOL" >/dev/null 2>&1 || true
echo "==> building control-plane, agent-micro, agent-gateway (linux/amd64)"
docker run --rm \
-v "$REPO_ROOT":/src \
-v "$OUT":/out \
-v "$GOCACHE_VOL":/gocache \
-w /src \
-e CGO_ENABLED=0 \
-e GOOS=linux \
-e GOARCH=amd64 \
-e GOCACHE=/gocache \
-e GOFLAGS="-mod=mod" \
"$GO_IMAGE" \
sh -c '
set -e
# -trimpath: strip absolute paths from the binary (reproducible builds).
# -ldflags="-s -w": drop symbol table + DWARF, smaller binary.
go build -trimpath -ldflags="-s -w" -o /out/control-plane ./control-plane/cmd/control-plane
go build -trimpath -ldflags="-s -w" -o /out/agent-micro ./agent-micro/cmd/agent-micro
go build -trimpath -ldflags="-s -w" -o /out/agent-gateway ./agent-gateway/cmd/agent-gateway
'
echo
echo "==> binaries:"
ls -lh "$OUT"
chmod +x "$OUT"/*
# Verify the binaries are actually linux/amd64. ponytail: catches a mistake
# where someone removes the GOOS/GOARCH env and ships a darwin binary to
# the alpine container.
echo
echo "==> sanity check (file type):"
file "$OUT"/*
# ---- dashboard ----
if [[ -d "$REPO_ROOT/dashboard" ]]; then
echo
echo "==> building dashboard"
if [[ ! -d "$REPO_ROOT/dashboard/node_modules" ]]; then
(cd "$REPO_ROOT/dashboard" && npm install)
fi
(cd "$REPO_ROOT/dashboard" && npm run build)
echo "dashboard built at $REPO_ROOT/dashboard/out"
fi
+56
View File
@@ -0,0 +1,56 @@
#!/usr/bin/env bash
# Push the built binaries and dashboard to both SDP VMs.
#
# 92 (micro): ~/SDP/agent-micro
# 186 (gateway): ~/SDP/control-plane, ~/SDP/agent-gateway, ~/SDP/dashboard
#
# On 186 we also splice the SDP location into nginx's existing default site
# and reload. Run scripts/build.sh first.
set -euo pipefail
cd "$(dirname "$0")/.."
REPO_ROOT="$(pwd)"
# ponytail: paths can be overridden by env so the same script works from CI.
HOST_92="${SDP_92_HOST:-administrator@172.18.136.92}"
PASS_92="${SDP_92_PASS:-password}"
HOST_186="${SDP_186_HOST:-administrator@172.18.139.186}"
PASS_186="${SDP_186_PASS:-Bre@kthrough2312}"
if ! command -v sshpass >/dev/null 2>&1; then
echo "sshpass not found. Install with: brew install sshpass" >&2
exit 1
fi
SSH_92="sshpass -p $PASS_92 ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR"
SCP_92="sshpass -p $PASS_92 scp -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR"
SSH_186="sshpass -p $PASS_186 ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR"
SCP_186="sshpass -p $PASS_186 scp -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR"
# ponytail: Wipe-and-replace. The deploys are stateful on the VM only via
# SQLite + .log files in ~/SDP/data — we keep that. Binaries and the
# dashboard are replaced cleanly.
REMOTE_RESET='rm -rf ~/SDP/bin ~/SDP/dashboard && mkdir -p ~/SDP/bin ~/SDP/dashboard'
echo "==> 92: $HOST_92"
$SSH_92 "$HOST_92" "$REMOTE_RESET"
$SCP_92 "$REPO_ROOT/bin/agent-micro" "$HOST_92:~/SDP/bin/agent-micro"
$SSH_92 "$HOST_92" "chmod +x ~/SDP/bin/agent-micro"
echo " agent-micro copied"
echo
echo "==> 186: $HOST_186"
$SSH_186 "$HOST_186" "$REMOTE_RESET"
$SCP_186 "$REPO_ROOT/bin/control-plane" "$HOST_186:~/SDP/bin/control-plane"
$SCP_186 "$REPO_ROOT/bin/agent-gateway" "$HOST_186:~/SDP/bin/agent-gateway"
$SCP_186 -r "$REPO_ROOT/dashboard/out/." "$HOST_186:~/SDP/dashboard/"
$SSH_186 "$HOST_186" "chmod +x ~/SDP/bin/control-plane ~/SDP/bin/agent-gateway"
echo " control-plane, agent-gateway, dashboard copied"
# Patch nginx on 186
echo
echo "==> 186: patching nginx"
"$REPO_ROOT/scripts/patch-nginx.sh"
echo
echo "done."
+104
View File
@@ -0,0 +1,104 @@
#!/usr/bin/env bash
# Splice the SDP dashboard location into the existing nginx default site on
# 172.18.139.186. Idempotent: re-running won't duplicate the block.
#
# We don't replace the file — we insert before the closing `}` of the
# existing `server { ... }` block. The block is guarded by a sentinel
# comment so subsequent runs are no-ops.
set -euo pipefail
cd "$(dirname "$0")/.."
HOST_186="${SDP_186_HOST:-administrator@172.18.139.186}"
PASS_186="${SDP_186_PASS:-Bre@kthrough2312}"
if ! command -v sshpass >/dev/null 2>&1; then
echo "sshpass not found. Install with: brew install sshpass" >&2
exit 1
fi
SSH="sshpass -p $PASS_186 ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR"
NGINX_SITE=/etc/nginx/sites-available/default
SDP_MARKER='# >>> sdp >>>'
$SSH "$HOST_186" bash -s <<REMOTE
set -e
NGINX_SITE=$NGINX_SITE
SDP_MARKER='$SDP_MARKER'
if grep -qF "\$SDP_MARKER" "\$NGINX_SITE"; then
echo "sdp block already present, skipping"
exit 0
fi
cp "\$NGINX_SITE" "\$NGINX_SITE.bak.\$(date +%s)"
python3 - <<'PY'
import re
path = "/etc/nginx/sites-available/default"
src = open(path).read()
block = """
\t# >>> sdp >>>
\t# Sandbox Deployment Platform dashboard
\tlocation /api/ {
\t\tproxy_pass http://127.0.0.1:8080;
\t\tproxy_set_header Host $host;
\t\tproxy_set_header X-Real-IP $remote_addr;
\t\tproxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
\t}
\tlocation /ws/ {
\t\tproxy_pass http://127.0.0.1:8080;
\t\tproxy_http_version 1.1;
\t\tproxy_set_header Upgrade $http_upgrade;
\t\tproxy_set_header Connection "upgrade";
\t\tproxy_read_timeout 3600s;
\t\tproxy_send_timeout 3600s;
\t}
\tlocation / {
\t\troot /home/administrator/SDP/dashboard;
\t\tindex index.html;
\t\ttry_files \$uri \$uri/ \$uri.html /index.html;
\t}
\t# <<< sdp <<<
"""
def find_server_end(s):
i = s.find("server")
if i < 0: return -1
j = s.find("{", i)
if j < 0: return -1
depth = 1
k = j + 1
in_str = None
while k < len(s):
c = s[k]
if in_str:
if c == in_str and s[k-1] != "\\":
in_str = None
else:
if c in ('"', "'"):
in_str = c
elif c == "{":
depth += 1
elif c == "}":
depth -= 1
if depth == 0:
return k
k += 1
return -1
end = find_server_end(src)
if end < 0:
raise SystemExit("could not find server block end")
new = src[:end] + block + src[end:]
open(path, "w").write(new)
PY
nginx -t
systemctl reload nginx
echo "nginx patched and reloaded"
REMOTE
+19
View File
@@ -0,0 +1,19 @@
#!/usr/bin/env bash
# SSH into the gateway VM (172.18.139.186). Wraps ssh with the known password.
# Usage: scripts/ssh-186.sh [extra ssh args...]
set -euo pipefail
cd "$(dirname "$0")/.."
HOST="${SDP_186_HOST:-administrator@172.18.139.186}"
PASS="${SDP_186_PASS:-Bre@kthrough2312}"
if ! command -v sshpass >/dev/null 2>&1; then
echo "sshpass not found. Install with: brew install sshpass" >&2
exit 1
fi
exec sshpass -p "$PASS" ssh \
-o StrictHostKeyChecking=no \
-o UserKnownHostsFile=/dev/null \
-o LogLevel=ERROR \
"$HOST" "$@"
+19
View File
@@ -0,0 +1,19 @@
#!/usr/bin/env bash
# SSH into the micro VM (172.18.136.92). Wraps ssh with the known password.
# Usage: scripts/ssh-92.sh [extra ssh args...]
set -euo pipefail
cd "$(dirname "$0")/.."
HOST="${SDP_92_HOST:-administrator@172.18.136.92}"
PASS="${SDP_92_PASS:-password}"
if ! command -v sshpass >/dev/null 2>&1; then
echo "sshpass not found. Install with: brew install sshpass" >&2
exit 1
fi
exec sshpass -p "$PASS" ssh \
-o StrictHostKeyChecking=no \
-o UserKnownHostsFile=/dev/null \
-o LogLevel=ERROR \
"$HOST" "$@"