Некоторые проверки не удались
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.
418 строки
15 KiB
Go
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)
|
|
}
|