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

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

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

177 строки
4.7 KiB
Go

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()
}