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