feat(worker): add Docker Compose discovery and management
Некоторые проверки не удались
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.
Этот коммит содержится в:
root
2026-07-29 21:31:52 +03:00
родитель e987f24903
Коммит ff0d2f088f
19 изменённых файлов: 2115 добавлений и 0 удалений

176
internal/webapp/compose.go Обычный файл
Просмотреть файл

@@ -0,0 +1,176 @@
package webapp
import (
"context"
"log"
"os"
"sync"
"time"
"rocketgit.ru/rsmon/worker/internal/compose"
)
// composeRefreshInterval is the cadence the Compose project snapshot is
// rebuilt. 60s matches the process-inventory loop so the two collectors
// stay in step; management operations are synchronous POSTs that do not
// wait for the next tick.
const composeRefreshInterval = 60 * time.Second
// ComposeRefresher owns the periodic `docker compose` discovery loop and
// exposes the latest snapshot for the operator console. It is the
// Compose counterpart of Inventory: a self-contained refresher that is
// constructed in New and started in Start.
//
// When Docker is unavailable (or compose is disabled) the refresher
// stays idle and reports an empty snapshot with an "unavailable" error,
// so /compose renders a banner instead of a half-built table.
type ComposeRefresher struct {
enabled bool
log *log.Logger
disco composeDiscover
mu sync.RWMutex
snap compose.Snapshot
lastAt time.Time
stopCh chan struct{}
stopWG sync.WaitGroup
started bool
}
// composeDiscover is the subset of the compose package the refresher
// calls. A function field so tests can inject a fixture discovery
// without exec'ing Docker.
type composeDiscover func(ctx context.Context) (*compose.DiscoveryResult, error)
// NewComposeRefresher constructs a refresher. When enabled is false the
// refresher never starts its loop and Snapshot returns an empty result;
// the page still renders (with the disabled notice). The discover hook
// defaults to compose.Discover so production needs no wiring.
func NewComposeRefresher(enabled bool, logger *log.Logger) *ComposeRefresher {
if logger == nil {
logger = log.New(os.Stderr, "webapp-compose: ", log.LstdFlags)
}
return &ComposeRefresher{
enabled: enabled,
log: logger,
disco: compose.Discover,
stopCh: make(chan struct{}),
}
}
// SetDiscovery injects the discovery function. Used by tests; production
// leaves the compose.Discover default set by NewComposeRefresher.
func (c *ComposeRefresher) SetDiscovery(f composeDiscover) {
if f == nil {
c.disco = compose.Discover
return
}
c.disco = f
}
// Enabled reports whether compose management is turned on.
func (c *ComposeRefresher) Enabled() bool { return c != nil && c.enabled }
// Start launches the background refresh loop. The first refresh runs
// immediately so /compose has data on the first request. Idempotent.
func (c *ComposeRefresher) Start(ctx context.Context) {
if c == nil || !c.enabled {
return
}
c.mu.Lock()
if c.started {
c.mu.Unlock()
return
}
c.started = true
c.mu.Unlock()
c.stopWG.Add(1)
go c.loop(ctx)
}
// Stop cancels the refresh loop and waits for it to exit.
func (c *ComposeRefresher) Stop() {
if c == nil {
return
}
c.mu.Lock()
if !c.started {
c.mu.Unlock()
return
}
select {
case <-c.stopCh:
default:
close(c.stopCh)
}
c.mu.Unlock()
c.stopWG.Wait()
}
// Snapshot returns the most recent discovery snapshot. Always safe to
// call; returns a zero-value snapshot (with the unavailable notice when
// disabled) before the first refresh completes.
func (c *ComposeRefresher) Snapshot() compose.Snapshot {
if c == nil {
return compose.Snapshot{Errors: []string{"compose management is disabled"}}
}
c.mu.RLock()
defer c.mu.RUnlock()
if !c.enabled {
return compose.Snapshot{Errors: []string{"compose management is disabled"}}
}
out := c.snap
out.Projects = append([]compose.ProjectSummary(nil), c.snap.Projects...)
return out
}
// LastRefreshAt returns the time of the last successful refresh.
func (c *ComposeRefresher) LastRefreshAt() time.Time {
c.mu.RLock()
defer c.mu.RUnlock()
return c.lastAt
}
// Refs resolves the Compose CLI arguments for a project from the current
// snapshot. Returns ok=false when the project is unknown or its working
// directory was not discovered (Compose cannot be driven without it).
func (c *ComposeRefresher) Refs(project string) (compose.ProjectRefs, bool) {
p := c.Snapshot().FindProject(project)
if p == nil || p.WorkingDir == "" {
return compose.ProjectRefs{}, false
}
return compose.ProjectRefs{Name: p.Name, WorkingDir: p.WorkingDir, ConfigFiles: p.ConfigFiles}, true
}
func (c *ComposeRefresher) loop(ctx context.Context) {
defer c.stopWG.Done()
c.refresh(ctx)
t := time.NewTicker(composeRefreshInterval)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-c.stopCh:
return
case <-t.C:
c.refresh(ctx)
}
}
}
func (c *ComposeRefresher) refresh(ctx context.Context) {
res, err := c.disco(ctx)
if err != nil || res == nil {
if err != nil {
c.log.Printf("compose refresh: %v", err)
}
return
}
snap := compose.Summarize(res)
c.mu.Lock()
c.snap = snap
c.lastAt = time.Now().UTC()
c.mu.Unlock()
}

