Files
worker/internal/webapp/handlers_compose.go
root ff0d2f088f
Некоторые проверки не удались
CI / test (push) Failing after 6s
Docker / Build and publish worker image (push) Failing after 8s
feat(worker): add Docker Compose discovery and management
Add an internal/compose package that discovers Compose projects via
`docker compose ls` + `docker ps` labels (grouped by
com.docker.compose.project/service) and enriches each container with
`docker inspect` ports/mounts and Traefik router labels. Management
runs `docker compose` in each project's working directory for
up/down/stop/restart/pull plus per-service variants and log tails.

Wire it into the webapp: a 60s ComposeRefresher (constructed in New,
started in Start, stopped in Close), a /compose list + detail + logs
HTML surface, and /web/api/compose/* JSON endpoints (list, detail,
logs, project/service lifecycle). Browser lifecycle POSTs are
session+CSRF protected; the /web/api/* variants accept HTTP basic auth.
WORKER_COMPOSE_ENABLED defaults on (false to disable).

Tests cover discovery parsing/grouping/traefik/summary, the action
allowlists, and the full handler surface (list/detail/logs HTML+API,
CSRF enforcement, disabled/unknown-action rejection, audit writes,
success+failure exec paths) via a stub Docker binary.
2026-07-29 21:31:52 +03:00

281 строка
9.4 KiB
Go

package webapp
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"strings"
"time"
"rocketgit.ru/rsmon/worker/internal/compose"
)
// Compose management endpoints. The browser UI uses session+CSRF POSTs
// under /compose; scripted callers use HTTP basic auth under
// /web/api/compose (the /web/api/* prefix is the only one the auth
// middleware accepts basic credentials on). Read endpoints (list,
// detail, logs) are GET; lifecycle endpoints (up/down/stop/restart/pull)
// are POST. Every mutation is audited via the shared webapp audit log.
// handleComposeList renders the project list page.
func (s *Server) handleComposeList(w http.ResponseWriter, r *http.Request) {
writeNoStore(w)
sess, _ := sessionFromContext(r.Context())
data := composeListPageData{
basePageData: s.newBasePage(r, "Compose projects", sess),
Snapshot: s.compose.Snapshot(),
LastAt: s.compose.LastRefreshAt(),
Enabled: s.compose.Enabled(),
}
if err := s.templates.Execute(w, "compose.html", data); err != nil {
s.deps.Logger.Printf("render compose: %v", err)
http.Error(w, "template error", http.StatusInternalServerError)
}
}
// handleComposeDetail renders one project with its services, containers,
// mounts, and Traefik routes, plus the management action buttons.
func (s *Server) handleComposeDetail(w http.ResponseWriter, r *http.Request) {
writeNoStore(w)
sess, _ := sessionFromContext(r.Context())
snap := s.compose.Snapshot()
p := snap.FindProject(r.PathValue("project"))
if p == nil {
http.NotFound(w, r)
return
}
data := composeDetailPageData{
basePageData: s.newBasePage(r, "Compose: "+p.Name, sess),
Project: p,
LastAt: s.compose.LastRefreshAt(),
ActionOK: r.URL.Query().Get("ok") == "1",
ActionMessage: r.URL.Query().Get("msg"),
}
if err := s.templates.Execute(w, "compose_detail.html", data); err != nil {
s.deps.Logger.Printf("render compose detail: %v", err)
http.Error(w, "template error", http.StatusInternalServerError)
}
}
// handleComposeLogsPage renders the recent log tail for a project in a
// <pre> block so an operator can read it from the browser without curl.
func (s *Server) handleComposeLogsPage(w http.ResponseWriter, r *http.Request) {
writeNoStore(w)
sess, _ := sessionFromContext(r.Context())
project := r.PathValue("project")
res, status := s.composeLogsResult(r.Context(), project)
if status != http.StatusOK {
http.Error(w, res.Output, status)
return
}
data := composeLogsPageData{
basePageData: s.newBasePage(r, "Compose logs: "+project, sess),
Project: project,
Output: res.Output,
}
if err := s.templates.Execute(w, "compose_logs.html", data); err != nil {
s.deps.Logger.Printf("render compose logs: %v", err)
http.Error(w, "template error", http.StatusInternalServerError)
}
}
// handleComposeProjectAction runs a lifecycle action against a project
// from the HTML form (session+CSRF) or the API (basic auth). The
// response is a redirect for the browser path and JSON for the API path.
func (s *Server) handleComposeProjectAction(w http.ResponseWriter, r *http.Request) {
if !s.compose.Enabled() {
http.Error(w, "compose management is disabled", http.StatusServiceUnavailable)
return
}
if !validProjectPostAction(r.PathValue("action")) {
http.NotFound(w, r)
return
}
if !s.composeMutationAuthorized(w, r) {
return
}
project := r.PathValue("project")
action := r.PathValue("action")
refs, ok := s.compose.Refs(project)
if !ok {
http.Error(w, "compose project working directory is unknown", http.StatusNotFound)
return
}
res, _ := compose.ManageProject(r.Context(), refs, compose.Action(action))
s.auditCompose(r, action, project, "")
s.respondComposeAction(w, r, project, res)
}
// handleComposeServiceAction runs a lifecycle action against a single
// service inside a project.
func (s *Server) handleComposeServiceAction(w http.ResponseWriter, r *http.Request) {
if !s.compose.Enabled() {
http.Error(w, "compose management is disabled", http.StatusServiceUnavailable)
return
}
if _, ok := compose.ValidServiceAction(r.PathValue("action")); !ok {
http.NotFound(w, r)
return
}
if !s.composeMutationAuthorized(w, r) {
return
}
project := r.PathValue("project")
service := r.PathValue("service")
action := r.PathValue("action")
refs, ok := s.compose.Refs(project)
if !ok {
http.Error(w, "compose project working directory is unknown", http.StatusNotFound)
return
}
res, _ := compose.ManageService(r.Context(), refs, service, compose.Action(action))
s.auditCompose(r, action, project, service)
s.respondComposeAction(w, r, project, res)
}
// handleComposeAPIList returns the full discovery snapshot as JSON. The
// shape matches the /compose HTML page (a Snapshot: projects + errors).
func (s *Server) handleComposeAPIList(w http.ResponseWriter, r *http.Request) {
writeComposeJSON(w, s.compose.Snapshot())
}
// handleComposeAPIDetail returns one project summary as JSON.
func (s *Server) handleComposeAPIDetail(w http.ResponseWriter, r *http.Request) {
p := s.compose.Snapshot().FindProject(r.PathValue("project"))
if p == nil {
http.Error(w, "compose project not found", http.StatusNotFound)
return
}
writeComposeJSON(w, p)
}
// handleComposeLogsAPI returns the recent log tail for a project as JSON.
func (s *Server) handleComposeLogsAPI(w http.ResponseWriter, r *http.Request) {
res, status := s.composeLogsResult(r.Context(), r.PathValue("project"))
if status != http.StatusOK {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(res)
return
}
writeComposeJSON(w, res)
}
// composeLogsResult resolves the log tail for a project and the HTTP
// status the caller should write. A 404 means the project (or its
// working directory) is unknown; 502 means Docker refused the call.
func (s *Server) composeLogsResult(ctx context.Context, project string) (compose.ManagementResult, int) {
refs, ok := s.compose.Refs(project)
if !ok {
return compose.ManagementResult{OK: false, Action: "logs", Output: "compose project working directory is unknown"}, http.StatusNotFound
}
res, err := compose.Logs(ctx, refs, 300)
if err != nil && res.Output == "" {
return res, http.StatusBadGateway
}
return res, http.StatusOK
}
// composeMutationAuthorized enforces CSRF for browser (session) callers
// while letting basic-auth API callers through without a token (the
// middleware already validated their credentials). Returns true when the
// request may proceed; writes a 403 and returns false otherwise.
func (s *Server) composeMutationAuthorized(w http.ResponseWriter, r *http.Request) bool {
sess, hasSess := sessionFromContext(r.Context())
if !hasSess {
return true // basic-auth API path; middleware authenticated it
}
if !s.requireCSRF(sess, r) {
http.Error(w, "csrf token required", http.StatusForbidden)
return false
}
return true
}
// validProjectPostAction reports whether name is a POST-able project
// action. It reuses compose.ValidProjectAction but excludes "logs",
// which is served by a dedicated GET route.
func validProjectPostAction(name string) bool {
if name == string(compose.ActionLogs) {
return false
}
_, ok := compose.ValidProjectAction(name)
return ok
}
// respondComposeAction sends the action result to the right consumer:
// JSON for /web/api/* (scripting), a redirect with a flash banner for
// the browser path. The action's combined output is URL-encoded into the
// banner so the operator sees what Compose printed without a separate
// request; very large output is truncated to keep the URL bounded.
func (s *Server) respondComposeAction(w http.ResponseWriter, r *http.Request, project string, res compose.ManagementResult) {
if strings.HasPrefix(r.URL.Path, "/web/api/") {
writeComposeJSON(w, res)
return
}
msg := strings.TrimSpace(res.Output)
if len(msg) > 500 {
msg = msg[:500] + "…"
}
if !res.OK && msg == "" {
msg = res.Action + " failed"
}
loc := fmt.Sprintf("/compose/%s?ok=%t&msg=%s", project, res.OK, url.QueryEscape(msg))
http.Redirect(w, r, loc, http.StatusSeeOther)
}
// writeComposeJSON serializes a value as no-store JSON. Used by every
// /web/api/compose endpoint so scripted callers get one consistent shape.
func writeComposeJSON(w http.ResponseWriter, v interface{}) {
writeNoStore(w)
w.Header().Set("Content-Type", "application/json; charset=utf-8")
_ = json.NewEncoder(w).Encode(v)
}
// auditCompose records a management action in the worker audit log. The
// target encodes project and service so the audit trail reconstructs the
// exact scope (e.g. "compose:rsmon/web"); failures are still audited so
// an operator can trace a stopped service back to the action.
func (s *Server) auditCompose(r *http.Request, action, project, service string) {
target := "compose:" + project
if service != "" {
target += "/" + service
}
authMode := auditAuthModeBasic
if _, hasSess := sessionFromContext(r.Context()); hasSess {
authMode = auditAuthModeLocal
}
_ = s.store.WriteAudit(r.Context(), &AuditEntry{
Actor: auditActorLocal,
Role: auditRoleAdmin,
AuthMode: authMode,
IP: clientIP(r),
UA: r.UserAgent(),
Action: "compose_" + action,
Target: target,
})
}
type composeListPageData struct {
basePageData
Snapshot compose.Snapshot
LastAt time.Time
Enabled bool
}
type composeDetailPageData struct {
basePageData
Project *compose.ProjectSummary
LastAt time.Time
ActionOK bool
ActionMessage string
}
type composeLogsPageData struct {
basePageData
Project string
Output string
}