Files
aura-crm/production/backend/internal/selfupdate/handler.go
T

200 lines
7.2 KiB
Go

// Package selfupdate is the CRM-side half of the "Обновить" button in
// Settings → Обновления — it never touches git or docker itself, it only
// proxies to deploy-agent (see ../../../deploy-agent/README.md) over that
// agent's Unix socket, and records what happened in deploy_history
// (migrations/055_deploy_history.sql) for the audit trail. This is
// deliberately the most locked-down handler in the app: both routes are
// gated with auth.RequireRole("owner") in main.go, not the granular
// permissions system everything else here uses — see that call's own doc
// comment for why. A backend without DEPLOY_AGENT_TOKEN configured (most
// deployments, until someone opts into installing deploy-agent) fails
// closed with 503 on every route, same pattern as internal/aiintake's
// missing GEMINI_API_KEY.
package selfupdate
import (
"context"
"encoding/json"
"net"
"net/http"
"time"
"production/internal/auth"
"production/internal/dbutil"
"github.com/gofiber/fiber/v2"
"github.com/jackc/pgx/v5/pgxpool"
)
// applyTimeout has to cover a full git pull + docker compose build + up —
// a cold `docker compose build` (base image not yet cached) can run
// several minutes; deploy-agent's own commandTimeout*4 budget is the same
// order of magnitude, this just has to be at least that generous so the
// backend doesn't give up on the HTTP call before the agent's own request
// context would.
const applyTimeout = 20 * time.Minute
const checkTimeout = 30 * time.Second
type Handler struct {
db *pgxpool.Pool
httpClient *http.Client
token string
}
// NewHandler returns a Handler whose routes all 503 if socketPath or token
// is empty — see package doc comment.
func NewHandler(db *pgxpool.Pool, socketPath, token string) *Handler {
var client *http.Client
if socketPath != "" {
client = &http.Client{
Transport: &http.Transport{
DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
var d net.Dialer
return d.DialContext(ctx, "unix", socketPath)
},
},
}
}
return &Handler{db: db, httpClient: client, token: token}
}
func (h *Handler) configured() bool { return h.httpClient != nil && h.token != "" }
type agentCommit struct {
Hash string `json:"hash"`
Author string `json:"author"`
Date string `json:"date"`
Subject string `json:"subject"`
}
func (h *Handler) callAgent(ctx context.Context, path string) (*http.Response, error) {
// Host/scheme are ignored by the unix-socket DialContext above — any
// placeholder works, http.NewRequest just needs a well-formed URL.
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://deploy-agent"+path, nil)
if err != nil {
return nil, err
}
if path == "/apply" {
req.Method = http.MethodPost
}
req.Header.Set("Authorization", "Bearer "+h.token)
return h.httpClient.Do(req)
}
// Check is read-only — GET deploy-agent/check, no deploy_history write (a
// staff member opening the tab or the frontend polling shouldn't spam the
// audit log; only Apply below writes to it).
func (h *Handler) Check(c *fiber.Ctx) error {
if !h.configured() {
return c.Status(503).JSON(fiber.Map{"error": "deploy-agent not configured"})
}
ctx, cancel := context.WithTimeout(context.Background(), checkTimeout)
defer cancel()
resp, err := h.callAgent(ctx, "/check")
if err != nil {
return c.Status(502).JSON(fiber.Map{"error": "deploy-agent unreachable: " + err.Error()})
}
defer resp.Body.Close()
var body struct {
Commits []agentCommit `json:"commits"`
Error string `json:"error"`
}
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
return c.Status(502).JSON(fiber.Map{"error": "deploy-agent returned an invalid response"})
}
if resp.StatusCode != 200 {
return c.Status(502).JSON(fiber.Map{"error": body.Error})
}
return c.JSON(fiber.Map{"commits": body.Commits})
}
// Apply proxies deploy-agent's own /apply and always records the outcome
// in deploy_history, success or failure — a failed deploy is exactly the
// kind of event this audit trail exists to keep, not something to drop
// because the HTTP call itself came back non-200.
func (h *Handler) Apply(c *fiber.Ctx) error {
if !h.configured() {
return c.Status(503).JSON(fiber.Map{"error": "deploy-agent not configured"})
}
startedAt := time.Now()
ctx, cancel := context.WithTimeout(context.Background(), applyTimeout)
defer cancel()
resp, err := h.callAgent(ctx, "/apply")
if err != nil {
h.record(startedAt, false, false, nil, "deploy-agent unreachable: "+err.Error(), c)
return c.Status(502).JSON(fiber.Map{"error": "deploy-agent unreachable: " + err.Error()})
}
defer resp.Body.Close()
var body struct {
Commits []agentCommit `json:"commits"`
Applied bool `json:"applied"`
Error string `json:"error"`
LogTail string `json:"log_tail"`
Message string `json:"message"`
}
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
h.record(startedAt, false, false, nil, "deploy-agent returned an invalid response", c)
return c.Status(502).JSON(fiber.Map{"error": "deploy-agent returned an invalid response"})
}
success := resp.StatusCode == 200
errMsg := body.Error
if !success && body.LogTail != "" {
errMsg = body.Error + "\n\n" + body.LogTail
}
h.record(startedAt, success, body.Applied, body.Commits, errMsg, c)
if !success {
return c.Status(502).JSON(fiber.Map{"error": body.Error, "log_tail": body.LogTail, "commits": body.Commits})
}
return c.JSON(fiber.Map{"commits": body.Commits, "applied": body.Applied, "message": body.Message})
}
func (h *Handler) record(startedAt time.Time, success, applied bool, commits []agentCommit, errMsg string, c *fiber.Ctx) {
commitsJSON, _ := json.Marshal(commits)
if commits == nil {
commitsJSON = []byte("[]")
}
_, _ = h.db.Exec(context.Background(), `
INSERT INTO deploy_history
(triggered_by_staff_id, triggered_by_staff_name, success, applied, commits, error_message, started_at)
VALUES ($1, $2, $3, $4, $5::jsonb, $6, $7)`,
auth.StaffID(c), auth.StaffName(c), success, applied, commitsJSON, dbutil.NullIfEmpty(errMsg), startedAt,
)
}
type historyRow struct {
ID string `json:"id"`
TriggeredByName string `json:"triggered_by_staff_name"`
Success bool `json:"success"`
Applied bool `json:"applied"`
Commits json.RawMessage `json:"commits"`
ErrorMessage *string `json:"error_message"`
StartedAt time.Time `json:"started_at"`
FinishedAt time.Time `json:"finished_at"`
}
func (h *Handler) History(c *fiber.Ctx) error {
rows, err := h.db.Query(context.Background(), `
SELECT id, triggered_by_staff_name, success, applied, commits, error_message, started_at, finished_at
FROM deploy_history ORDER BY started_at DESC LIMIT 50`)
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": "internal error"})
}
defer rows.Close()
out := []historyRow{}
for rows.Next() {
var r historyRow
if err := rows.Scan(&r.ID, &r.TriggeredByName, &r.Success, &r.Applied, &r.Commits, &r.ErrorMessage, &r.StartedAt, &r.FinishedAt); err != nil {
return c.Status(500).JSON(fiber.Map{"error": "internal error"})
}
out = append(out, r)
}
return c.JSON(out)
}