Просмотреть файл

@@ -30,6 +30,7 @@ const (
envWorkerLogin = "WORKER_LOGIN"
envWorkerPassword = "WORKER_PASSWORD"
envClusterEnabled = "WORKER_CLUSTER_ENABLED"
envComposeEnabled = "WORKER_COMPOSE_ENABLED"
envReleaseURL = "WORKER_RELEASE_URL"
)

280
internal/webapp/handlers_compose.go Обычный файл
Просмотреть файл

@@ -0,0 +1,280 @@
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
}

417
internal/webapp/handlers_compose_test.go Обычный файл
Просмотреть файл

@@ -0,0 +1,417 @@
package webapp
import (
"context"
"encoding/json"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"rocketgit.ru/rsmon/worker/internal/compose"
)
// newComposeTestServer builds a session-auth server with the Compose
// subsystem enabled. The default newTestServer leaves ComposeEnabled
// false (zero-value Config), so the handlers would short-circuit to the
// disabled banner; flipping the refresher flag on exercises the real
// code paths without starting the background loop.
func newComposeTestServer(t *testing.T, runner WorkerView) *Server {
t.Helper()
srv := newTestServer(t, runner)
srv.compose.enabled = true
return srv
}
// newComposeTestServerBasicAuth is the basic-auth variant used by the
// /web/api/compose/* tests: those routes accept HTTP basic credentials
// in lieu of a session cookie.
func newComposeTestServerBasicAuth(t *testing.T, runner WorkerView, login, password string) *Server {
t.Helper()
srv := newTestServerWithBasicAuth(t, runner, login, password)
srv.compose.enabled = true
return srv
}
// injectComposeSnapshot seeds the refresher with a single "rsmon"
// project so the list/detail/logs handlers have something to render
// without exec'ing Docker. The project's WorkingDir is a real temp dir
// so management operations (which chdir into it) succeed under a stub
// Docker binary.
func injectComposeSnapshot(t *testing.T, srv *Server, workDir string) {
t.Helper()
res := &compose.DiscoveryResult{
Projects: map[string]compose.Project{
"rsmon": {
Name: "rsmon",
Status: "running(1)",
WorkingDir: workDir,
ConfigFiles: filepath.Join(workDir, "docker-compose.yml"),
Services: map[string]compose.Service{
"web": {Name: "web", Containers: []compose.Container{{
ID: "c1",
Name: "rsmon-web-1",
Image: "nginx:latest",
State: "running",
Status: "Up 5 minutes",
Health: "healthy",
PID: 4242,
Ports: []compose.PortBinding{{HostIP: "0.0.0.0", HostPort: "8080"}},
Mounts: []compose.Mount{{Source: filepath.Join(workDir, "data"), Destination: "/data", Type: "bind"}},
Labels: map[string]string{
compose.LabelComposeProject: "rsmon",
compose.LabelComposeService: "web",
},
}}},
},
},
},
}
snap := compose.Summarize(res)
srv.compose.mu.Lock()
srv.compose.snap = snap
srv.compose.lastAt = time.Now().UTC()
srv.compose.mu.Unlock()
}
// stubDockerOK points the compose package at an executable stub that
// echoes its arguments and exits 0, so management/logs handlers run the
// full exec path without a real Docker daemon. Restored on cleanup.
func stubDockerOK(t *testing.T) {
t.Helper()
dir := t.TempDir()
p := filepath.Join(dir, "stub-docker")
script := "#!/bin/sh\necho \"stub: $*\"\nexit 0\n"
require.NoError(t, os.WriteFile(p, []byte(script), 0o755))
compose.SetDockerBin(p)
t.Cleanup(func() { compose.SetDockerBin("") })
}
// stubDockerFail points at a stub that exits non-zero with a stderr
// message, so the !OK management path is observable.
func stubDockerFail(t *testing.T) {
t.Helper()
dir := t.TempDir()
p := filepath.Join(dir, "stub-docker")
script := "#!/bin/sh\necho \"compose boom\" >&2\nexit 1\n"
require.NoError(t, os.WriteFile(p, []byte(script), 0o755))
compose.SetDockerBin(p)
t.Cleanup(func() { compose.SetDockerBin("") })
}
// auditHasAction reports whether the audit log contains a row matching
// action and a target substring (e.g. "compose_restart" / "rsmon").
func auditHasAction(t *testing.T, srv *Server, action, targetSub string) bool {
t.Helper()
rows, err := srv.store.RecentAudit(context.Background(), 10)
require.NoError(t, err)
for _, r := range rows {
if r.Action == action && strings.Contains(r.Target, targetSub) {
return true
}
}
return false
}
func TestComposeList_RendersProjects(t *testing.T) {
srv := newComposeTestServer(t, &stubRunner{id: "w-1"})
injectComposeSnapshot(t, srv, t.TempDir())
ts := newHTTPTestServer(t, srv)
c, _ := loginAsFirstRun(t, ts.URL, srv)
resp, err := c.Get(ts.URL + "/compose")
require.NoError(t, err)
body := mustBody(t, resp)
assert.Contains(t, body, "Docker Compose projects")
assert.Contains(t, body, "rsmon")
assert.Contains(t, body, "details") // link to the detail page
}
func TestComposeList_DisabledBanner(t *testing.T) {
// Default server leaves Compose disabled.
srv := newTestServer(t, &stubRunner{id: "w-1"})
ts := newHTTPTestServer(t, srv)
c, _ := loginAsFirstRun(t, ts.URL, srv)
resp, err := c.Get(ts.URL + "/compose")
require.NoError(t, err)
body := mustBody(t, resp)
assert.Contains(t, body, "Compose management is disabled")
}
func TestComposeDetail_FoundAndNotFound(t *testing.T) {
srv := newComposeTestServer(t, &stubRunner{id: "w-1"})
injectComposeSnapshot(t, srv, t.TempDir())
ts := newHTTPTestServer(t, srv)
c, _ := loginAsFirstRun(t, ts.URL, srv)
resp, err := c.Get(ts.URL + "/compose/rsmon")
require.NoError(t, err)
body := mustBody(t, resp)
assert.Contains(t, body, "Project actions")
assert.Contains(t, body, "rsmon-web-1")
resp2, err := c.Get(ts.URL + "/compose/missing")
require.NoError(t, err)
defer resp2.Body.Close() //nolint:errcheck
assert.Equal(t, http.StatusNotFound, resp2.StatusCode)
}
func TestComposeAPIList_JSON(t *testing.T) {
srv := newComposeTestServerBasicAuth(t, &stubRunner{id: "w-1"}, "alice", "s3cret")
injectComposeSnapshot(t, srv, t.TempDir())
ts := newHTTPTestServer(t, srv)
req, _ := http.NewRequest(http.MethodGet, ts.URL+"/web/api/compose", nil)
req.SetBasicAuth("alice", "s3cret")
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer resp.Body.Close() //nolint:errcheck
require.Equal(t, http.StatusOK, resp.StatusCode)
var snap compose.Snapshot
require.NoError(t, json.NewDecoder(resp.Body).Decode(&snap))
require.Len(t, snap.Projects, 1)
assert.Equal(t, "rsmon", snap.Projects[0].Name)
}
func TestComposeAPIDetail_JSONAndNotFound(t *testing.T) {
srv := newComposeTestServerBasicAuth(t, &stubRunner{id: "w-1"}, "alice", "s3cret")
injectComposeSnapshot(t, srv, t.TempDir())
ts := newHTTPTestServer(t, srv)
req, _ := http.NewRequest(http.MethodGet, ts.URL+"/web/api/compose/rsmon", nil)
req.SetBasicAuth("alice", "s3cret")
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer resp.Body.Close() //nolint:errcheck
require.Equal(t, http.StatusOK, resp.StatusCode)
var p compose.ProjectSummary
require.NoError(t, json.NewDecoder(resp.Body).Decode(&p))
assert.Equal(t, "rsmon", p.Name)
req2, _ := http.NewRequest(http.MethodGet, ts.URL+"/web/api/compose/missing", nil)
req2.SetBasicAuth("alice", "s3cret")
resp2, err := http.DefaultClient.Do(req2)
require.NoError(t, err)
defer resp2.Body.Close() //nolint:errcheck
assert.Equal(t, http.StatusNotFound, resp2.StatusCode)
}
func TestComposeProjectAction_RequiresCSRF(t *testing.T) {
srv := newComposeTestServer(t, &stubRunner{id: "w-1"})
injectComposeSnapshot(t, srv, t.TempDir())
ts := newHTTPTestServer(t, srv)
c, _ := loginAsFirstRun(t, ts.URL, srv)
form := url.Values{}
form.Set("csrf_token", "") // missing token
req, _ := http.NewRequest(http.MethodPost, ts.URL+"/compose/rsmon/restart", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := c.Do(req)
require.NoError(t, err)
defer resp.Body.Close() //nolint:errcheck
assert.Equal(t, http.StatusForbidden, resp.StatusCode, "POST without CSRF must be 403")
}
func TestComposeProjectAction_HTMLRedirectAndAudit(t *testing.T) {
srv := newComposeTestServer(t, &stubRunner{id: "w-1"})
injectComposeSnapshot(t, srv, t.TempDir())
stubDockerOK(t)
ts := newHTTPTestServer(t, srv)
c, _ := loginAsFirstRun(t, ts.URL, srv)
// Pull the CSRF token out of the detail page's action forms.
detail, err := c.Get(ts.URL + "/compose/rsmon")
require.NoError(t, err)
csrf := extractCSRFToken(t, mustBody(t, detail))
form := url.Values{}
form.Set("csrf_token", csrf)
req, _ := http.NewRequest(http.MethodPost, ts.URL+"/compose/rsmon/restart", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := c.Do(req)
require.NoError(t, err)
defer resp.Body.Close() //nolint:errcheck
assert.Equal(t, http.StatusSeeOther, resp.StatusCode)
loc := resp.Header.Get("Location")
assert.True(t, strings.HasPrefix(loc, "/compose/rsmon?ok=true"), "redirect location=%q", loc)
assert.True(t, auditHasAction(t, srv, "compose_restart", "compose:rsmon"),
"audit row for project restart expected")
}
func TestComposeProjectAction_APIBasicAuthAndAudit(t *testing.T) {
srv := newComposeTestServerBasicAuth(t, &stubRunner{id: "w-1"}, "alice", "s3cret")
injectComposeSnapshot(t, srv, t.TempDir())
stubDockerOK(t)
ts := newHTTPTestServer(t, srv)
req, _ := http.NewRequest(http.MethodPost, ts.URL+"/web/api/compose/rsmon/restart", nil)
req.SetBasicAuth("alice", "s3cret")
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer resp.Body.Close() //nolint:errcheck
require.Equal(t, http.StatusOK, resp.StatusCode)
var res compose.ManagementResult
require.NoError(t, json.NewDecoder(resp.Body).Decode(&res))
assert.True(t, res.OK)
assert.Contains(t, res.Output, "stub:")
assert.True(t, auditHasAction(t, srv, "compose_restart", "compose:rsmon"),
"audit row for API restart expected")
}
func TestComposeProjectAction_FailureReportsNotOK(t *testing.T) {
srv := newComposeTestServerBasicAuth(t, &stubRunner{id: "w-1"}, "alice", "s3cret")
injectComposeSnapshot(t, srv, t.TempDir())
stubDockerFail(t)
ts := newHTTPTestServer(t, srv)
req, _ := http.NewRequest(http.MethodPost, ts.URL+"/web/api/compose/rsmon/stop", nil)
req.SetBasicAuth("alice", "s3cret")
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer resp.Body.Close() //nolint:errcheck
require.Equal(t, http.StatusOK, resp.StatusCode)
var res compose.ManagementResult
require.NoError(t, json.NewDecoder(resp.Body).Decode(&res))
assert.False(t, res.OK)
assert.Contains(t, res.Output, "compose boom")
}
func TestComposeProjectAction_UnknownActionRejected(t *testing.T) {
srv := newComposeTestServer(t, &stubRunner{id: "w-1"})
injectComposeSnapshot(t, srv, t.TempDir())
ts := newHTTPTestServer(t, srv)
c, _ := loginAsFirstRun(t, ts.URL, srv)
for _, action := range []string{"bogus", "logs"} { // logs is GET-only
req, _ := http.NewRequest(http.MethodPost, ts.URL+"/compose/rsmon/"+action, strings.NewReader(""))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := c.Do(req)
require.NoError(t, err)
assert.Equal(t, http.StatusNotFound, resp.StatusCode, "action %q should 404", action)
resp.Body.Close() //nolint:errcheck
}
}
func TestComposeProjectAction_DisabledReturns503(t *testing.T) {
srv := newTestServer(t, &stubRunner{id: "w-1"}) // compose disabled
injectComposeSnapshot(t, srv, t.TempDir())
ts := newHTTPTestServer(t, srv)
c, _ := loginAsFirstRun(t, ts.URL, srv)
req, _ := http.NewRequest(http.MethodPost, ts.URL+"/compose/rsmon/restart", strings.NewReader(""))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := c.Do(req)
require.NoError(t, err)
defer resp.Body.Close() //nolint:errcheck
assert.Equal(t, http.StatusServiceUnavailable, resp.StatusCode)
}
func TestComposeServiceAction_HTMLRedirectAndAudit(t *testing.T) {
srv := newComposeTestServer(t, &stubRunner{id: "w-1"})
injectComposeSnapshot(t, srv, t.TempDir())
stubDockerOK(t)
ts := newHTTPTestServer(t, srv)
c, _ := loginAsFirstRun(t, ts.URL, srv)
detail, err := c.Get(ts.URL + "/compose/rsmon")
require.NoError(t, err)
csrf := extractCSRFToken(t, mustBody(t, detail))
form := url.Values{}
form.Set("csrf_token", csrf)
req, _ := http.NewRequest(http.MethodPost, ts.URL+"/compose/rsmon/service/web/restart", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := c.Do(req)
require.NoError(t, err)
defer resp.Body.Close() //nolint:errcheck
assert.Equal(t, http.StatusSeeOther, resp.StatusCode)
assert.True(t, strings.HasPrefix(resp.Header.Get("Location"), "/compose/rsmon"))
assert.True(t, auditHasAction(t, srv, "compose_restart", "compose:rsmon/web"),
"audit row for service restart expected")
}
func TestComposeServiceAction_UnknownActionRejected(t *testing.T) {
srv := newComposeTestServer(t, &stubRunner{id: "w-1"})
injectComposeSnapshot(t, srv, t.TempDir())
ts := newHTTPTestServer(t, srv)
c, _ := loginAsFirstRun(t, ts.URL, srv)
req, _ := http.NewRequest(http.MethodPost, ts.URL+"/compose/rsmon/service/web/bogus", strings.NewReader(""))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := c.Do(req)
require.NoError(t, err)
defer resp.Body.Close() //nolint:errcheck
assert.Equal(t, http.StatusNotFound, resp.StatusCode)
}
func TestComposeLogs_HTMLPage(t *testing.T) {
srv := newComposeTestServer(t, &stubRunner{id: "w-1"})
injectComposeSnapshot(t, srv, t.TempDir())
stubDockerOK(t)
ts := newHTTPTestServer(t, srv)
c, _ := loginAsFirstRun(t, ts.URL, srv)
resp, err := c.Get(ts.URL + "/compose/rsmon/logs")
require.NoError(t, err)
body := mustBody(t, resp)
assert.Contains(t, body, "Compose logs: rsmon")
assert.Contains(t, body, "stub:")
}
func TestComposeLogs_API_JSONAndNotFound(t *testing.T) {
srv := newComposeTestServerBasicAuth(t, &stubRunner{id: "w-1"}, "alice", "s3cret")
injectComposeSnapshot(t, srv, t.TempDir())
stubDockerOK(t)
ts := newHTTPTestServer(t, srv)
req, _ := http.NewRequest(http.MethodGet, ts.URL+"/web/api/compose/rsmon/logs", nil)
req.SetBasicAuth("alice", "s3cret")
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer resp.Body.Close() //nolint:errcheck
require.Equal(t, http.StatusOK, resp.StatusCode)
var res compose.ManagementResult
require.NoError(t, json.NewDecoder(resp.Body).Decode(&res))
assert.Contains(t, res.Output, "stub:")
req2, _ := http.NewRequest(http.MethodGet, ts.URL+"/web/api/compose/missing/logs", nil)
req2.SetBasicAuth("alice", "s3cret")
resp2, err := http.DefaultClient.Do(req2)
require.NoError(t, err)
defer resp2.Body.Close() //nolint:errcheck
assert.Equal(t, http.StatusNotFound, resp2.StatusCode)
}
func TestValidProjectPostAction(t *testing.T) {
for _, a := range []string{"up", "down", "stop", "restart", "pull"} {
assert.True(t, validProjectPostAction(a), "%q should be POST-able", a)
}
assert.False(t, validProjectPostAction("logs"), "logs is GET-only")
assert.False(t, validProjectPostAction("bogus"), "bogus is invalid")
}
func TestComposeAPIList_RequiresAuth(t *testing.T) {
srv := newComposeTestServerBasicAuth(t, &stubRunner{id: "w-1"}, "alice", "s3cret")
injectComposeSnapshot(t, srv, t.TempDir())
ts := newHTTPTestServer(t, srv)
// No credentials: the basic-auth fast path returns 401.
resp, err := http.Get(ts.URL + "/web/api/compose")
require.NoError(t, err)
defer resp.Body.Close() //nolint:errcheck
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
}

Просмотреть файл

@@ -51,6 +51,23 @@ func (s *Server) routes() {
s.mux.Handle("POST /settings/rotate-token", s.requireSession(s.handleRotateToken))
s.mux.Handle("GET /updates", s.requireSession(s.handleUpdates))
// Docker Compose discovery + management. The list/detail/logs pages
// are session-protected HTML; the lifecycle endpoints accept either a
// session (browser, CSRF-checked in the handler) or HTTP basic auth
// (scripting, on the /web/api/compose/* prefix). Compose is disabled
// per host via WORKER_COMPOSE_ENABLED=false; when off, the management
// handlers return 503 and the list page renders a banner.
s.mux.Handle("GET /compose", s.requireSession(s.handleComposeList))
s.mux.Handle("GET /compose/{project}", s.requireSession(s.handleComposeDetail))
s.mux.Handle("GET /compose/{project}/logs", s.requireSession(s.handleComposeLogsPage))
s.mux.Handle("POST /compose/{project}/{action}", s.requireSession(s.handleComposeProjectAction))
s.mux.Handle("POST /compose/{project}/service/{service}/{action}", s.requireSession(s.handleComposeServiceAction))
s.mux.Handle("GET /web/api/compose", s.requireSession(s.handleComposeAPIList))
s.mux.Handle("GET /web/api/compose/{project}", s.requireSession(s.handleComposeAPIDetail))
s.mux.Handle("GET /web/api/compose/{project}/logs", s.requireSession(s.handleComposeLogsAPI))
s.mux.Handle("POST /web/api/compose/{project}/{action}", s.requireSession(s.handleComposeProjectAction))
s.mux.Handle("POST /web/api/compose/{project}/service/{service}/{action}", s.requireSession(s.handleComposeServiceAction))
// Health endpoint for the cmd health subcommand and for the
// operator to confirm the listener is up without going through the
// login form. Returns 200 with a tiny body.

Просмотреть файл

@@ -87,6 +87,14 @@ type Config struct {
// "tag_name" field (GitHub release JSON is the canonical
// shape). WORKER_RELEASE_URL sets this.
ReleaseURL string
// ComposeEnabled turns the Docker Compose discovery + management
// subsystem on. Defaults to true (WORKER_COMPOSE_ENABLED=false to
// disable): the refresher no-ops when the Docker daemon is absent,
// so leaving it on is safe on non-Docker hosts. When enabled, the
// /compose page lists every Compose project on the host and the
// /web/api/compose/* endpoints drive up/down/stop/restart/pull.
ComposeEnabled bool
}
// ValidateBasicAuth enforces that WORKER_LOGIN and WORKER_PASSWORD
@@ -177,9 +185,23 @@ func ConfigFromEnv(env map[string]string, defaultDataDir string) (Config, error)
cfg.StorePath = v
}
cfg.ReleaseURL = strings.TrimSpace(env[envReleaseURL])
// Compose discovery defaults to on; only an explicit false disables it.
cfg.ComposeEnabled = !parseBoolFalseDefault(env[envComposeEnabled])
return cfg, nil
}
// parseBoolFalseDefault returns true for any value that is not an
// explicit falsy literal. Used by ComposeEnabled so that the default
// (unset env var) keeps the feature on, unlike parseBool where the
// default is false.
func parseBoolFalseDefault(v string) bool {
switch strings.ToLower(strings.TrimSpace(v)) {
case "false", "0", "no", "off", "":
return false
}
return true
}
// parseBool returns true for the strings "true", "1", "yes" (any
// case, trimmed). Anything else is false. Used for opt-in feature
// flags wired through env vars without dragging in a config-package
@@ -201,6 +223,7 @@ func ConfigFromEnvOrDefault() Config {
envWorkerHost, envWorkerPort, envWorkerURL, envWorkerLogin, envWorkerPassword,
"RSMON_WEBAPP_DATA_DIR", "RSMON_WEBAPP_STORE_PATH",
"WORKER_CLUSTER_ENABLED",
envComposeEnabled,
envReleaseURL,
} {
if v := os.Getenv(k); v != "" {
@@ -336,6 +359,7 @@ type Server struct {
templates *Templates
logBuffer *LogBuffer
inventory *Inventory
compose *ComposeRefresher
metrics *Metrics
cluster ClusterView
pruneStop chan struct{}
@@ -402,6 +426,7 @@ func New(cfg Config, deps *Deps) (*Server, error) { //nolint:gocritic // Config
templates: tmpl,
logBuffer: NewLogBuffer(5000),
inventory: NewInventory(store, deps.Logger),
compose: NewComposeRefresher(cfg.ComposeEnabled, deps.Logger),
metrics: NewMetrics(),
cluster: deps.Cluster,
pruneStop: make(chan struct{}),
@@ -460,6 +485,10 @@ func (s *Server) SetCluster(c ClusterView) { s.cluster = c }
// Cluster returns the attached cluster subsystem (or nil).
func (s *Server) Cluster() ClusterView { return s.cluster }
// Compose returns the Compose discovery/management refresher so tests
// and the cmd binary can drive it (e.g. inject a fixture discovery).
func (s *Server) Compose() *ComposeRefresher { return s.compose }
// Close shuts down the HTTP listener, the prune goroutine, and the
// embedded store. Safe to call multiple times.
func (s *Server) Close(ctx context.Context) error {
@@ -472,6 +501,9 @@ func (s *Server) Close(ctx context.Context) error {
close(s.pruneStop)
}
s.pruneWG.Wait()
if s.compose != nil {
s.compose.Stop()
}
if s.httpServer != nil {
shutdownCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
@@ -493,6 +525,12 @@ func (s *Server) Start(ctx context.Context) error {
s.pruneWG.Add(1)
go s.pruneLoop(ctx)
// Start the Compose discovery loop alongside the listener. It no-ops
// when disabled or when Docker is absent, so it is always safe to
// start. The first refresh runs immediately so /compose has data on
// the first request.
s.compose.Start(ctx)
// Run ListenAndServe in a goroutine so we can race it against ctx.
errCh := make(chan error, 1)
go func() {

44
internal/webapp/templates/compose.html Обычный файл
Просмотреть файл

@@ -0,0 +1,44 @@
{{define "body"}}<section class="card">
<h1>Docker Compose projects</h1>
{{if not .Enabled}}
<p class="muted">Compose management is disabled (WORKER_COMPOSE_ENABLED=false). Re-enable it and restart the worker to see projects here.</p>
{{else}}
<p class="muted">Discovered from <code>docker compose ls</code> and <code>docker ps</code> labels, refreshed every 60s. Lifecycle actions (up/down/stop/restart/pull) run <code>docker compose</code> in each project's working directory.</p>
{{if .LastAt.IsZero}}
<p class="muted">No refresh yet — the first scan runs within 60s of start.</p>
{{else}}
<p class="muted">Last refresh: {{fmtTime .LastAt}}</p>
{{end}}
{{range .Snapshot.Errors}}
<p class="muted">⚠ {{.}}</p>
{{end}}
<table>
<thead>
<tr>
<th>Project</th>
<th>Status</th>
<th>Services</th>
<th>Containers</th>
<th>Running</th>
<th>Compose file</th>
<th></th>
</tr>
</thead>
<tbody>
{{range .Snapshot.Projects}}
<tr>
<td>{{.Name}}</td>
<td>{{.Status}}</td>
<td>{{.ServiceCount}}</td>
<td>{{.ContainerCount}}</td>
<td>{{.RunningCount}}</td>
<td>{{.ConfigFiles}}</td>
<td><a href="/compose/{{.Name}}">details</a></td>
</tr>
{{else}}
<tr><td colspan="7" class="muted">No Compose projects discovered.</td></tr>
{{end}}
</tbody>
</table>
{{end}}
</section>{{end}}

Просмотреть файл

@@ -0,0 +1,81 @@
{{define "body"}}<section class="card">
<h1>{{.Project.Name}}</h1>
<p class="muted">Working dir: <code>{{.Project.WorkingDir}}</code>{{if .Project.ConfigFiles}} · compose file: <code>{{.Project.ConfigFiles}}</code>{{end}} · last refresh: {{fmtTime .LastAt}}</p>
{{if .ActionMessage}}
<p class="{{if .ActionOK}}muted{{else}}muted{{end}}">{{if .ActionOK}}✓{{else}}⚠{{end}} {{.ActionMessage}}</p>
{{end}}
<h2>Project actions</h2>
<div class="cards">
<form action="/compose/{{.Project.Name}}/up" method="post"><input type="hidden" name="csrf_token" value="{{.CSRFToken}}"><button type="submit">Up</button></form>
<form action="/compose/{{.Project.Name}}/restart" method="post"><input type="hidden" name="csrf_token" value="{{.CSRFToken}}"><button type="submit">Restart</button></form>
<form action="/compose/{{.Project.Name}}/pull" method="post"><input type="hidden" name="csrf_token" value="{{.CSRFToken}}"><button type="submit">Pull</button></form>
<form action="/compose/{{.Project.Name}}/stop" method="post"><input type="hidden" name="csrf_token" value="{{.CSRFToken}}"><button type="submit">Stop</button></form>
<form action="/compose/{{.Project.Name}}/down" method="post"><input type="hidden" name="csrf_token" value="{{.CSRFToken}}"><button type="submit">Down</button></form>
<a href="/compose/{{.Project.Name}}/logs"><button type="button">Logs</button></a>
</div>
<h2>Services ({{.Project.ServiceCount}})</h2>
<table>
<thead><tr><th>Service</th><th>Container</th><th>Image</th><th>State</th><th>Status</th><th>Health</th><th>PID</th><th>Ports</th><th>Actions</th></tr></thead>
<tbody>
{{$root := .}}
{{range .Project.Services}}
<tr>
<td>{{.Name}}</td>
<td>{{range .Containers}}{{.Name}}<br>{{end}}</td>
<td>{{range .Containers}}{{.Image}}<br>{{end}}</td>
<td>{{range .Containers}}{{.State}}<br>{{end}}</td>
<td>{{range .Containers}}{{.Status}}<br>{{end}}</td>
<td>{{range .Containers}}{{.Health}}<br>{{end}}</td>
<td>{{range .Containers}}{{.PID}}<br>{{end}}</td>
<td>{{range .Containers}}{{range .Ports}}{{.HostPort}}<br>{{end}}{{end}}</td>
<td>
<form action="/compose/{{$root.Project.Name}}/service/{{.Name}}/up" method="post" style="display:inline"><input type="hidden" name="csrf_token" value="{{$root.CSRFToken}}"><button type="submit">up</button></form>
<form action="/compose/{{$root.Project.Name}}/service/{{.Name}}/restart" method="post" style="display:inline"><input type="hidden" name="csrf_token" value="{{$root.CSRFToken}}"><button type="submit">restart</button></form>
<form action="/compose/{{$root.Project.Name}}/service/{{.Name}}/stop" method="post" style="display:inline"><input type="hidden" name="csrf_token" value="{{$root.CSRFToken}}"><button type="submit">stop</button></form>
</td>
</tr>
{{else}}
<tr><td colspan="9" class="muted">No services discovered for this project.</td></tr>
{{end}}
</tbody>
</table>
{{if .Project.GroupedMounts}}
<h2>Mounts</h2>
<table>
<thead><tr><th>Source</th><th>Destination</th><th>Type</th><th>Used by</th></tr></thead>
<tbody>
{{range .Project.GroupedMounts}}
<tr>
<td>{{.Source}}</td>
<td>{{.Destination}}</td>
<td>{{.Type}}</td>
<td>{{.SharedMountSummary}}</td>
</tr>
{{end}}
</tbody>
</table>
{{end}}
{{if .Project.AllTraefikRoutes}}
<h2>Traefik routes</h2>
<table>
<thead><tr><th>Router</th><th>Service</th><th>Container</th><th>Hostnames</th><th>Prefixes</th><th>Rule</th></tr></thead>
<tbody>
{{range .Project.AllTraefikRoutes}}
<tr>
<td>{{.RouterName}}</td>
<td>{{.Service}}</td>
<td>{{.Container}}</td>
<td>{{range .Hostnames}}{{.}}<br>{{end}}</td>
<td>{{range .PathPrefixes}}{{.}}<br>{{end}}</td>
<td><code>{{.Rule}}</code></td>
</tr>
{{end}}
</tbody>
</table>
{{end}}
</section>{{end}}

Просмотреть файл

@@ -0,0 +1,6 @@
{{define "body"}}<section class="card">
<h1>Compose logs: {{.Project}}</h1>
<p class="muted">Last 300 lines from <code>docker compose logs --no-color --tail 300</code>. Streaming tails are not supported in the web UI yet.</p>
<pre class="logs">{{if .Output}}{{.Output}}{{else}}<span class="muted">(no output)</span>{{end}}</pre>
<p><a href="/compose/{{.Project}}">← back to {{.Project}}</a></p>
</section>{{end}}

Просмотреть файл

@@ -14,6 +14,7 @@
<nav>
<a href="/overview">Overview</a>
<a href="/apps">Apps</a>
<a href="/compose">Compose</a>
<a href="/checks">Checks</a>
<a href="/notifications">Notifications</a>
<a href="/logs">Logs</a>