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

200 строки
6.3 KiB
Go

package compose
import (
"fmt"
"sort"
"strings"
)
// ProjectSummary is the operator-facing view of one Compose project:
// counts, per-service containers, grouped mounts, and Traefik routes.
// It is the shape rendered on /compose and returned by the JSON API.
type ProjectSummary struct {
Name string `json:"name"`
Status string `json:"status"`
ConfigFiles string `json:"config_files"`
WorkingDir string `json:"working_dir"`
ServiceCount int `json:"service_count"`
ContainerCount int `json:"container_count"`
RunningCount int `json:"running_count"`
Services []ServiceSummary `json:"services"`
GroupedMounts []ProjectMountInfo `json:"grouped_mounts"`
AllTraefikRoutes []ProjectTraefikRoute `json:"traefik_routes"`
}
// ServiceSummary is a condensed view of a Compose service.
type ServiceSummary struct {
Name string `json:"name"`
Containers []ContainerSummary `json:"containers"`
}
// ContainerSummary holds the key container fields the detail page shows.
type ContainerSummary struct {
Name string `json:"name"`
ID string `json:"id"`
Image string `json:"image"`
State string `json:"state"`
Status string `json:"status"`
Health string `json:"health"`
PID int `json:"pid"`
Ports []PortBinding `json:"ports"`
}
// ProjectMountInfo represents a mount that may be shared across the
// services/containers of a project.
type ProjectMountInfo struct {
Source string `json:"source"`
Destination string `json:"destination"`
Type string `json:"type"`
UsedBy []MountUsage `json:"used_by"`
}
// MountUsage identifies which service/container uses a mount.
type MountUsage struct {
Service string `json:"service"`
Container string `json:"container"`
}
// ProjectTraefikRoute is a Traefik route attributed to the project.
type ProjectTraefikRoute struct {
RouterName string `json:"router_name"`
Service string `json:"service"`
Container string `json:"container"`
Hostnames []string `json:"hostnames"`
PathPrefixes []string `json:"path_prefixes"`
Paths []string `json:"paths"`
Rule string `json:"rule"`
}
// BuildProjectSummary creates the operator-facing summary from a raw
// Project. Services, mounts, and routes are sorted for stable display.
func BuildProjectSummary(p Project) ProjectSummary {
s := ProjectSummary{
Name: p.Name,
Status: p.Status,
ConfigFiles: p.ConfigFiles,
WorkingDir: p.WorkingDir,
Services: make([]ServiceSummary, 0, len(p.Services)),
GroupedMounts: make([]ProjectMountInfo, 0),
AllTraefikRoutes: make([]ProjectTraefikRoute, 0),
}
mountGroups := map[string]*ProjectMountInfo{}
for svcName, svc := range p.Services {
s.ServiceCount++
svcSummary := ServiceSummary{Name: svcName, Containers: make([]ContainerSummary, 0, len(svc.Containers))}
for _, c := range svc.Containers {
s.ContainerCount++
if c.State == "running" {
s.RunningCount++
}
svcSummary.Containers = append(svcSummary.Containers, ContainerSummary{
Name: c.Name,
ID: c.ID,
Image: c.Image,
State: c.State,
Status: c.Status,
Health: c.Health,
PID: c.PID,
Ports: c.Ports,
})
groupMount(mountGroups, svcName, c)
for _, r := range ExtractTraefikRoutes(c) {
s.AllTraefikRoutes = append(s.AllTraefikRoutes, ProjectTraefikRoute{
RouterName: r.RouterName,
Service: svcName,
Container: c.Name,
Hostnames: r.Hostnames,
PathPrefixes: r.PathPrefixes,
Paths: r.Paths,
Rule: r.Rule,
})
}
}
s.Services = append(s.Services, svcSummary)
}
for _, mi := range mountGroups {
s.GroupedMounts = append(s.GroupedMounts, *mi)
}
sort.Slice(s.Services, func(i, j int) bool { return s.Services[i].Name < s.Services[j].Name })
sort.Slice(s.GroupedMounts, func(i, j int) bool { return s.GroupedMounts[i].Source < s.GroupedMounts[j].Source })
sort.Slice(s.AllTraefikRoutes, func(i, j int) bool {
return s.AllTraefikRoutes[i].RouterName < s.AllTraefikRoutes[j].RouterName
})
return s
}
func groupMount(groups map[string]*ProjectMountInfo, svc string, c Container) {
for _, m := range c.Mounts {
key := fmt.Sprintf("%s|%s|%s", m.Source, m.Destination, m.Type)
usage := MountUsage{Service: svc, Container: c.Name}
if existing := groups[key]; existing != nil {
for _, u := range existing.UsedBy {
if u == usage {
goto next
}
}
existing.UsedBy = append(existing.UsedBy, usage)
next:
continue
}
groups[key] = &ProjectMountInfo{
Source: m.Source, Destination: m.Destination, Type: m.Type, UsedBy: []MountUsage{usage},
}
}
}
// BuildAllProjectSummaries returns every project summary sorted by name.
func BuildAllProjectSummaries(res *DiscoveryResult) []ProjectSummary {
out := make([]ProjectSummary, 0, len(res.Projects))
for _, p := range res.Projects {
out = append(out, BuildProjectSummary(p))
}
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return out
}
// FormatMountSourceShort shortens a long mount source for table display.
func FormatMountSourceShort(source string, maxLen int) string {
if len(source) <= maxLen || maxLen < 10 {
return source
}
keep := (maxLen - 3) / 2
return source[:keep] + "..." + source[len(source)-keep:]
}
// SharedMountSummary returns a short label describing who uses a mount.
func (m *ProjectMountInfo) SharedMountSummary() string {
if len(m.UsedBy) == 0 {
return "unused"
}
if len(m.UsedBy) == 1 {
return fmt.Sprintf("%s/%s", m.UsedBy[0].Service, m.UsedBy[0].Container)
}
counts := map[string]int{}
for _, u := range m.UsedBy {
counts[u.Service]++
}
var parts []string
for svc, n := range counts {
if n == 1 {
parts = append(parts, svc)
} else {
parts = append(parts, fmt.Sprintf("%s(%d)", svc, n))
}
}
sort.Strings(parts)
return "shared: " + strings.Join(parts, ", ")
}
// FindProject returns the summary for the named project, or nil.
func (s Snapshot) FindProject(name string) *ProjectSummary {
for i := range s.Projects {
if s.Projects[i].Name == name {
return &s.Projects[i]
}
}
return nil
}