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
+13 -11
View File
@@ -1,6 +1,6 @@
// 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.
// maintains a WebSocket to the control plane, accepts deploy/stop frames,
// and runs the build+container pipeline locally for Go microservices.
package main
import (
@@ -13,17 +13,17 @@ import (
"sync"
"time"
"github.com/docker/docker/client"
docker "github.com/moby/moby/client"
"github.com/gorilla/websocket"
"github.com/sdp/agent-micro/internal/deployer"
"github.com/sdp/agent-micro/internal/gitutil"
"github.com/sdp/agentlib/deployer"
"github.com/sdp/agentlib/gitutil"
"github.com/sdp/protocol"
)
// ponytail: hand-curated from REQUIREMENTS.md. Real version reads a yaml
// config file. Adding a new service = one line.
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",
@@ -35,7 +35,7 @@ func main() {
nodeID := flag.String("node", envOr("SDP_NODE_ID", "micro"), "node id sent in WS query")
flag.Parse()
cli, err := client.NewClientWithOpts(client.FromEnv)
cli, err := docker.NewClientWithOpts(docker.FromEnv)
if err != nil {
log.Fatalf("docker client: %v", err)
}
@@ -69,7 +69,8 @@ func main() {
func dial(u *url.URL) (*websocket.Conn, error) {
log.Printf("connecting to %s", u)
return websocket.DefaultDialer.Dial(u.String(), nil)
c, _, err := websocket.DefaultDialer.Dial(u.String(), nil)
return c, err
}
// writer pumps outbound events to whichever conn is current. If conn is
@@ -93,7 +94,7 @@ type runState struct {
cancel context.CancelFunc
}
func readLoop(c *websocket.Conn, cli *dockerclient.Client, out chan<- []byte, mu *sync.Mutex, connPtr **websocket.Conn) {
func readLoop(c *websocket.Conn, cli *docker.Client, out chan<- []byte, mu *sync.Mutex, connPtr **websocket.Conn) {
inflight := map[string]*runState{}
for {
_, raw, err := c.ReadMessage()
@@ -107,6 +108,7 @@ func readLoop(c *websocket.Conn, cli *dockerclient.Client, out chan<- []byte, mu
_ = c.Close()
return
}
// Inbound frame: {op, data, id}. Op is the verb. data is op-specific.
var frame struct {
Op string `json:"op"`
Data protocol.DeployRequest `json:"data"`
@@ -128,7 +130,7 @@ func readLoop(c *websocket.Conn, cli *dockerclient.Client, out chan<- []byte, mu
})
continue
}
d := deployer.New(cli, frame.Data.DeploymentID,
d := deployer.NewGo(cli, frame.Data.DeploymentID,
frame.Data.Repository, repoPath,
frame.Data.Branch, frame.Data.Env,
gitutil.Creds{Username: frame.Data.Username, Password: frame.Data.Password},
+10 -4
View File
@@ -1,9 +1,15 @@
module github.com/sdp/agent-micro
go 1.23
go 1.24
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
github.com/gorilla/websocket v1.5.1
github.com/moby/moby/client v0.5.0
github.com/sdp/agentlib v0.0.0-00010101000000-000000000000
github.com/sdp/protocol v0.0.0-00010101000000-000000000000
)
replace (
github.com/sdp/agentlib => ../agentlib
github.com/sdp/protocol => ../protocol
)
-204
View File
@@ -1,204 +0,0 @@
// 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
}
}
}
-65
View File
@@ -1,65 +0,0 @@
// 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
}