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.
Этот коммит содержится в:
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)
|
||||
}
|
||||
Ссылка в новой задаче
Block a user