200 lines
7.2 KiB
Go
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)
|
|
}
|