Files
worker/internal/compose/discover_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

145 строки
4.1 KiB
Go

package compose
import (
"context"
"testing"
)
func TestParseLabels(t *testing.T) {
got := parseLabels("com.docker.compose.project=rsmon,com.docker.compose.service=web,foo=a=b")
if got[LabelComposeProject] != "rsmon" {
t.Fatalf("project = %q", got[LabelComposeProject])
}
if got[LabelComposeService] != "web" {
t.Fatalf("service = %q", got[LabelComposeService])
}
if got["foo"] != "a=b" { // value may contain '='
t.Fatalf("foo = %q", got["foo"])
}
if len(parseLabels("")) != 0 {
t.Fatal("empty labels should yield empty map")
}
}
func TestGroupContainers(t *testing.T) {
res := &DiscoveryResult{Projects: map[string]Project{
"rsmon": {Name: "rsmon", Status: "running(2)", ConfigFiles: "/compose/rsmon/docker-compose.yml", Services: map[string]Service{}},
}}
containers := []Container{
{
ID: "a",
Name: "rsmon-web",
Image: "nginx",
State: "running",
Labels: map[string]string{LabelComposeProject: "rsmon", LabelComposeService: "web", LabelComposeWorkingDir: "/compose/rsmon", LabelComposeConfigFiles: "/compose/rsmon/docker-compose.yml"},
},
{
ID: "b",
Name: "rsmon-backend",
Image: "rsmon",
State: "running",
Labels: map[string]string{LabelComposeProject: "rsmon", LabelComposeService: "backend"},
},
{
ID: "c",
Name: "loose",
State: "running",
Labels: map[string]string{}, // not a compose container
},
}
groupContainers(res, containers)
proj, ok := res.Projects["rsmon"]
if !ok {
t.Fatal("rsmon project missing")
}
if proj.WorkingDir != "/compose/rsmon" {
t.Fatalf("working dir = %q", proj.WorkingDir)
}
if len(proj.Services) != 2 {
t.Fatalf("service count = %d", len(proj.Services))
}
if got := proj.Services["web"].Containers[0].Service; got != "web" {
t.Fatalf("web service tag = %q", got)
}
if _, ok := res.Projects["loose"]; ok {
t.Fatal("non-compose container should not create a project")
}
}
func TestParseTraefikLabels(t *testing.T) {
routes := ParseTraefikLabels(map[string]string{
"traefik.http.routers.rsmon.rule": "Host(`rsmon.rscx.ru`)",
"traefik.http.routers.rsmon-api.rule": "PathPrefix(`/api`)",
"traefik.http.routers.rsmon.service": "rsmon", // non-rule label, ignored
"traefik.http.middlewares.redirect.rule": "not a router", // wrong prefix path
})
if len(routes) != 2 {
t.Fatalf("route count = %d", len(routes))
}
var hosts, prefixes []string
for _, r := range routes {
hosts = append(hosts, r.Hostnames...)
prefixes = append(prefixes, r.PathPrefixes...)
}
if !contains(hosts, "rsmon.rscx.ru") {
t.Fatalf("hosts = %v", hosts)
}
if !contains(prefixes, "/api") {
t.Fatalf("prefixes = %v", prefixes)
}
}
func contains(s []string, v string) bool {
for _, x := range s {
if x == v {
return true
}
}
return false
}
func TestBuildProjectSummary(t *testing.T) {
res := &DiscoveryResult{Projects: map[string]Project{
"p": {
Name: "p",
Services: map[string]Service{
"web": {Name: "web", Containers: []Container{
{Name: "web-1", State: "running", Image: "nginx", Ports: []PortBinding{{HostPort: "80"}}},
}},
"db": {Name: "db", Containers: []Container{
{Name: "db-1", State: "exited", Image: "postgres"},
}},
},
},
}}
snap := Summarize(res)
if len(snap.Projects) != 1 {
t.Fatalf("project count = %d", len(snap.Projects))
}
p := snap.Projects[0]
if p.ContainerCount != 2 || p.RunningCount != 1 || p.ServiceCount != 2 {
t.Fatalf("counts = svc=%d c=%d run=%d", p.ServiceCount, p.ContainerCount, p.RunningCount)
}
if p.Services[0].Name != "db" { // sorted alphabetically
t.Fatalf("first service = %q", p.Services[0].Name)
}
}
func TestValidProjectAction(t *testing.T) {
for _, a := range []string{"up", "down", "stop", "restart", "pull"} {
if _, ok := ValidProjectAction(a); !ok {
t.Errorf("%q should be valid", a)
}
}
if _, ok := ValidProjectAction("rm"); ok {
t.Error("rm should be rejected")
}
}
func TestManageProjectRejectsUnknownAction(t *testing.T) {
if _, err := ManageProject(context.Background(), ProjectRefs{Name: "x", WorkingDir: "/tmp"}, "bogus"); err == nil {
t.Fatal("expected error for unknown action")
}
}