283 lines
10 KiB
Go
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)
|
|
}
|