Files
worker/internal/webapp/handlers_compose_test.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

418 строки
15 KiB
Go

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