Некоторые проверки не удались
CI / test (push) Failing after 6s
Docker / Build and publish worker image (push) Failing after 8s
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.
281 строка
9.4 KiB
Go
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
|
|
}
|