2bc3ff73a2
- 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.
177 lines
4.2 KiB
Go
177 lines
4.2 KiB
Go
// Command agent-gateway runs on the API Gateway VM (172.18.139.186). It
|
|
// maintains a WebSocket to the control plane and deploys the gateway —
|
|
// which is a PHP project that runs in a pre-loaded php:8.3-apache
|
|
// container. The Go agent itself only orchestrates; it does not build
|
|
// the PHP code.
|
|
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"flag"
|
|
"log"
|
|
"net/url"
|
|
"os"
|
|
"sync"
|
|
"time"
|
|
|
|
docker "github.com/moby/moby/client"
|
|
"github.com/gorilla/websocket"
|
|
|
|
"github.com/sdp/agentlib/deployer"
|
|
"github.com/sdp/agentlib/gitutil"
|
|
"github.com/sdp/protocol"
|
|
)
|
|
|
|
// ponytail: the gateway VM holds a single PHP project at the path below.
|
|
// Real version reads a yaml config like agent-micro does.
|
|
var repos = map[string]string{
|
|
"api-gateway": "/home/user/SDP",
|
|
}
|
|
|
|
func main() {
|
|
cpURL := flag.String("cp", envOr("SDP_CP_URL", "ws://localhost:8080/ws/agent"), "control plane WS URL")
|
|
nodeID := flag.String("node", envOr("SDP_NODE_ID", "gateway"), "node id sent in WS query")
|
|
flag.Parse()
|
|
|
|
cli, err := docker.NewClientWithOpts(docker.FromEnv)
|
|
if err != nil {
|
|
log.Fatalf("docker client: %v", err)
|
|
}
|
|
|
|
u, _ := url.Parse(*cpURL)
|
|
q := u.Query()
|
|
q.Set("node", *nodeID)
|
|
u.RawQuery = q.Encode()
|
|
|
|
out := make(chan []byte, 128)
|
|
var connMu sync.Mutex
|
|
var conn *websocket.Conn
|
|
|
|
go writer(&conn, &connMu, out)
|
|
|
|
for {
|
|
c, err := dial(u)
|
|
if err != nil {
|
|
log.Printf("dial: %v; retrying in 2s", err)
|
|
time.Sleep(2 * time.Second)
|
|
continue
|
|
}
|
|
connMu.Lock()
|
|
conn = c
|
|
connMu.Unlock()
|
|
log.Printf("agent-gateway connected as %s", *nodeID)
|
|
|
|
readLoop(c, cli, out, &connMu, &conn)
|
|
}
|
|
}
|
|
|
|
func dial(u *url.URL) (*websocket.Conn, error) {
|
|
log.Printf("connecting to %s", u)
|
|
c, _, err := websocket.DefaultDialer.Dial(u.String(), nil)
|
|
return c, err
|
|
}
|
|
|
|
// writer pumps outbound events to whichever conn is current. If conn is
|
|
// nil (during reconnect), messages buffer until the next conn is set.
|
|
func writer(conn **websocket.Conn, mu *sync.Mutex, out <-chan []byte) {
|
|
for msg := range out {
|
|
mu.Lock()
|
|
c := *conn
|
|
mu.Unlock()
|
|
if c == nil {
|
|
continue
|
|
}
|
|
if err := c.WriteMessage(websocket.TextMessage, msg); err != nil {
|
|
log.Printf("write: %v", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
type runState struct {
|
|
deployer *deployer.Deployer
|
|
cancel context.CancelFunc
|
|
}
|
|
|
|
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()
|
|
if err != nil {
|
|
log.Printf("read: %v", err)
|
|
mu.Lock()
|
|
if *connPtr == c {
|
|
*connPtr = nil
|
|
}
|
|
mu.Unlock()
|
|
_ = c.Close()
|
|
return
|
|
}
|
|
var frame struct {
|
|
Op string `json:"op"`
|
|
Data protocol.DeployRequest `json:"data"`
|
|
ID string `json:"id"`
|
|
}
|
|
if err := json.Unmarshal(raw, &frame); err != nil {
|
|
log.Printf("bad frame: %v", err)
|
|
continue
|
|
}
|
|
switch frame.Op {
|
|
case "deploy":
|
|
repoPath, ok := repos[frame.Data.Repository]
|
|
if !ok {
|
|
emit(out, protocol.Event{
|
|
DeploymentID: frame.Data.DeploymentID,
|
|
Kind: "status",
|
|
State: "FAILED",
|
|
At: time.Now().UnixMilli(),
|
|
})
|
|
continue
|
|
}
|
|
d := deployer.NewPHP(cli, frame.Data.DeploymentID,
|
|
frame.Data.Repository, repoPath,
|
|
frame.Data.Branch, frame.Data.Env,
|
|
gitutil.Creds{Username: frame.Data.Username, Password: frame.Data.Password},
|
|
)
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
inflight[frame.Data.DeploymentID] = &runState{deployer: d, cancel: cancel}
|
|
go runDeploy(d, ctx, out)
|
|
case "stop":
|
|
if rs, ok := inflight[frame.ID]; ok {
|
|
_ = rs.deployer.Stop(context.Background())
|
|
rs.cancel()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func runDeploy(d *deployer.Deployer, ctx context.Context, out chan<- []byte) {
|
|
events := make(chan protocol.Event, 64)
|
|
go func() {
|
|
state := d.Run(ctx, events)
|
|
if state == "RUNNING" {
|
|
d.StreamLogs(ctx, events)
|
|
}
|
|
close(events)
|
|
}()
|
|
for e := range events {
|
|
emit(out, e)
|
|
}
|
|
}
|
|
|
|
func emit(out chan<- []byte, e protocol.Event) {
|
|
b, _ := json.Marshal(e)
|
|
select {
|
|
case out <- b:
|
|
default:
|
|
// ponytail: drop on backpressure. Deploys are rare.
|
|
}
|
|
}
|
|
|
|
func envOr(k, def string) string {
|
|
if v := os.Getenv(k); v != "" {
|
|
return v
|
|
}
|
|
return def
|
|
}
|