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