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.
Этот коммит содержится в:
144
internal/compose/discover_test.go
Обычный файл
144
internal/compose/discover_test.go
Обычный файл
@@ -0,0 +1,144 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
Ссылка в новой задаче
Block a user