feat(worker): add Docker Compose discovery and management
Некоторые проверки не удались
CI / test (push) Failing after 6s
Docker / Build and publish worker image (push) Failing after 8s
Некоторые проверки не удались
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.
Этот коммит содержится в:
176
internal/webapp/compose.go
Обычный файл
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
Обычный файл
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
Обычный файл
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
Обычный файл
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}}
|
||||
81
internal/webapp/templates/compose_detail.html
Обычный файл
81
internal/webapp/templates/compose_detail.html
Обычный файл
@@ -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}}
|
||||
6
internal/webapp/templates/compose_logs.html
Обычный файл
6
internal/webapp/templates/compose_logs.html
Обычный файл
@@ -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>
|
||||
|
||||
Ссылка в новой задаче
Block a user