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 удалений

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

@@ -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() {