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