3d99940658
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.
162 lines
3.9 KiB
Go
162 lines
3.9 KiB
Go
// 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
|
|
}
|