Files
bri-sandbox-development-pla…/agent-micro/internal/deployer/deployer.go
T
Achmad Setyabudi Susilo 3d99940658 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.
2026-06-24 07:25:01 +07:00

205 lines
5.6 KiB
Go

// 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
}
}
}