Slice 1: build green, MVP core flow

- New agentlib module (gitutil + deployer with NewGo / NewPHP) replaces
  agent-micro/internal so both agents can share it (Go's internal/ rule
  was blocking agent-gateway from importing agent-micro's packages).
- Migrate agents from legacy github.com/docker/docker/client to the
  current github.com/moby/moby/client v0.5.0 / moby/moby/api v1.55.0.
- Fix compile errors in the original committed code: missing
  gorilla/websocket import in control-plane/internal/ws/handlers.go,
  unaliased dockerclient reference, wrong SQLite driver name
  (sqlite3 -> sqlite), Dialer.Dial 3-return-value mismatch.
- scripts/build.sh: Go 1.23 -> 1.24, apk add git, safe.directory for
  bind-mounted host tree, chmod inside container (host can't chmod
  files owned by container root).
- README and REQUIREMENTS updated to reflect the actual architecture
  (Go + SQLite, no Spring Boot, moby SDK, per-deploy no image build)
  with a per-feature status checklist at the end of REQUIREMENTS.
This commit is contained in:
opencode
2026-06-24 01:43:43 +00:00
parent 7c1013e083
commit 2bc3ff73a2
18 changed files with 3218 additions and 321 deletions
+322
View File
@@ -0,0 +1,322 @@
// 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.
//
// Two build flavours share the same lifecycle:
//
// GoDeployer (microservices) — host `go build`, run alpine:3.20 with the
// repo bind-mounted at /src and the binary as the container command.
//
// PhpDeployer (api-gateway) — host `git pull` only; the gateway's PHP
// image (php:8.3-apache) is pre-loaded on the VM and the repo is
// bind-mounted at /app. Best-effort `composer install --no-dev` runs
// on the host if a composer.json is present.
//
// In both cases there is NO Dockerfile-based image build. The runtime image
// must be loaded on the host ahead of time (manual `docker load`).
package deployer
import (
"context"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
containertypes "github.com/moby/moby/api/types/container"
networktypes "github.com/moby/moby/api/types/network"
dockerclient "github.com/moby/moby/client"
"github.com/sdp/agentlib/gitutil"
"github.com/sdp/protocol"
)
// Kind selects between build flavours. The control plane doesn't need to
// know — it just sends a DeployRequest with a Repository name. The agent
// picks the right Deployer based on which binary it's running as.
type Kind string
const (
KindGo Kind = "go" // microservice: host go build
KindPHP Kind = "php" // api-gateway: host composer install (best-effort)
)
// Spec captures the kind-specific bits of a Deployer that differ between
// Go and PHP. The constructor functions below fill these in.
type Spec struct {
ImageTag string
BindMount string // host dir bind-mounted into the container
Workdir string // container working dir
Cmd []string
ExposedPorts networktypes.PortSet
}
type Deployer struct {
kind Kind
ID string
Repository string
RepoPath string
Branch string
Env map[string]string
Creds gitutil.Creds
cli *dockerclient.Client
containerID string
spec Spec
}
func newDeployer(kind Kind, cli *dockerclient.Client, id, repo, repoPath, branch string, env map[string]string, c gitutil.Creds, spec Spec) *Deployer {
return &Deployer{
kind: kind,
ID: id,
Repository: repo,
RepoPath: repoPath,
Branch: branch,
Env: env,
Creds: c,
cli: cli,
spec: spec,
}
}
// portKey builds a network.Port from a "port/proto" string like "8080/tcp".
func portKey(s string) (networktypes.Port, error) {
return networktypes.ParsePort(s)
}
// portSet is a small helper to make a PortSet from "port/proto" literals.
func portSet(ports ...string) networktypes.PortSet {
out := networktypes.PortSet{}
for _, s := range ports {
p, err := portKey(s)
if err == nil {
out[p] = struct{}{}
}
}
return out
}
// NewGo is the microservice deployer: host `go build`, run alpine:3.20.
func NewGo(cli *dockerclient.Client, id, repo, repoPath, branch string, env map[string]string, c gitutil.Creds) *Deployer {
binName := "app-" + repo
return newDeployer(KindGo, cli, id, repo, repoPath, branch, env, c, Spec{
ImageTag: "alpine:3.20",
BindMount: "/src",
Workdir: "/src",
Cmd: []string{"/src/" + binName},
ExposedPorts: portSet("8080/tcp"),
})
}
// NewPHP is the api-gateway deployer: best-effort composer install on
// the host, then run php:8.3-apache with the repo bind-mounted at /app.
//
// The PHP image must be pre-loaded on the VM via `docker load`. The repo
// is expected to be a standard PHP project layout (public/ entrypoint
// is conventional but not required).
func NewPHP(cli *dockerclient.Client, id, repo, repoPath, branch string, env map[string]string, c gitutil.Creds) *Deployer {
return newDeployer(KindPHP, cli, id, repo, repoPath, branch, env, c, Spec{
ImageTag: "php:8.3-apache",
BindMount: "/app",
Workdir: "/app",
// Apache in the official php image reads /app/public as the
// document root by default. If the gateway has a different
// layout the env var APACHE_DOCUMENT_ROOT can override.
Cmd: []string{"apache2-foreground"},
ExposedPorts: portSet("80/tcp"),
})
}
// Run executes the kind-specific pipeline and returns the final state
// ("RUNNING" on success, "FAILED" otherwise). All events are pushed to
// out; the caller is expected to drain.
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 }},
}
if d.kind == KindGo {
stages = append(stages, struct {
name string
fn func() error
}{"go build", func() error { return d.buildGo(ctx) }})
} else {
stages = append(stages, struct {
name string
fn func() error
}{"composer install", func() error { return d.composerInstall(ctx) }})
}
stages = append(stages, struct {
name string
fn func() error
}{"start container", func() error { return d.startContainer(ctx) }})
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) buildGo(ctx context.Context) error {
binName := "app-" + d.Repository
binPath := filepath.Join(d.RepoPath, 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
}
// composerInstall runs `composer install --no-dev` on the host if a
// composer.json is present. Best-effort: if composer isn't installed
// or fails, we log a warning and continue — the gateway's PHP image
// typically has its own runtime autoloader or preinstalled deps.
//
// Per REQUIREMENTS.md: the API Gateway must run inside Docker; we
// don't try to launch a PHP dev server or anything fancy here.
func (d *Deployer) composerInstall(_ context.Context) error {
manifest := filepath.Join(d.RepoPath, "composer.json")
if _, err := exec.LookPath("composer"); err != nil {
return nil // no composer on the VM; skip silently
}
if _, err := os.Stat(manifest); err != nil {
return nil // no composer.json; nothing to install
}
cmd := exec.Command("composer", "install", "--no-dev", "--no-interaction", "--no-progress")
cmd.Dir = d.RepoPath
// Best-effort: even if composer fails, don't fail the deploy.
_, _ = cmd.CombinedOutput()
return nil
}
// startContainer runs the configured base image with the repo
// bind-mounted at d.spec.BindMount. The container's working dir is
// set to d.spec.Workdir so the entrypoint finds its config / static
// 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 := &containertypes.Config{
Image: d.spec.ImageTag,
Cmd: d.spec.Cmd,
Env: envList,
WorkingDir: d.spec.Workdir,
ExposedPorts: d.spec.ExposedPorts,
}
hostCfg := &containertypes.HostConfig{
Binds: []string{d.RepoPath + ":" + d.spec.BindMount},
RestartPolicy: containertypes.RestartPolicy{Name: containertypes.RestartPolicyUnlessStopped},
}
resp, err := d.cli.ContainerCreate(ctx, dockerclient.ContainerCreateOptions{
Config: cfg,
HostConfig: hostCfg,
Name: d.containerName(),
})
if err != nil {
return fmt.Errorf("container create: %w", err)
}
d.containerID = resp.ID
if _, err := d.cli.ContainerStart(ctx, resp.ID, dockerclient.ContainerStartOptions{}); 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
}
_, err := d.cli.ContainerStop(ctx, d.containerID, dockerclient.ContainerStopOptions{})
return err
}
// 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, dockerclient.ContainerLogsOptions{
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 {
if err != io.EOF {
// log nothing; the agent's writer will drop on disconnect
}
return
}
}
}