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