Files
aura-crm/production/deploy-agent/main.go
T

283 lines
10 KiB
Go

// Command deploy-agent is a small, standalone host process — deliberately
// NOT part of the production Go module and NOT built into any Docker image
// — that does exactly two privileged things production's backend cannot
// safely do itself: read this host's git state, and drive `docker compose`
// for the production stack. It exists so that a "check for updates" /
// "apply update" button in the CRM doesn't require giving the backend
// container a docker.sock mount or a copy of this repo's .git directory —
// either of which would turn a compromised (or merely buggy) backend
// process into full host control. Instead, this agent listens on a Unix
// domain socket, and only that one socket file is bind-mounted into the
// backend container (see docker-compose.yml) — no TCP port, no network
// exposure, nothing else about the host reachable through it.
//
// Run as a systemd service (see deploy-agent/deploy-agent.service) under
// the same root user the rest of this host's docker/git tooling already
// runs as (see deploy-agent/README.md for the security tradeoff that
// accepts and how to harden it further).
package main
import (
"bufio"
"bytes"
"context"
"crypto/subtle"
"encoding/json"
"errors"
"log"
"net"
"net/http"
"os"
"os/exec"
"strings"
"sync"
"time"
)
const (
defaultSocketPath = "/root/production/deploy-agent/agent.sock"
defaultRepoPath = "/root/production"
defaultBranch = "main"
defaultComposeSvc = "backend web" // services to pull+restart; deliberately excludes postgres/minio/telegram-bot-api
)
// commandTimeout bounds every individual git/docker invocation — a hung
// `docker compose pull` (registry unreachable, say) fails
// the whole /apply loudly after this instead of leaving the HTTP request
// (and whoever's waiting on it in the CRM) hanging indefinitely.
const commandTimeout = 5 * time.Minute
type commit struct {
Hash string `json:"hash"`
Author string `json:"author"`
Date string `json:"date"`
Subject string `json:"subject"`
}
type agent struct {
repoPath string
branch string
composeSvc string
token string
mu sync.Mutex // serializes /apply — two concurrent deploys stepping on the same working tree is asking for a broken deploy, not a faster one
deploying bool
}
func main() {
socketPath := envOr("DEPLOY_AGENT_SOCKET", defaultSocketPath)
token := os.Getenv("DEPLOY_AGENT_TOKEN")
if token == "" {
log.Fatal("DEPLOY_AGENT_TOKEN must be set — this agent can rebuild and restart the production stack, it must never accept unauthenticated requests")
}
a := &agent{
repoPath: envOr("DEPLOY_AGENT_REPO_PATH", defaultRepoPath),
branch: envOr("DEPLOY_AGENT_BRANCH", defaultBranch),
composeSvc: envOr("DEPLOY_AGENT_COMPOSE_SERVICES", defaultComposeSvc),
token: token,
}
os.Remove(socketPath) // stale socket from a previous run that didn't shut down cleanly
listener, err := net.Listen("unix", socketPath)
if err != nil {
log.Fatalf("listen on %s: %v", socketPath, err)
}
// 0666 rather than 0600: this agent runs as root on the host, but the
// only client — production's backend container — connects as its own
// unprivileged "app" user (see backend/Dockerfile's USER app), a
// different UID inside the container's namespace than root's on the
// host. World-read-write on the socket inode is what actually lets
// that connection through; the real authorization boundary is the
// bearer token check in withAuth below, not this file's Unix
// permission bits — nothing else on the host has a path to this socket
// at all (see docker-compose.yml's bind mount, scoped to backend only).
if err := os.Chmod(socketPath, 0o666); err != nil {
log.Fatalf("chmod %s: %v", socketPath, err)
}
mux := http.NewServeMux()
mux.HandleFunc("/check", a.withAuth(a.handleCheck))
mux.HandleFunc("/apply", a.withAuth(a.handleApply))
log.Printf("deploy-agent listening on unix:%s (repo=%s branch=%s)", socketPath, a.repoPath, a.branch)
log.Fatal(http.Serve(listener, mux))
}
func envOr(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
// withAuth does a constant-time comparison against the configured bearer
// token — this socket is only reachable from inside the backend container
// (see docker-compose.yml's bind mount), but the token stays a real check
// rather than a formality: any other process that can reach this one
// socket file (e.g. something else later added to the same container)
// shouldn't get to trigger a deploy just by being co-located.
func (a *agent) withAuth(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
got := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
if subtle.ConstantTimeCompare([]byte(got), []byte(a.token)) != 1 {
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
return
}
next(w, r)
}
}
func (a *agent) handleCheck(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), commandTimeout)
defer cancel()
if _, err := a.run(ctx, "git", "fetch", "--quiet", "origin", a.branch); err != nil {
writeJSON(w, http.StatusBadGateway, map[string]string{"error": "git fetch failed: " + err.Error()})
return
}
commits, err := a.pendingCommits(ctx)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, map[string]any{"commits": commits})
}
// handleApply is intentionally linear and stops at the first failure —
// there is no rollback here (a partially-applied deploy, e.g. code pulled
// but the image failed to build, is exactly what /check on the next run
// would surface as "nothing new" since the pull already landed; the caller
// gets the failure and the build log tail to act on). Fast-forward-only
// pull: if the local tree has diverged from origin (should never happen —
// nothing else should ever commit directly on this host — but if it does,
// this fails loudly instead of silently creating a merge commit under an
// automated process's name).
func (a *agent) handleApply(w http.ResponseWriter, r *http.Request) {
a.mu.Lock()
if a.deploying {
a.mu.Unlock()
writeJSON(w, http.StatusConflict, map[string]string{"error": "a deploy is already in progress"})
return
}
a.deploying = true
a.mu.Unlock()
defer func() {
a.mu.Lock()
a.deploying = false
a.mu.Unlock()
}()
ctx, cancel := context.WithTimeout(r.Context(), commandTimeout*4)
defer cancel()
steps := []struct {
name string
args []string
}{
{"git fetch", []string{"git", "fetch", "--quiet", "origin", a.branch}},
}
for _, step := range steps {
if _, err := a.run(ctx, step.args[0], step.args[1:]...); err != nil {
writeJSON(w, http.StatusBadGateway, map[string]string{"error": step.name + " failed: " + err.Error()})
return
}
}
commits, err := a.pendingCommits(ctx)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
if len(commits) == 0 {
writeJSON(w, http.StatusOK, map[string]any{"commits": []commit{}, "applied": false, "message": "already up to date"})
return
}
// git pull keeps the on-disk checkout (CHANGELOG.md, this binary's own
// source, etc.) in sync for anyone inspecting the host later — it no
// longer drives the deploy itself. The actual update is docker compose
// pull: .gitea/workflows/build-release.yml already built and pushed
// images for this same commit to Gitea's registry by the time /check
// reports it as pending (CI runs on push, before a human even opens
// this tab), so there's nothing to build here — just pull the
// already-finished image and recreate.
pullSteps := [][]string{
{"git", "pull", "--ff-only", "origin", a.branch},
append([]string{"docker", "compose", "pull"}, strings.Fields(a.composeSvc)...),
append([]string{"docker", "compose", "up", "-d"}, strings.Fields(a.composeSvc)...),
}
for _, args := range pullSteps {
out, err := a.run(ctx, args[0], args[1:]...)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]any{
"error": strings.Join(args, " ") + " failed: " + err.Error(),
"log_tail": tail(out, 60),
"commits": commits,
"applied": false,
})
return
}
}
writeJSON(w, http.StatusOK, map[string]any{"commits": commits, "applied": true})
}
// pendingCommits diffs HEAD..origin/<branch> — call after a `git fetch` so
// origin/<branch> is current. Ordered oldest-first (git log's natural
// newest-first reversed) so a deploy's commit list reads top-to-bottom in
// the order they'll land, matching CHANGELOG.md's own newest-entries-on-
// top-but-within-an-entry-chronological convention.
func (a *agent) pendingCommits(ctx context.Context) ([]commit, error) {
const sep = "\x1f" // unit separator — won't collide with real commit subjects
out, err := a.run(ctx, "git", "log", "--reverse", "HEAD.."+"origin/"+a.branch,
"--date=short", "--format=%H"+sep+"%an"+sep+"%ad"+sep+"%s")
if err != nil {
return nil, err
}
commits := []commit{}
scanner := bufio.NewScanner(bytes.NewReader(out))
for scanner.Scan() {
line := scanner.Text()
if line == "" {
continue
}
parts := strings.SplitN(line, sep, 4)
if len(parts) != 4 {
continue
}
commits = append(commits, commit{Hash: parts[0], Author: parts[1], Date: parts[2], Subject: parts[3]})
}
return commits, scanner.Err()
}
func (a *agent) run(ctx context.Context, name string, args ...string) ([]byte, error) {
cmd := exec.CommandContext(ctx, name, args...)
cmd.Dir = a.repoPath
var buf bytes.Buffer
cmd.Stdout = &buf
cmd.Stderr = &buf
err := cmd.Run()
if err != nil {
if errors.Is(ctx.Err(), context.DeadlineExceeded) {
return buf.Bytes(), errors.New("timed out")
}
return buf.Bytes(), errors.New(strings.TrimSpace(buf.String()))
}
return buf.Bytes(), nil
}
func tail(out []byte, maxLines int) string {
lines := strings.Split(strings.TrimRight(string(out), "\n"), "\n")
if len(lines) > maxLines {
lines = lines[len(lines)-maxLines:]
}
return strings.Join(lines, "\n")
}
func writeJSON(w http.ResponseWriter, status int, body any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(body)
}