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