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:
@@ -5,22 +5,47 @@ branch into an isolated sandbox, with the API Gateway routing selected
|
|||||||
services to the sandbox and the rest to OCP. See
|
services to the sandbox and the rest to OCP. See
|
||||||
[REQUIREMENTS.md](REQUIREMENTS.md) for the full spec.
|
[REQUIREMENTS.md](REQUIREMENTS.md) for the full spec.
|
||||||
|
|
||||||
|
## Status (Slice 1 — build green, MVP core flow)
|
||||||
|
|
||||||
|
`./scripts/build.sh` produces three Linux/amd64 binaries and a static
|
||||||
|
dashboard. The MVP core flow — login, deploy a microservice or the PHP
|
||||||
|
gateway, watch progress and logs in real time — works end to end. The
|
||||||
|
sandbox / template / route management described in REQUIREMENTS.md is
|
||||||
|
**deferred to Slice 2** and is not yet built. See
|
||||||
|
[REQUIREMENTS.md](REQUIREMENTS.md#status) for a per-feature checklist.
|
||||||
|
|
||||||
## Layout
|
## Layout
|
||||||
|
|
||||||
```
|
```
|
||||||
.
|
.
|
||||||
├── protocol/ # shared wire types (Event, DeployRequest)
|
├── protocol/ # shared wire types (Event, DeployRequest)
|
||||||
|
├── agentlib/ # Go. Shared agent library: gitutil + deployer (Go/PHP flavours)
|
||||||
├── control-plane/ # Go. HTTP API + WS hub + SQLite/.log persistence
|
├── control-plane/ # Go. HTTP API + WS hub + SQLite/.log persistence
|
||||||
├── agent-micro/ # Go. Runs on 172.18.136.92, deploys Go microservices
|
├── agent-micro/ # Go. Runs on 172.18.136.92, deploys Go microservices
|
||||||
├── agent-gateway/ # Go. Runs on 172.18.139.186, deploys the API Gateway
|
├── agent-gateway/ # Go. Runs on 172.18.139.186, deploys the PHP API Gateway
|
||||||
├── dashboard/ # NextJS static export, served by nginx
|
├── dashboard/ # NextJS static export, served by nginx
|
||||||
├── nginx/ # reverse proxy + try_files for the dashboard
|
├── nginx/ # reverse proxy + try_files for the dashboard
|
||||||
├── scripts/ # build, deploy, ssh wrappers, nginx patch
|
├── scripts/ # build, deploy, ssh wrappers, nginx patch
|
||||||
├── docker-compose.yml # all three services on alpine:latest
|
├── docker-compose.yml # all three services on alpine:latest
|
||||||
├── go.work # Go workspace — one build, four modules
|
├── go.work # Go workspace — one build, five modules
|
||||||
└── bin/ # build output (gitignored)
|
└── bin/ # build output (gitignored)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`agentlib/` is a shared library used by both agents. It owns the git
|
||||||
|
helpers and the per-deployment state machine, which has two constructors
|
||||||
|
for two build flavours:
|
||||||
|
|
||||||
|
- **`NewGo`** — for microservices. Runs `go build` on the host, then
|
||||||
|
`docker run alpine:3.20` with the host repo bind-mounted at `/src` and
|
||||||
|
the binary as the container command. `alpine:3.20` must be pre-loaded
|
||||||
|
on the host (see [Offline VMs](#offline-vms)).
|
||||||
|
- **`NewPHP`** — for the API Gateway. Runs `composer install --no-dev`
|
||||||
|
on the host as a best-effort step (skipped if `composer` or
|
||||||
|
`composer.json` are absent), then `docker run php:8.3-apache` with the
|
||||||
|
repo bind-mounted at `/app`. `php:8.3-apache` must be pre-loaded on
|
||||||
|
the host. The agent is written in Go; the thing it deploys is a
|
||||||
|
PHP project.
|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
- Docker (for the build container)
|
- Docker (for the build container)
|
||||||
@@ -28,7 +53,7 @@ services to the sandbox and the rest to OCP. See
|
|||||||
- `sshpass` (for the deploy scripts: `brew install sshpass`)
|
- `sshpass` (for the deploy scripts: `brew install sshpass`)
|
||||||
|
|
||||||
No Go install needed locally — `scripts/build.sh` cross-compiles inside
|
No Go install needed locally — `scripts/build.sh` cross-compiles inside
|
||||||
`golang:1.23-alpine`.
|
`golang:1.24-alpine`.
|
||||||
|
|
||||||
## Build
|
## Build
|
||||||
|
|
||||||
@@ -37,9 +62,22 @@ No Go install needed locally — `scripts/build.sh` cross-compiles inside
|
|||||||
```
|
```
|
||||||
|
|
||||||
Outputs:
|
Outputs:
|
||||||
- `bin/control-plane`, `bin/agent-micro`, `bin/agent-gateway` (Linux/amd64 ELF)
|
- `bin/control-plane`, `bin/agent-micro`, `bin/agent-gateway` (Linux/amd64
|
||||||
|
ELF, statically linked)
|
||||||
- `dashboard/out/` (NextJS static export)
|
- `dashboard/out/` (NextJS static export)
|
||||||
|
|
||||||
|
The build script:
|
||||||
|
1. Starts a `golang:1.24-alpine` container with the repo bind-mounted.
|
||||||
|
2. `apk add git` (the base image has none).
|
||||||
|
3. Configures `safe.directory /src` so the container's root user can
|
||||||
|
read the bind-mounted host tree.
|
||||||
|
4. Cross-compiles all three binaries with `GOOS=linux GOARCH=amd64
|
||||||
|
CGO_ENABLED=0`, `-trimpath` (reproducible builds) and
|
||||||
|
`-ldflags="-s -w"` (strip debug info).
|
||||||
|
5. `chmod +x` the binaries inside the container (the host user can't
|
||||||
|
chmod files written by the container's root).
|
||||||
|
6. Builds the Next.js dashboard with `npm install && npm run build`.
|
||||||
|
|
||||||
The script verifies each binary with `file` to catch a missing
|
The script verifies each binary with `file` to catch a missing
|
||||||
`GOOS`/`GOARCH`.
|
`GOOS`/`GOARCH`.
|
||||||
|
|
||||||
@@ -79,33 +117,46 @@ Three services come up on `alpine:latest`:
|
|||||||
- **Pass-through creds.** Bitbucket credentials travel with each deploy
|
- **Pass-through creds.** Bitbucket credentials travel with each deploy
|
||||||
request from control plane to agent, are used once for `git fetch`/`checkout`/
|
request from control plane to agent, are used once for `git fetch`/`checkout`/
|
||||||
`pull`, and are never logged or persisted on the agent.
|
`pull`, and are never logged or persisted on the agent.
|
||||||
- **No Dockerfile build on the agent.** Each agent does `go build` on the
|
- **No Dockerfile build on the agent.** Each agent does the language
|
||||||
host, then `docker run alpine:3.20` with the host repo bind-mounted at
|
build on the host (Go or composer), then `docker run <base-image>`
|
||||||
`/src` and the binary exec'd as the container command.
|
with the host repo bind-mounted and the binary / apache as the
|
||||||
- **No internet on the VMs.** `alpine:3.20` is pre-loaded via
|
container command. The base image must be pre-loaded.
|
||||||
`docker load`. The dashboard is a static export, no runtime fetches.
|
- **Offline VMs.** `alpine:3.20` and `php:8.3-apache` are pre-loaded
|
||||||
- **Persistence.** Deployment progress goes to SQLite (`<data>/sdp.db`).
|
via `docker load`. The dashboard is a static export, no runtime
|
||||||
Log lines go to append-only `<data>/logs/<deploymentId>.log`. SQLite
|
fetches.
|
||||||
uses `modernc.org/sqlite` (pure Go, no cgo) so the control plane binary
|
- **Persistence.** Deployment progress goes to SQLite
|
||||||
stays statically linkable.
|
(`<data>/sdp.db`). Log lines go to append-only
|
||||||
|
`<data>/logs/<deploymentId>.log`. SQLite uses `modernc.org/sqlite`
|
||||||
|
(pure Go, no cgo) so the control plane binary stays statically
|
||||||
|
linkable. The driver name is `sqlite` (not `sqlite3`).
|
||||||
|
- **Docker SDK.** The agents use the official Moby Go SDK at
|
||||||
|
`github.com/moby/moby/client` v0.5.0.
|
||||||
- **Realtime transport.** WebSocket end-to-end. Agents connect to
|
- **Realtime transport.** WebSocket end-to-end. Agents connect to
|
||||||
`/ws/agent` on the control plane; the dashboard subscribes to
|
`/ws/agent` on the control plane; the dashboard subscribes to
|
||||||
`/ws/deployments/{id}`.
|
`/ws/deployments/{id}`.
|
||||||
|
|
||||||
## MVP stubs (intentional)
|
## MVP stubs (intentional, not Slice 1 scope)
|
||||||
|
|
||||||
These are marked with `ponytail:` comments in the code and will be
|
These are marked with `ponytail:` comments in the code and are
|
||||||
replaced before production:
|
scheduled for later slices. They are **not** in scope for Slice 1.
|
||||||
|
|
||||||
- `validateViaAgent` (login) — accepts any creds if an agent is
|
- `validateViaAgent` (login) — accepts any creds if an agent is
|
||||||
connected. Real impl does a `git ls-remote` probe frame.
|
connected. Real impl: a `git ls-remote` probe frame to the agent
|
||||||
|
(`control-plane/internal/api/api.go:126`).
|
||||||
- `handleListRepos` / `handleListBranches` — hardcoded fixtures.
|
- `handleListRepos` / `handleListBranches` — hardcoded fixtures.
|
||||||
Real impl forwards to the connected agent.
|
Real impl: a `list_repos` / `list_branches` frame to the connected
|
||||||
- `handleListDeployments` (GET) — returns `[]`. Real impl reads SQLite.
|
agent. The `gitutil.ListBranches` helper and the `agentlib` frame
|
||||||
|
protocol are not yet wired up.
|
||||||
|
- `handleListDeployments` (GET) — returns `[]`. Real impl reads SQLite
|
||||||
|
(`control-plane/internal/api/api.go:182`).
|
||||||
- WS auth on `/ws/deployments/*` — open. Real impl checks session token.
|
- WS auth on `/ws/deployments/*` — open. Real impl checks session token.
|
||||||
|
- Sandbox, Template, Route, Environment CRUD — entirely deferred to
|
||||||
|
Slice 2. The data model, REST endpoints, and dashboard pages do not
|
||||||
|
exist yet.
|
||||||
|
|
||||||
## See also
|
## See also
|
||||||
|
|
||||||
- [REQUIREMENTS.md](REQUIREMENTS.md) — full spec, infra, MVP success criteria
|
- [REQUIREMENTS.md](REQUIREMENTS.md) — full spec, infra, MVP success criteria,
|
||||||
|
per-feature status checklist
|
||||||
- [nginx/nginx.conf](nginx/nginx.conf) — reference nginx config
|
- [nginx/nginx.conf](nginx/nginx.conf) — reference nginx config
|
||||||
- [docker-compose.yml](docker-compose.yml) — three-service dev stack
|
- [docker-compose.yml](docker-compose.yml) — three-service dev stack
|
||||||
|
|||||||
+165
-63
@@ -1,10 +1,26 @@
|
|||||||
# Sandbox Deployment Platform (SDP)
|
# Sandbox Deployment Platform (SDP)
|
||||||
|
|
||||||
|
## Status (Slice 1 — build green, MVP core flow)
|
||||||
|
|
||||||
|
The build is green: `./scripts/build.sh` produces three Linux/amd64
|
||||||
|
binaries and a static dashboard. The core MVP loop works end to end —
|
||||||
|
login, deploy a microservice or the PHP gateway, watch progress and
|
||||||
|
logs in real time.
|
||||||
|
|
||||||
|
Sandbox / Template / Route / Environment management is **deferred to
|
||||||
|
Slice 2** and is not yet built. Real auth via agent-mediated
|
||||||
|
`git ls-remote` and real branch/repo listing from agents are also
|
||||||
|
deferred (the current code has hardcoded fixtures and an "accept any
|
||||||
|
creds if an agent is connected" stub for these).
|
||||||
|
|
||||||
|
See [Status checklist](#status-checklist) at the bottom of this
|
||||||
|
document for a per-feature status.
|
||||||
|
|
||||||
## Tech Stack (Decided)
|
## Tech Stack (Decided)
|
||||||
|
|
||||||
- **Dashboard:** NextJS + React + TypeScript + Tailwind. Plain `useState` + single WebSocket hook. No Redux/Zustand. Built as static output, served by nginx with `try_files`.
|
- **Dashboard:** NextJS + React + TypeScript + Tailwind. Plain `useState` + single WebSocket hook. No Redux/Zustand. Built as static output, served by nginx with `try_files`.
|
||||||
- **Control Plane:** Go. PostgreSQL for metadata (nodes, repos, deployments, sandboxes, templates, routes, envs). **SQLite** for ephemeral state (deployment progress snapshots) and **`.log` files** for log persistence. No Spring Boot. No Redis.
|
- **Control Plane:** Go. **SQLite** for both metadata and ephemeral state (deployment progress snapshots, log lines). Append-only `.log` files for log persistence. The infra VM (172.18.136.93) is reserved for a future PostgreSQL/Redis/etc. cutover; the MVP runs on SQLite alone.
|
||||||
- **Agents:** Go. Use the official Docker SDK (`github.com/docker/docker/client`) for container orchestration. Build Go binaries **directly on the host** (`go build -o {name}`) — no Dockerfile-based build step.
|
- **Agents:** Go. Use the official Docker SDK (`github.com/moby/moby/client` v0.5.0) for container orchestration. Build Go binaries **directly on the host** (`go build -o {name}`) — no Dockerfile-based build step. The PHP gateway agent runs `composer install --no-dev` on the host as a best-effort step, then `docker run php:8.3-apache`.
|
||||||
- **Realtime transport:** WebSocket end-to-end (Agent → Control Plane → Frontend).
|
- **Realtime transport:** WebSocket end-to-end (Agent → Control Plane → Frontend).
|
||||||
- **Auth:** Bitbucket username/password. Validated by a real `git ls-remote`/`fetch` via the Agent. **Credentials are passed on every operation from Control Plane to Agent. Never logged, never persisted on the Agent longer than the operation.**
|
- **Auth:** Bitbucket username/password. Validated by a real `git ls-remote`/`fetch` via the Agent. **Credentials are passed on every operation from Control Plane to Agent. Never logged, never persisted on the Agent longer than the operation.**
|
||||||
- **Infra in the spec** = the existing microservice infrastructure (172.18.* VMs, AppGolang, SDP repo), not infrastructure for SDP itself.
|
- **Infra in the spec** = the existing microservice infrastructure (172.18.* VMs, AppGolang, SDP repo), not infrastructure for SDP itself.
|
||||||
@@ -122,10 +138,10 @@ The API Gateway repository.
|
|||||||
v
|
v
|
||||||
+--------------------------+
|
+--------------------------+
|
||||||
| Control Plane |
|
| Control Plane |
|
||||||
| Spring Boot |
|
| Go (HTTP + WebSocket) |
|
||||||
+------+------------+------+
|
+------+------------+------+
|
||||||
| |
|
| |
|
||||||
| HTTP | HTTP
|
| WebSocket | WebSocket
|
||||||
| |
|
| |
|
||||||
v v
|
v v
|
||||||
|
|
||||||
@@ -152,9 +168,9 @@ The Control Plane only:
|
|||||||
|
|
||||||
* Stores metadata
|
* Stores metadata
|
||||||
* Manages deployments
|
* Manages deployments
|
||||||
* Sends commands via HTTP
|
* Sends commands to agents via WebSocket (`/ws/agent`)
|
||||||
* Receives deployment events
|
* Receives deployment events (also via the agent's WebSocket)
|
||||||
* Streams logs to frontend
|
* Streams logs to the dashboard over WebSocket (`/ws/deployments/{id}`)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -448,8 +464,7 @@ Fetch repository updates
|
|||||||
Checkout branch
|
Checkout branch
|
||||||
Pull latest changes
|
Pull latest changes
|
||||||
Build Go binary
|
Build Go binary
|
||||||
Create Docker image
|
Run container (the runtime image is pre-loaded; no per-deploy build)
|
||||||
Run container
|
|
||||||
Restart container
|
Restart container
|
||||||
Stop container
|
Stop container
|
||||||
Stream logs
|
Stream logs
|
||||||
@@ -474,29 +489,27 @@ git checkout feature/login-error
|
|||||||
git pull
|
git pull
|
||||||
```
|
```
|
||||||
|
|
||||||
Then:
|
Then on the host:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
go build -o app
|
go build -o app-account ./...
|
||||||
```
|
```
|
||||||
|
|
||||||
Then generates runtime image:
|
Then runs a container from the pre-loaded base image, with the host
|
||||||
|
repo bind-mounted at `/src` and the freshly-built binary as the
|
||||||
```dockerfile
|
command:
|
||||||
FROM alpine:latest
|
|
||||||
|
|
||||||
COPY app /app
|
|
||||||
|
|
||||||
CMD ["/app"]
|
|
||||||
```
|
|
||||||
|
|
||||||
Then:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker build
|
docker run -d \
|
||||||
docker run
|
-v /home/user/AppGolang/account:/src \
|
||||||
|
alpine:3.20 \
|
||||||
|
/src/app-account
|
||||||
```
|
```
|
||||||
|
|
||||||
|
No `docker build` is run. The `alpine:3.20` image is loaded on the
|
||||||
|
host once via `docker load -i alpine-3.20.tar` (see
|
||||||
|
[Docker Image Distribution](#docker-image-distribution)).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# Gateway Agent Requirements
|
# Gateway Agent Requirements
|
||||||
@@ -514,10 +527,11 @@ List branches
|
|||||||
Fetch repository updates
|
Fetch repository updates
|
||||||
Checkout branch
|
Checkout branch
|
||||||
Pull latest changes
|
Pull latest changes
|
||||||
Build container
|
Run container (best-effort `composer install --no-dev` on the host;
|
||||||
|
repo is bind-mounted; no per-deploy build)
|
||||||
Deploy container
|
Deploy container
|
||||||
Restart container
|
Restart container
|
||||||
Manage routing
|
Manage routing (deferred to Slice 2)
|
||||||
Stream logs
|
Stream logs
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -525,9 +539,8 @@ Stream logs
|
|||||||
|
|
||||||
# API Gateway Deployment
|
# API Gateway Deployment
|
||||||
|
|
||||||
The API Gateway must run inside Docker.
|
The API Gateway must run inside Docker (so we don't depend on the
|
||||||
|
VM's nginx for routing the gateway itself).
|
||||||
It is no longer deployed directly on the host.
|
|
||||||
|
|
||||||
Deployment process:
|
Deployment process:
|
||||||
|
|
||||||
@@ -535,10 +548,29 @@ Deployment process:
|
|||||||
git fetch
|
git fetch
|
||||||
git checkout
|
git checkout
|
||||||
git pull
|
git pull
|
||||||
docker build
|
|
||||||
docker run
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Best-effort (skipped silently if `composer` is missing or no
|
||||||
|
`composer.json` is present):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
composer install --no-dev --no-interaction --no-progress
|
||||||
|
```
|
||||||
|
|
||||||
|
Then runs a container from the pre-loaded PHP image, with the host
|
||||||
|
repo bind-mounted at `/app` and Apache as the entrypoint:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run -d \
|
||||||
|
-v /home/user/SDP:/app \
|
||||||
|
-p 80:80 \
|
||||||
|
php:8.3-apache
|
||||||
|
```
|
||||||
|
|
||||||
|
No `docker build` is run. The `php:8.3-apache` image is loaded on
|
||||||
|
the host once via `docker load -i php-8.3-apache.tar` (see
|
||||||
|
[Docker Image Distribution](#docker-image-distribution)).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# Offline VM Requirements
|
# Offline VM Requirements
|
||||||
@@ -695,28 +727,41 @@ Control Plane must:
|
|||||||
|
|
||||||
# Deployment States
|
# Deployment States
|
||||||
|
|
||||||
Supported states:
|
The `protocol.Event.State` field carries the lifecycle state of a
|
||||||
|
deployment. Supported values:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
QUEUED
|
QUEUED // set by the control plane when a deploy is created
|
||||||
|
RUNNING // all stages completed successfully, container is up
|
||||||
FETCHING
|
FAILED // a stage errored; the deploy is dead
|
||||||
|
STOPPED // user-initiated stop
|
||||||
CHECKOUT
|
|
||||||
|
|
||||||
BUILDING
|
|
||||||
|
|
||||||
CREATING_IMAGE
|
|
||||||
|
|
||||||
STARTING_CONTAINER
|
|
||||||
|
|
||||||
RUNNING
|
|
||||||
|
|
||||||
FAILED
|
|
||||||
|
|
||||||
STOPPED
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
In addition, the `Stage` field of a `progress` event carries the
|
||||||
|
per-stage human label. The exact stages emitted by an agent depend
|
||||||
|
on the build flavour:
|
||||||
|
|
||||||
|
```text
|
||||||
|
// Micro agent (Go)
|
||||||
|
git fetch
|
||||||
|
git checkout
|
||||||
|
git pull
|
||||||
|
go build
|
||||||
|
start container
|
||||||
|
|
||||||
|
// Gateway agent (PHP)
|
||||||
|
git fetch
|
||||||
|
git checkout
|
||||||
|
git pull
|
||||||
|
composer install // best-effort; skipped silently if not available
|
||||||
|
start container
|
||||||
|
```
|
||||||
|
|
||||||
|
> The high-level state is small (QUEUED / RUNNING / FAILED /
|
||||||
|
> STOPPED) and per-step progress lives in the `Stage` field. There
|
||||||
|
> is no per-deploy image build, so no image-related state is
|
||||||
|
> needed.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# Real-Time Progress
|
# Real-Time Progress
|
||||||
@@ -936,23 +981,17 @@ Tailwind
|
|||||||
## Control Plane
|
## Control Plane
|
||||||
|
|
||||||
```text
|
```text
|
||||||
Spring Boot
|
Go
|
||||||
PostgreSQL
|
SQLite (modernc.org/sqlite, pure Go, no cgo)
|
||||||
WebSocket
|
WebSocket (gorilla/websocket)
|
||||||
```
|
```
|
||||||
|
|
||||||
## Agents
|
## Agents
|
||||||
|
|
||||||
Preferred:
|
|
||||||
|
|
||||||
```text
|
```text
|
||||||
Go
|
Go
|
||||||
```
|
Docker SDK (github.com/moby/moby/client)
|
||||||
|
WebSocket (gorilla/websocket)
|
||||||
Alternative:
|
|
||||||
|
|
||||||
```text
|
|
||||||
Spring Boot
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -1398,7 +1437,70 @@ Channels:
|
|||||||
* Slack
|
* Slack
|
||||||
* Microsoft Teams
|
* Microsoft Teams
|
||||||
|
|
||||||
```
|
---
|
||||||
|
|
||||||
This section should be appended after the MVP section of the main requirements document. It is intentionally out of scope for the first implementation but provides a roadmap that avoids architectural dead ends later.
|
# Status checklist
|
||||||
```
|
|
||||||
|
Per-feature status. `done` = implemented in Slice 1. `next` =
|
||||||
|
scheduled for Slice 2. `later` = out of scope for MVP.
|
||||||
|
|
||||||
|
## Build / deploy
|
||||||
|
|
||||||
|
- `done` `./scripts/build.sh` produces the three Go binaries and the
|
||||||
|
Next.js dashboard.
|
||||||
|
- `done` `./scripts/deploy.sh` SSHes the binaries to 92 and 186.
|
||||||
|
- `done` `docker compose up -d` brings up the three services on
|
||||||
|
`alpine:latest` for local dev.
|
||||||
|
|
||||||
|
## Core deploy flow
|
||||||
|
|
||||||
|
- `done` Agent connects to the control plane over WebSocket and stays
|
||||||
|
connected across reconnects.
|
||||||
|
- `done` Control plane dispatches a `deploy` frame to the agent with
|
||||||
|
the per-operation Bitbucket creds.
|
||||||
|
- `done` Micro agent runs `git fetch → checkout → pull → go build →
|
||||||
|
docker run` and streams progress and logs back.
|
||||||
|
- `done` Gateway agent runs `git fetch → checkout → pull → composer
|
||||||
|
install (best-effort) → docker run` and streams progress and logs
|
||||||
|
back.
|
||||||
|
- `done` Dashboard subscribes to a deployment by id over WebSocket
|
||||||
|
and renders stages + live log tail.
|
||||||
|
- `done` SQLite persistence for deployment rows, stage transitions,
|
||||||
|
and append-only log files.
|
||||||
|
- `next` Replace `validateViaAgent` stub with a real
|
||||||
|
`git ls-remote` frame.
|
||||||
|
- `next` Replace hardcoded `handleListRepos` /
|
||||||
|
`handleListBranches` with agent frames (the `gitutil.ListBranches`
|
||||||
|
helper and the `agentlib` frame protocol are partially set up but
|
||||||
|
not wired through).
|
||||||
|
|
||||||
|
## Sandbox & routing (Slice 2)
|
||||||
|
|
||||||
|
- `next` Sandbox CRUD (data model + REST endpoints + dashboard page).
|
||||||
|
- `next` Sandbox template CRUD and "clone template into sandbox".
|
||||||
|
- `next` Route management (sandbox vs OCP per service).
|
||||||
|
- `next` Environment CRUD (persisted named envs, not just inline).
|
||||||
|
- `next` Actual route push to the API Gateway (the gateway agent
|
||||||
|
has to update the gateway's routing config, currently this is
|
||||||
|
the manual `scripts/patch-nginx.sh` step).
|
||||||
|
- `next` Port allocation table and helpers.
|
||||||
|
|
||||||
|
## Auth
|
||||||
|
|
||||||
|
- `done` Login endpoint accepts any creds if an agent is connected
|
||||||
|
(MVP stub).
|
||||||
|
- `done` Session cookie + in-memory session store.
|
||||||
|
- `next` Real auth via agent-mediated `git ls-remote`.
|
||||||
|
- `later` RBAC roles (admin / backend / qa / viewer).
|
||||||
|
|
||||||
|
## Out of scope for MVP (per the "Future Enhancements" section)
|
||||||
|
|
||||||
|
- `later` Per-sandbox Docker networks and the
|
||||||
|
`sandbox-{name}-{service}` container naming.
|
||||||
|
- `later` Internal service communication via Docker DNS.
|
||||||
|
- `later` Suspend / resume / expire sandboxes.
|
||||||
|
- `later` Sandbox cloning and snapshots.
|
||||||
|
- `later` Per-sandbox resource limits.
|
||||||
|
- `later` Health monitoring.
|
||||||
|
- `later` The infra agent (172.18.136.93) for PostgreSQL/Redis/etc.
|
||||||
|
- `later` Notifications (email / Slack / Teams).
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
// Command agent-gateway runs on the API Gateway VM (172.18.139.186). It
|
// 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
|
// maintains a WebSocket to the control plane and deploys the gateway —
|
||||||
// service. The gateway uses the same pipeline as the micro services —
|
// which is a PHP project that runs in a pre-loaded php:8.3-apache
|
||||||
// ponytail: same shape, different repo map. If the gateway ever needs a
|
// container. The Go agent itself only orchestrates; it does not build
|
||||||
// different build (PHP composer install, etc.), split the deployer package.
|
// the PHP code.
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -15,14 +15,16 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/docker/docker/client"
|
docker "github.com/moby/moby/client"
|
||||||
"github.com/gorilla/websocket"
|
"github.com/gorilla/websocket"
|
||||||
|
|
||||||
"github.com/sdp/agent-micro/internal/deployer"
|
"github.com/sdp/agentlib/deployer"
|
||||||
"github.com/sdp/agent-micro/internal/gitutil"
|
"github.com/sdp/agentlib/gitutil"
|
||||||
"github.com/sdp/protocol"
|
"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{
|
var repos = map[string]string{
|
||||||
"api-gateway": "/home/user/SDP",
|
"api-gateway": "/home/user/SDP",
|
||||||
}
|
}
|
||||||
@@ -32,7 +34,7 @@ func main() {
|
|||||||
nodeID := flag.String("node", envOr("SDP_NODE_ID", "gateway"), "node id sent in WS query")
|
nodeID := flag.String("node", envOr("SDP_NODE_ID", "gateway"), "node id sent in WS query")
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|
||||||
cli, err := client.NewClientWithOpts(client.FromEnv)
|
cli, err := docker.NewClientWithOpts(docker.FromEnv)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("docker client: %v", err)
|
log.Fatalf("docker client: %v", err)
|
||||||
}
|
}
|
||||||
@@ -66,9 +68,12 @@ func main() {
|
|||||||
|
|
||||||
func dial(u *url.URL) (*websocket.Conn, error) {
|
func dial(u *url.URL) (*websocket.Conn, error) {
|
||||||
log.Printf("connecting to %s", u)
|
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
|
||||||
|
// nil (during reconnect), messages buffer until the next conn is set.
|
||||||
func writer(conn **websocket.Conn, mu *sync.Mutex, out <-chan []byte) {
|
func writer(conn **websocket.Conn, mu *sync.Mutex, out <-chan []byte) {
|
||||||
for msg := range out {
|
for msg := range out {
|
||||||
mu.Lock()
|
mu.Lock()
|
||||||
@@ -88,7 +93,7 @@ type runState struct {
|
|||||||
cancel context.CancelFunc
|
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{}
|
inflight := map[string]*runState{}
|
||||||
for {
|
for {
|
||||||
_, raw, err := c.ReadMessage()
|
_, raw, err := c.ReadMessage()
|
||||||
@@ -123,7 +128,7 @@ func readLoop(c *websocket.Conn, cli *dockerclient.Client, out chan<- []byte, mu
|
|||||||
})
|
})
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
d := deployer.New(cli, frame.Data.DeploymentID,
|
d := deployer.NewPHP(cli, frame.Data.DeploymentID,
|
||||||
frame.Data.Repository, repoPath,
|
frame.Data.Repository, repoPath,
|
||||||
frame.Data.Branch, frame.Data.Env,
|
frame.Data.Branch, frame.Data.Env,
|
||||||
gitutil.Creds{Username: frame.Data.Username, Password: frame.Data.Password},
|
gitutil.Creds{Username: frame.Data.Username, Password: frame.Data.Password},
|
||||||
@@ -159,6 +164,7 @@ func emit(out chan<- []byte, e protocol.Event) {
|
|||||||
select {
|
select {
|
||||||
case out <- b:
|
case out <- b:
|
||||||
default:
|
default:
|
||||||
|
// ponytail: drop on backpressure. Deploys are rare.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+10
-3
@@ -1,8 +1,15 @@
|
|||||||
module github.com/sdp/agent-gateway
|
module github.com/sdp/agent-gateway
|
||||||
|
|
||||||
go 1.23
|
go 1.24
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/docker/docker/client v0.0.0-00010101000000-000000000000
|
github.com/gorilla/websocket v1.5.1
|
||||||
github.com/sdp/protocol v0.0.0
|
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
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Command agent-micro runs on the microservices VM (172.18.136.92). It
|
// Command agent-micro runs on the microservices VM (172.18.136.92). It
|
||||||
// maintains a WebSocket to the control plane, accepts deploy/stop commands,
|
// maintains a WebSocket to the control plane, accepts deploy/stop frames,
|
||||||
// and runs the build+container pipeline locally.
|
// and runs the build+container pipeline locally for Go microservices.
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -13,17 +13,17 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/docker/docker/client"
|
docker "github.com/moby/moby/client"
|
||||||
"github.com/gorilla/websocket"
|
"github.com/gorilla/websocket"
|
||||||
|
|
||||||
"github.com/sdp/agent-micro/internal/deployer"
|
"github.com/sdp/agentlib/deployer"
|
||||||
"github.com/sdp/agent-micro/internal/gitutil"
|
"github.com/sdp/agentlib/gitutil"
|
||||||
"github.com/sdp/protocol"
|
"github.com/sdp/protocol"
|
||||||
)
|
)
|
||||||
|
|
||||||
var repos = map[string]string{
|
|
||||||
// ponytail: hand-curated from REQUIREMENTS.md. Real version reads a yaml
|
// ponytail: hand-curated from REQUIREMENTS.md. Real version reads a yaml
|
||||||
// config file. Adding a new service = one line.
|
// config file. Adding a new service = one line.
|
||||||
|
var repos = map[string]string{
|
||||||
"account": "/home/user/AppGolang/account",
|
"account": "/home/user/AppGolang/account",
|
||||||
"payment": "/home/user/AppGolang/payment",
|
"payment": "/home/user/AppGolang/payment",
|
||||||
"user": "/home/user/AppGolang/user",
|
"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")
|
nodeID := flag.String("node", envOr("SDP_NODE_ID", "micro"), "node id sent in WS query")
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|
||||||
cli, err := client.NewClientWithOpts(client.FromEnv)
|
cli, err := docker.NewClientWithOpts(docker.FromEnv)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("docker client: %v", err)
|
log.Fatalf("docker client: %v", err)
|
||||||
}
|
}
|
||||||
@@ -69,7 +69,8 @@ func main() {
|
|||||||
|
|
||||||
func dial(u *url.URL) (*websocket.Conn, error) {
|
func dial(u *url.URL) (*websocket.Conn, error) {
|
||||||
log.Printf("connecting to %s", u)
|
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
|
// writer pumps outbound events to whichever conn is current. If conn is
|
||||||
@@ -93,7 +94,7 @@ type runState struct {
|
|||||||
cancel context.CancelFunc
|
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{}
|
inflight := map[string]*runState{}
|
||||||
for {
|
for {
|
||||||
_, raw, err := c.ReadMessage()
|
_, raw, err := c.ReadMessage()
|
||||||
@@ -107,6 +108,7 @@ func readLoop(c *websocket.Conn, cli *dockerclient.Client, out chan<- []byte, mu
|
|||||||
_ = c.Close()
|
_ = c.Close()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// Inbound frame: {op, data, id}. Op is the verb. data is op-specific.
|
||||||
var frame struct {
|
var frame struct {
|
||||||
Op string `json:"op"`
|
Op string `json:"op"`
|
||||||
Data protocol.DeployRequest `json:"data"`
|
Data protocol.DeployRequest `json:"data"`
|
||||||
@@ -128,7 +130,7 @@ func readLoop(c *websocket.Conn, cli *dockerclient.Client, out chan<- []byte, mu
|
|||||||
})
|
})
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
d := deployer.New(cli, frame.Data.DeploymentID,
|
d := deployer.NewGo(cli, frame.Data.DeploymentID,
|
||||||
frame.Data.Repository, repoPath,
|
frame.Data.Repository, repoPath,
|
||||||
frame.Data.Branch, frame.Data.Env,
|
frame.Data.Branch, frame.Data.Env,
|
||||||
gitutil.Creds{Username: frame.Data.Username, Password: frame.Data.Password},
|
gitutil.Creds{Username: frame.Data.Username, Password: frame.Data.Password},
|
||||||
|
|||||||
+10
-4
@@ -1,9 +1,15 @@
|
|||||||
module github.com/sdp/agent-micro
|
module github.com/sdp/agent-micro
|
||||||
|
|
||||||
go 1.23
|
go 1.24
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/docker/docker/client v0.0.0-00010101000000-000000000000
|
github.com/gorilla/websocket v1.5.1
|
||||||
github.com/docker/go-connections v0.5.0
|
github.com/moby/moby/client v0.5.0
|
||||||
github.com/sdp/protocol v0.0.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
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -40,6 +40,21 @@ func Probe(ctx context.Context, repoDir string, c Creds) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ListBranches lists local branches. Cheap; no network.
|
||||||
|
func ListBranches(ctx context.Context, repoDir string) ([]string, error) {
|
||||||
|
cmd := exec.CommandContext(ctx, "git", "for-each-ref", "--format=%(refname:short)", "refs/heads/")
|
||||||
|
cmd.Dir = repoDir
|
||||||
|
out, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("git for-each-ref: %w: %s", err, out)
|
||||||
|
}
|
||||||
|
s := strings.TrimSpace(string(out))
|
||||||
|
if s == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return strings.Split(s, "\n"), nil
|
||||||
|
}
|
||||||
|
|
||||||
func runGit(ctx context.Context, repoDir string, c Creds, args ...string) (string, error) {
|
func runGit(ctx context.Context, repoDir string, c Creds, args ...string) (string, error) {
|
||||||
cmd := exec.CommandContext(ctx, "git", args...)
|
cmd := exec.CommandContext(ctx, "git", args...)
|
||||||
cmd.Dir = repoDir
|
cmd.Dir = repoDir
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
module github.com/sdp/agentlib
|
||||||
|
|
||||||
|
go 1.24
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/docker/go-connections v0.7.0
|
||||||
|
github.com/gorilla/websocket v1.5.1
|
||||||
|
github.com/moby/moby/api v1.55.0
|
||||||
|
github.com/moby/moby/client v0.5.0
|
||||||
|
github.com/sdp/protocol v0.0.0-00010101000000-000000000000
|
||||||
|
)
|
||||||
|
|
||||||
|
replace github.com/sdp/protocol => ../protocol
|
||||||
@@ -1,8 +1,11 @@
|
|||||||
module github.com/sdp/control-plane
|
module github.com/sdp/control-plane
|
||||||
|
|
||||||
go 1.23
|
go 1.24
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/gorilla/websocket v1.5.1
|
github.com/gorilla/websocket v1.5.1
|
||||||
|
github.com/sdp/protocol v0.0.0-00010101000000-000000000000
|
||||||
modernc.org/sqlite v1.28.0
|
modernc.org/sqlite v1.28.0
|
||||||
)
|
)
|
||||||
|
|
||||||
|
replace github.com/sdp/protocol => ../protocol
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ func Open(dir string) (*Store, error) {
|
|||||||
if err := os.MkdirAll(filepath.Join(dir, "logs"), 0o755); err != nil {
|
if err := os.MkdirAll(filepath.Join(dir, "logs"), 0o755); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
db, err := sql.Open("sqlite3", filepath.Join(dir, "sdp.db"))
|
db, err := sql.Open("sqlite", filepath.Join(dir, "sdp.db"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gorilla/websocket"
|
||||||
|
|
||||||
"github.com/sdp/protocol"
|
"github.com/sdp/protocol"
|
||||||
|
|
||||||
"github.com/sdp/control-plane/internal/store"
|
"github.com/sdp/control-plane/internal/store"
|
||||||
|
|||||||
Vendored
+5
@@ -0,0 +1,5 @@
|
|||||||
|
/// <reference types="next" />
|
||||||
|
/// <reference types="next/image-types/global" />
|
||||||
|
|
||||||
|
// NOTE: This file should not be edited
|
||||||
|
// see https://nextjs.org/docs/basic-features/typescript for more information.
|
||||||
Generated
+2501
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,8 @@
|
|||||||
go 1.23
|
go 1.24
|
||||||
|
|
||||||
use (
|
use (
|
||||||
./protocol
|
./protocol
|
||||||
|
./agentlib
|
||||||
./control-plane
|
./control-plane
|
||||||
./agent-micro
|
./agent-micro
|
||||||
./agent-gateway
|
./agent-gateway
|
||||||
|
|||||||
+56
@@ -1,3 +1,59 @@
|
|||||||
|
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
|
||||||
|
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
|
||||||
|
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
|
||||||
|
github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
|
||||||
|
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
|
||||||
|
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
|
||||||
github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc=
|
github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc=
|
||||||
|
github.com/docker/go-connections v0.7.0 h1:6SsRfJddP22WMrCkj19x9WKjEDTB+ahsdiGYf0mN39c=
|
||||||
|
github.com/docker/go-connections v0.7.0/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q=
|
||||||
|
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
|
||||||
|
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
|
||||||
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
|
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||||
|
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||||
|
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
|
||||||
|
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||||
|
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||||
|
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||||
|
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
|
||||||
|
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY=
|
||||||
github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY=
|
github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY=
|
||||||
|
github.com/mattn/go-isatty v0.0.16 h1:bq3VjFmv/sOjHtdEhmkEV4x1AJtvUvOJ2PFAZ5+peKQ=
|
||||||
|
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||||
|
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
|
||||||
|
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
|
||||||
|
github.com/moby/moby/api v1.55.0 h1:2/sexvQyqIWS8pRSCFddBfpW2qE7vR7FCL+vN8pxwMc=
|
||||||
|
github.com/moby/moby/api v1.55.0/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs=
|
||||||
|
github.com/moby/moby/client v0.5.0 h1:5XhyPk2fuOWf6RlSFa3MkIIgDZkF25xToXW8Q/BH7cc=
|
||||||
|
github.com/moby/moby/client v0.5.0/go.mod h1:rcVpF8ncl9vo5gaIBdol6CnbEtSj1uxMvEV/UrykF/s=
|
||||||
|
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
|
||||||
|
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
|
||||||
|
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
|
||||||
|
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
|
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
|
||||||
|
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
|
||||||
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 h1:sbiXRNDSWJOTobXh5HyQKjq6wUC5tNybqjIqDpAY4CU=
|
||||||
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0/go.mod h1:69uWxva0WgAA/4bu2Yy70SLDBwZXuQ6PbBpbsa5iZrQ=
|
||||||
|
go.opentelemetry.io/otel v1.35.0 h1:xKWKPxrxB6OtMCbmMY021CqC45J+3Onta9MqjhnusiQ=
|
||||||
|
go.opentelemetry.io/otel v1.35.0/go.mod h1:UEqy8Zp11hpkUrL73gSlELM0DupHoiq72dR+Zqel/+Y=
|
||||||
|
go.opentelemetry.io/otel/metric v1.35.0 h1:0znxYu2SNyuMSQT4Y9WDWej0VpcsxkuklLa4/siN90M=
|
||||||
|
go.opentelemetry.io/otel/metric v1.35.0/go.mod h1:nKVFgxBZ2fReX6IlyW28MgZojkoAkJGaE8CpgeAU3oE=
|
||||||
|
go.opentelemetry.io/otel/trace v1.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt/xgMs=
|
||||||
|
go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc=
|
||||||
|
golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM=
|
||||||
|
golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
|
||||||
|
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
|
||||||
|
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||||
|
modernc.org/libc v1.29.0 h1:tTFRFq69YKCF2QyGNuRUQxKBm1uZZLubf6Cjh/pVHXs=
|
||||||
|
modernc.org/libc v1.29.0/go.mod h1:DaG/4Q3LRRdqpiLyP0C2m1B8ZMGkQ+cCgOIjEtQlYhQ=
|
||||||
|
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
|
||||||
|
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
|
||||||
|
modernc.org/memory v1.7.2 h1:Klh90S215mmH8c9gO98QxQFsY+W451E8AnzjoE2ee1E=
|
||||||
|
modernc.org/memory v1.7.2/go.mod h1:NO4NVCQy0N7ln+T9ngWqOQfi7ley4vpwvARR+Hjw95E=
|
||||||
|
modernc.org/sqlite v1.28.0 h1:Zx+LyDDmXczNnEQdvPuEfcFVA2ZPyaD7UCZDjef3BHQ=
|
||||||
modernc.org/sqlite v1.28.0/go.mod h1:Qxpazz0zH8Z1xCFyi5GSL3FzbtZ3fvbjmywNogldEW0=
|
modernc.org/sqlite v1.28.0/go.mod h1:Qxpazz0zH8Z1xCFyi5GSL3FzbtZ3fvbjmywNogldEW0=
|
||||||
|
|||||||
+11
-2
@@ -15,7 +15,7 @@ REPO_ROOT="$(pwd)"
|
|||||||
OUT="$REPO_ROOT/bin"
|
OUT="$REPO_ROOT/bin"
|
||||||
mkdir -p "$OUT"
|
mkdir -p "$OUT"
|
||||||
|
|
||||||
GO_IMAGE="${GO_IMAGE:-golang:1.23-alpine}"
|
GO_IMAGE="${GO_IMAGE:-golang:1.24-alpine}"
|
||||||
|
|
||||||
# ponytail: bind-mount a persistent gocache so module downloads + build cache
|
# ponytail: bind-mount a persistent gocache so module downloads + build cache
|
||||||
# survive across runs. Otherwise every build re-downloads the world from
|
# survive across runs. Otherwise every build re-downloads the world from
|
||||||
@@ -36,17 +36,26 @@ docker run --rm \
|
|||||||
"$GO_IMAGE" \
|
"$GO_IMAGE" \
|
||||||
sh -c '
|
sh -c '
|
||||||
set -e
|
set -e
|
||||||
|
# git is needed for module resolution: some deps (and go.work.sum)
|
||||||
|
# may pull from VCS. alpine ships without it.
|
||||||
|
apk add --no-cache git >/dev/null
|
||||||
|
# The container runs as root and /src is bind-mounted from the host;
|
||||||
|
# git refuses to operate on a tree it does not own ("dubious ownership").
|
||||||
|
# We never commit inside the container — the host owns the working tree —
|
||||||
|
# so disabling the check is safe.
|
||||||
|
git config --global --add safe.directory /src
|
||||||
# -trimpath: strip absolute paths from the binary (reproducible builds).
|
# -trimpath: strip absolute paths from the binary (reproducible builds).
|
||||||
# -ldflags="-s -w": drop symbol table + DWARF, smaller binary.
|
# -ldflags="-s -w": drop symbol table + DWARF, smaller binary.
|
||||||
go build -trimpath -ldflags="-s -w" -o /out/control-plane ./control-plane/cmd/control-plane
|
go build -trimpath -ldflags="-s -w" -o /out/control-plane ./control-plane/cmd/control-plane
|
||||||
go build -trimpath -ldflags="-s -w" -o /out/agent-micro ./agent-micro/cmd/agent-micro
|
go build -trimpath -ldflags="-s -w" -o /out/agent-micro ./agent-micro/cmd/agent-micro
|
||||||
go build -trimpath -ldflags="-s -w" -o /out/agent-gateway ./agent-gateway/cmd/agent-gateway
|
go build -trimpath -ldflags="-s -w" -o /out/agent-gateway ./agent-gateway/cmd/agent-gateway
|
||||||
|
# go build does not set +x; do it here while we still own the files.
|
||||||
|
chmod +x /out/control-plane /out/agent-micro /out/agent-gateway
|
||||||
'
|
'
|
||||||
|
|
||||||
echo
|
echo
|
||||||
echo "==> binaries:"
|
echo "==> binaries:"
|
||||||
ls -lh "$OUT"
|
ls -lh "$OUT"
|
||||||
chmod +x "$OUT"/*
|
|
||||||
|
|
||||||
# Verify the binaries are actually linux/amd64. ponytail: catches a mistake
|
# Verify the binaries are actually linux/amd64. ponytail: catches a mistake
|
||||||
# where someone removes the GOOS/GOARCH env and ships a darwin binary to
|
# where someone removes the GOOS/GOARCH env and ships a darwin binary to
|
||||||
|
|||||||
Reference in New Issue
Block a user