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.
Этот коммит содержится в:
1
Makefile
1
Makefile
@@ -16,6 +16,7 @@ test:
|
|||||||
./internal/distworker \
|
./internal/distworker \
|
||||||
./internal/installer \
|
./internal/installer \
|
||||||
./internal/webapp \
|
./internal/webapp \
|
||||||
|
./internal/compose \
|
||||||
./internal/workercluster \
|
./internal/workercluster \
|
||||||
./internal/wire \
|
./internal/wire \
|
||||||
./internal/checkexec \
|
./internal/checkexec \
|
||||||
|
|||||||
185
internal/compose/discover.go
Обычный файл
185
internal/compose/discover.go
Обычный файл
@@ -0,0 +1,185 @@
|
|||||||
|
package compose
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// psItem is one NDJSON object from `docker ps --format json`. Only the
|
||||||
|
// fields discovery consumes are decoded; the rest are ignored. Field
|
||||||
|
// names match Docker's template JSON exactly (ID, Names, Image, State,
|
||||||
|
// Status, HealthStatus, Labels, Ports).
|
||||||
|
type psItem struct {
|
||||||
|
ID string `json:"ID"`
|
||||||
|
Names string `json:"Names"`
|
||||||
|
Image string `json:"Image"`
|
||||||
|
State string `json:"State"`
|
||||||
|
Status string `json:"Status"`
|
||||||
|
HealthStatus string `json:"HealthStatus"`
|
||||||
|
Labels string `json:"Labels"`
|
||||||
|
Ports string `json:"Ports"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Discover collects the current Compose project snapshot. It is the
|
||||||
|
// single entry point for the refresh loop and the operator console.
|
||||||
|
//
|
||||||
|
// The flow is:
|
||||||
|
// 1. `docker compose ls --all --format json` seeds the project map so
|
||||||
|
// exited/stopped projects (which have no running containers) still
|
||||||
|
// appear with their status and compose file.
|
||||||
|
// 2. `docker ps --format json` lists every running container; those
|
||||||
|
// carrying `com.docker.compose.project` are inspected and grouped.
|
||||||
|
// 3. Project-level metadata from container labels refines (and usually
|
||||||
|
// matches) the `compose ls` data.
|
||||||
|
//
|
||||||
|
// Docker being unavailable is reported as a non-empty Errors slice with
|
||||||
|
// an empty Projects map, so the caller renders a banner rather than a
|
||||||
|
// blank page. Per-container inspect failures are skipped silently (the
|
||||||
|
// container may have been removed mid-scan) and never abort the scan.
|
||||||
|
func Discover(ctx context.Context) (*DiscoveryResult, error) {
|
||||||
|
res := &DiscoveryResult{Projects: map[string]Project{}}
|
||||||
|
if !Available(ctx) {
|
||||||
|
res.Errors = append(res.Errors, "docker daemon is not available")
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
ls := composeLS(ctx)
|
||||||
|
for name, item := range ls {
|
||||||
|
res.Projects[name] = Project{
|
||||||
|
Name: name,
|
||||||
|
Status: item.Status,
|
||||||
|
ConfigFiles: item.ConfigFiles,
|
||||||
|
Services: map[string]Service{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
items, err := psItems(ctx)
|
||||||
|
if err != nil {
|
||||||
|
res.Errors = append(res.Errors, err.Error())
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
containers, err := enrichContainers(ctx, items)
|
||||||
|
if err != nil {
|
||||||
|
// enrichContainers never returns a hard error today; keep the
|
||||||
|
// signature so future enrichment steps can report partial loss.
|
||||||
|
res.Errors = append(res.Errors, err.Error())
|
||||||
|
}
|
||||||
|
groupContainers(res, containers)
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// psItems runs `docker ps --format json` and decodes the NDJSON stream.
|
||||||
|
// Each line is one container; malformed lines are skipped.
|
||||||
|
func psItems(ctx context.Context) ([]psItem, error) {
|
||||||
|
out, err := runDocker(ctx, "ps", "--format", "json")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("docker ps: %w", err)
|
||||||
|
}
|
||||||
|
var items []psItem
|
||||||
|
for _, line := range strings.Split(strings.TrimSpace(out), "\n") {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var it psItem
|
||||||
|
if err := json.Unmarshal([]byte(line), &it); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
items = append(items, it)
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// enrichContainers maps `docker ps` rows into enriched Container values.
|
||||||
|
// Containers without a Compose project label are skipped. Each retained
|
||||||
|
// container is `docker inspect`-ed for its host PID, mounts, published
|
||||||
|
// ports, and full label set; inspect failures downgrade to the `ps` data
|
||||||
|
// so a transient inspect error never drops a known container.
|
||||||
|
func enrichContainers(ctx context.Context, items []psItem) ([]Container, error) {
|
||||||
|
containers := make([]Container, 0, len(items))
|
||||||
|
for _, it := range items {
|
||||||
|
labels := parseLabels(it.Labels)
|
||||||
|
if labels[LabelComposeProject] == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
c := Container{
|
||||||
|
ID: it.ID,
|
||||||
|
Name: strings.TrimPrefix(it.Names, "/"),
|
||||||
|
Image: it.Image,
|
||||||
|
State: it.State,
|
||||||
|
Status: it.Status,
|
||||||
|
Health: it.HealthStatus,
|
||||||
|
Labels: labels,
|
||||||
|
}
|
||||||
|
if data, err := inspectContainer(ctx, it.ID); err == nil && data != nil {
|
||||||
|
c.PID = data.State.Pid
|
||||||
|
c.Ports = extractPorts(data)
|
||||||
|
c.Mounts = extractMounts(data)
|
||||||
|
for k, v := range data.Config.Labels {
|
||||||
|
c.Labels[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
containers = append(containers, c)
|
||||||
|
}
|
||||||
|
return containers, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// groupContainers folds enriched containers into the project map. The
|
||||||
|
// project and service come from the Compose labels; the working dir and
|
||||||
|
// config file paths are lifted from any container in the project (they
|
||||||
|
// are identical across containers of one project) and override the
|
||||||
|
// `compose ls` values when present, since labels carry the exact paths
|
||||||
|
// the project was deployed with.
|
||||||
|
//
|
||||||
|
// Pure (no I/O) so it can be unit-tested with fixture containers.
|
||||||
|
func groupContainers(res *DiscoveryResult, containers []Container) {
|
||||||
|
for _, c := range containers {
|
||||||
|
projectName := c.Labels[LabelComposeProject]
|
||||||
|
if projectName == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
serviceName := c.Labels[LabelComposeService]
|
||||||
|
if serviceName == "" {
|
||||||
|
serviceName = c.Name
|
||||||
|
}
|
||||||
|
c.Service = serviceName
|
||||||
|
|
||||||
|
project, ok := res.Projects[projectName]
|
||||||
|
if !ok {
|
||||||
|
project = Project{Name: projectName, Services: map[string]Service{}}
|
||||||
|
}
|
||||||
|
// Container labels carry the authoritative working dir / config
|
||||||
|
// file; prefer them over the `compose ls` row when present.
|
||||||
|
if wd := c.Labels[LabelComposeWorkingDir]; wd != "" {
|
||||||
|
project.WorkingDir = wd
|
||||||
|
}
|
||||||
|
if cf := c.Labels[LabelComposeConfigFiles]; cf != "" {
|
||||||
|
project.ConfigFiles = cf
|
||||||
|
}
|
||||||
|
|
||||||
|
service := project.Services[serviceName]
|
||||||
|
service.Name = serviceName
|
||||||
|
service.Containers = append(service.Containers, c)
|
||||||
|
project.Services[serviceName] = service
|
||||||
|
|
||||||
|
res.Projects[projectName] = project
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Snapshot is a sorted, JSON-friendly view of a DiscoveryResult for the
|
||||||
|
// operator console: projects ordered by name. It is the shape rendered
|
||||||
|
// on /compose and returned by GET /web/api/compose.
|
||||||
|
type Snapshot struct {
|
||||||
|
Projects []ProjectSummary `json:"projects"`
|
||||||
|
Errors []string `json:"errors,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Summarize builds the sorted Snapshot from a raw DiscoveryResult.
|
||||||
|
func Summarize(res *DiscoveryResult) Snapshot {
|
||||||
|
snap := Snapshot{Errors: append([]string(nil), res.Errors...)}
|
||||||
|
snap.Projects = BuildAllProjectSummaries(res)
|
||||||
|
return snap
|
||||||
|
}
|
||||||
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
130
internal/compose/docker.go
Обычный файл
130
internal/compose/docker.go
Обычный файл
@@ -0,0 +1,130 @@
|
|||||||
|
package compose
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// dockerBin is the Docker CLI binary name. Overridable in tests via
|
||||||
|
// SetDockerBin so the management/discovery code never hard-codes a path.
|
||||||
|
var dockerBin = "docker"
|
||||||
|
|
||||||
|
// SetDockerBin overrides the Docker CLI binary used by discovery and
|
||||||
|
// management. Pass an empty value to restore the default ("docker").
|
||||||
|
// Tests use it to point at a stub binary; production leaves it alone.
|
||||||
|
func SetDockerBin(name string) {
|
||||||
|
if name == "" {
|
||||||
|
dockerBin = "docker"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dockerBin = name
|
||||||
|
}
|
||||||
|
|
||||||
|
// commandTimeout is the deadline applied to every Docker CLI call so a
|
||||||
|
// hung dockerd cannot wedge the inventory refresh loop or a management
|
||||||
|
// request. 30s is well above `docker ps` / `docker inspect` on a busy
|
||||||
|
// host but short enough that the operator notices.
|
||||||
|
const commandTimeout = 30 * time.Second
|
||||||
|
|
||||||
|
// Available reports whether the Docker CLI can reach the daemon. Used
|
||||||
|
// by the refresh loop to no-op (instead of erroring) on hosts without
|
||||||
|
// Docker, and by the UI to show a "Docker not available" banner.
|
||||||
|
func Available(ctx context.Context) bool {
|
||||||
|
c, cancel := context.WithTimeout(ctx, commandTimeout)
|
||||||
|
defer cancel()
|
||||||
|
if err := exec.CommandContext(c, dockerBin, "version").Run(); err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// `docker version` succeeds against the client even when the daemon
|
||||||
|
// is down on some builds; `docker ps` requires the daemon, so it is
|
||||||
|
// the authoritative liveness probe.
|
||||||
|
pc, pcancel := context.WithTimeout(ctx, commandTimeout)
|
||||||
|
defer pcancel()
|
||||||
|
return exec.CommandContext(pc, dockerBin, "ps", "--format", "{{.ID}}").Run() == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// composeLSItem is one row of `docker compose ls --all --format json`.
|
||||||
|
type composeLSItem struct {
|
||||||
|
Name string `json:"Name"`
|
||||||
|
Status string `json:"Status"`
|
||||||
|
ConfigFiles string `json:"ConfigFiles"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// composeLS runs `docker compose ls --all --format json` and returns
|
||||||
|
// the projects keyed by name. Unlike `docker ps --format json` (NDJSON),
|
||||||
|
// `docker compose ls` emits a single JSON array, so the whole stdout is
|
||||||
|
// unmarshalled at once. Non-fatal: returns an empty map on any error.
|
||||||
|
func composeLS(ctx context.Context) map[string]composeLSItem {
|
||||||
|
out, err := runDocker(ctx, "compose", "ls", "--all", "--format", "json")
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
trimmed := strings.TrimSpace(out)
|
||||||
|
if trimmed == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var items []composeLSItem
|
||||||
|
if err := json.Unmarshal([]byte(trimmed), &items); err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
m := make(map[string]composeLSItem, len(items))
|
||||||
|
for _, it := range items {
|
||||||
|
m[it.Name] = it
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
// runDocker executes the Docker CLI with the given args and returns
|
||||||
|
// stdout. The context deadline is enforced by Go (CommandContext); a
|
||||||
|
// non-zero exit yields an error that carries the daemon's stderr so the
|
||||||
|
// caller can surface the daemon's diagnostic instead of a bare exit code.
|
||||||
|
func runDocker(ctx context.Context, args ...string) (string, error) {
|
||||||
|
c, cancel := context.WithTimeout(ctx, commandTimeout)
|
||||||
|
defer cancel()
|
||||||
|
cmd := exec.CommandContext(c, dockerBin, args...)
|
||||||
|
out, err := cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
if ee, ok := err.(*exec.ExitError); ok && len(ee.Stderr) > 0 {
|
||||||
|
return "", fmt.Errorf("%s: %s", strings.Join(args, " "), strings.TrimSpace(string(ee.Stderr)))
|
||||||
|
}
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return string(out), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// dockerCombined runs the Docker CLI with a working directory and
|
||||||
|
// returns combined stdout+stderr. Used by management operations, which
|
||||||
|
// must run inside the project's working directory so Compose resolves
|
||||||
|
// the compose file and `.env` exactly as the operator would. Unlike
|
||||||
|
// runDocker, stderr is merged into the returned string because compose
|
||||||
|
// progress output goes to stderr and is useful to the operator.
|
||||||
|
func dockerCombined(ctx context.Context, dir string, args ...string) (string, error) {
|
||||||
|
c, cancel := context.WithTimeout(ctx, commandTimeout)
|
||||||
|
defer cancel()
|
||||||
|
cmd := exec.CommandContext(c, dockerBin, args...)
|
||||||
|
cmd.Dir = dir
|
||||||
|
out, err := cmd.CombinedOutput()
|
||||||
|
return string(out), err
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseLabels parses the comma-separated `key=value` label string that
|
||||||
|
// `docker ps --format json` emits in the Labels field. Tolerates empty
|
||||||
|
// input and values containing '=' (only the first '=' splits).
|
||||||
|
func parseLabels(labels string) map[string]string {
|
||||||
|
out := make(map[string]string)
|
||||||
|
if labels == "" {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
for _, pair := range strings.Split(labels, ",") {
|
||||||
|
k, v, ok := strings.Cut(pair, "=")
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out[strings.TrimSpace(k)] = strings.TrimSpace(v)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
94
internal/compose/inspect.go
Обычный файл
94
internal/compose/inspect.go
Обычный файл
@@ -0,0 +1,94 @@
|
|||||||
|
package compose
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// inspectData mirrors the subset of `docker inspect <id>` output that
|
||||||
|
// discovery consumes. Env is intentionally absent: the worker must not
|
||||||
|
// collect container environment (which carries secrets). Only Config
|
||||||
|
// labels, runtime State (for the host PID), Mounts, and published Ports
|
||||||
|
// are read.
|
||||||
|
type inspectData struct {
|
||||||
|
ID string `json:"Id"`
|
||||||
|
State inspectState `json:"State"`
|
||||||
|
Config inspectConfig `json:"Config"`
|
||||||
|
Mounts []inspectMount `json:"Mounts"`
|
||||||
|
NetworkSettings inspectNetworks `json:"NetworkSettings"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type inspectState struct {
|
||||||
|
Status string `json:"Status"`
|
||||||
|
Running bool `json:"Running"`
|
||||||
|
Pid int `json:"Pid"`
|
||||||
|
ExitCode int `json:"ExitCode"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type inspectConfig struct {
|
||||||
|
Labels map[string]string `json:"Labels"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type inspectMount struct {
|
||||||
|
Type string `json:"Type"`
|
||||||
|
Source string `json:"Source"`
|
||||||
|
Destination string `json:"Destination"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type inspectNetworks struct {
|
||||||
|
Ports portMap `json:"Ports"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// portMap mirrors the Docker inspect "Ports" object: the key is the
|
||||||
|
// container port ("80/tcp") and the value is the list of host bindings
|
||||||
|
// (nil when the port is exposed but not published).
|
||||||
|
type portMap map[string][]portBinding
|
||||||
|
|
||||||
|
type portBinding struct {
|
||||||
|
HostIP string `json:"HostIp"`
|
||||||
|
HostPort string `json:"HostPort"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// inspectContainer runs `docker inspect <id>` and decodes the first
|
||||||
|
// element. A missing container (race with `docker rm`) is reported as
|
||||||
|
// an error so the caller can skip it without aborting the whole scan.
|
||||||
|
func inspectContainer(ctx context.Context, id string) (*inspectData, error) {
|
||||||
|
out, err := runDocker(ctx, "inspect", id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var data []inspectData
|
||||||
|
if err := json.Unmarshal([]byte(strings.TrimSpace(out)), &data); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(data) == 0 {
|
||||||
|
return nil, fmt.Errorf("no inspect data for %s", id)
|
||||||
|
}
|
||||||
|
return &data[0], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractPorts flattens the inspect Ports map into a slice of host
|
||||||
|
// bindings. Container ports that are exposed but not published (nil
|
||||||
|
// binding list) are skipped because they have no host-side footprint.
|
||||||
|
func extractPorts(data *inspectData) []PortBinding {
|
||||||
|
var ports []PortBinding
|
||||||
|
for _, bindings := range data.NetworkSettings.Ports {
|
||||||
|
for _, b := range bindings {
|
||||||
|
ports = append(ports, PortBinding{HostIP: b.HostIP, HostPort: b.HostPort})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ports
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractMounts copies the inspect Mounts into the wire Mount shape,
|
||||||
|
// dropping Docker-internal fields (mode, propagation, rw) the operator
|
||||||
|
// console does not render.
|
||||||
|
func extractMounts(data *inspectData) []Mount {
|
||||||
|
mounts := make([]Mount, 0, len(data.Mounts))
|
||||||
|
for _, m := range data.Mounts {
|
||||||
|
mounts = append(mounts, Mount{Source: m.Source, Destination: m.Destination, Type: m.Type})
|
||||||
|
}
|
||||||
|
return mounts
|
||||||
|
}
|
||||||
124
internal/compose/manage.go
Обычный файл
124
internal/compose/manage.go
Обычный файл
@@ -0,0 +1,124 @@
|
|||||||
|
package compose
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Action is a management operation the operator console can run against
|
||||||
|
// a project. The string values are the suffix used in the management
|
||||||
|
// API path (/compose/{project}/{action}) and the audit log target.
|
||||||
|
type Action string
|
||||||
|
|
||||||
|
const (
|
||||||
|
ActionUp Action = "up"
|
||||||
|
ActionDown Action = "down"
|
||||||
|
ActionStop Action = "stop"
|
||||||
|
ActionRestart Action = "restart"
|
||||||
|
ActionPull Action = "pull"
|
||||||
|
ActionLogs Action = "logs"
|
||||||
|
)
|
||||||
|
|
||||||
|
// projectActions is the allowlist of actions a project-level POST
|
||||||
|
// accepts. It maps the action to the compose subcommand(s). The map is
|
||||||
|
// the single source of truth so the route handler and the executor
|
||||||
|
// agree on what is permitted.
|
||||||
|
var projectActions = map[Action][]string{
|
||||||
|
ActionUp: {"up", "-d", "--remove-orphans"},
|
||||||
|
ActionDown: {"down", "--remove-orphans"},
|
||||||
|
ActionStop: {"stop"},
|
||||||
|
ActionRestart: {"restart"},
|
||||||
|
ActionPull: {"pull"},
|
||||||
|
}
|
||||||
|
|
||||||
|
// serviceActions is the allowlist for service-level operations
|
||||||
|
// (start/stop/restart a single service inside a project).
|
||||||
|
var serviceActions = map[Action][]string{
|
||||||
|
ActionUp: {"up", "-d", "--no-deps"},
|
||||||
|
ActionStop: {"stop"},
|
||||||
|
ActionRestart: {"restart"},
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidProjectAction reports whether name is an accepted project action.
|
||||||
|
func ValidProjectAction(name string) (Action, bool) {
|
||||||
|
a := Action(name)
|
||||||
|
_, ok := projectActions[a]
|
||||||
|
if !ok && a == ActionLogs {
|
||||||
|
return a, true
|
||||||
|
}
|
||||||
|
return a, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidServiceAction reports whether name is an accepted service action.
|
||||||
|
func ValidServiceAction(name string) (Action, bool) {
|
||||||
|
a := Action(name)
|
||||||
|
_, ok := serviceActions[a]
|
||||||
|
return a, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// ManagementResult is returned by every management operation. Output is
|
||||||
|
// the combined compose stdout/stderr; OK is false when the command
|
||||||
|
// exited non-zero. The handler serializes it as JSON for the API path
|
||||||
|
// and renders Output in a <pre> block on the HTML path.
|
||||||
|
type ManagementResult struct {
|
||||||
|
OK bool `json:"ok"`
|
||||||
|
Action string `json:"action"`
|
||||||
|
Output string `json:"output"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ManageProject runs an action against a whole project. refs supplies
|
||||||
|
// the working directory (so Compose auto-discovers the compose file and
|
||||||
|
// .env) and the project name (pinned with -p). A missing working dir is
|
||||||
|
// a hard error: without it Compose cannot locate the project files.
|
||||||
|
func ManageProject(ctx context.Context, refs ProjectRefs, action Action) (ManagementResult, error) {
|
||||||
|
args, ok := projectActions[action]
|
||||||
|
if !ok {
|
||||||
|
return ManagementResult{}, fmt.Errorf("compose: unknown project action %q", action)
|
||||||
|
}
|
||||||
|
return runCompose(ctx, refs, args...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ManageService runs an action against a single service in a project.
|
||||||
|
// --no-deps on `up` ensures starting one service does not implicitly
|
||||||
|
// recreate its dependencies (matching dockge's service-start behavior).
|
||||||
|
func ManageService(ctx context.Context, refs ProjectRefs, service string, action Action) (ManagementResult, error) {
|
||||||
|
args, ok := serviceActions[action]
|
||||||
|
if !ok {
|
||||||
|
return ManagementResult{}, fmt.Errorf("compose: unknown service action %q", action)
|
||||||
|
}
|
||||||
|
if service == "" {
|
||||||
|
return ManagementResult{}, fmt.Errorf("compose: service is required")
|
||||||
|
}
|
||||||
|
full := append(append([]string{}, args...), service)
|
||||||
|
return runCompose(ctx, refs, full...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Logs returns the recent log output for a project as a single string.
|
||||||
|
// It runs `docker compose logs --no-color --tail <n>` (no -f) so the
|
||||||
|
// operator console gets a finite snapshot; streaming tails are a later
|
||||||
|
// enhancement (dockge uses a PTY + websocket, out of scope here).
|
||||||
|
func Logs(ctx context.Context, refs ProjectRefs, tail int) (ManagementResult, error) {
|
||||||
|
if tail <= 0 {
|
||||||
|
tail = 200
|
||||||
|
}
|
||||||
|
return runCompose(ctx, refs, "logs", "--no-color", "--tail", fmt.Sprintf("%d", tail))
|
||||||
|
}
|
||||||
|
|
||||||
|
// runCompose executes `docker compose -p <name> <args...>` with the
|
||||||
|
// working directory set to refs.WorkingDir. The working dir makes
|
||||||
|
// Compose resolve the compose file and `.env` exactly as the operator
|
||||||
|
// would from the shell; -p pins the project name so the command cannot
|
||||||
|
// accidentally target a different project that shares the directory.
|
||||||
|
func runCompose(ctx context.Context, refs ProjectRefs, args ...string) (ManagementResult, error) {
|
||||||
|
if refs.WorkingDir == "" {
|
||||||
|
return ManagementResult{OK: false, Output: "compose project working directory is unknown; cannot manage"}, fmt.Errorf("compose: empty working dir")
|
||||||
|
}
|
||||||
|
full := append([]string{"compose", "-p", refs.Name}, args...)
|
||||||
|
out, err := dockerCombined(ctx, refs.WorkingDir, full...)
|
||||||
|
res := ManagementResult{OK: err == nil, Action: strings.Join(args, " "), Output: out}
|
||||||
|
if err != nil {
|
||||||
|
return res, err
|
||||||
|
}
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
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
|
||||||
|
}
|
||||||
87
internal/compose/traefik.go
Обычный файл
87
internal/compose/traefik.go
Обычный файл
@@ -0,0 +1,87 @@
|
|||||||
|
package compose
|
||||||
|
|
||||||
|
import (
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Traefik label parsing. Compose projects fronted by Traefik encode
|
||||||
|
// their router rules as `traefik.http.routers.<name>.rule=Host(...)`.
|
||||||
|
// We surface hostnames/paths so the operator console can show which
|
||||||
|
// domains a project serves without reading raw labels.
|
||||||
|
|
||||||
|
const (
|
||||||
|
traefikLabelPrefix = "traefik.http.routers."
|
||||||
|
traefikRuleSuffix = ".rule"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
// Host(`example.com`) or Host("example.com")
|
||||||
|
hostRegex = regexp.MustCompile(`Host\s*\(\s*[` + "`" + `"]([^` + "`" + `"]+)[` + "`" + `"]\s*\)`)
|
||||||
|
// Path(`/api`) or Path("/api")
|
||||||
|
pathRegex = regexp.MustCompile(`Path\s*\(\s*[` + "`" + `"]([^` + "`" + `"]+)[` + "`" + `"]\s*\)`)
|
||||||
|
// PathPrefix(`/api`) or PathPrefix("/api")
|
||||||
|
pathPrefixRegex = regexp.MustCompile(`PathPrefix\s*\(\s*[` + "`" + `"]([^` + "`" + `"]+)[` + "`" + `"]\s*\)`)
|
||||||
|
)
|
||||||
|
|
||||||
|
// TraefikRoute is one decoded Traefik router derived from labels.
|
||||||
|
type TraefikRoute struct {
|
||||||
|
RouterName string `json:"router_name"`
|
||||||
|
Hostnames []string `json:"hostnames"`
|
||||||
|
Paths []string `json:"paths"`
|
||||||
|
PathPrefixes []string `json:"path_prefixes"`
|
||||||
|
Rule string `json:"rule"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseTraefikLabels scans a label map for `traefik.http.routers.<n>.rule`
|
||||||
|
// entries and returns one TraefikRoute per router, with the rule's
|
||||||
|
// Host/Path/PathPrefix operands extracted. Pure function: no I/O.
|
||||||
|
func ParseTraefikLabels(labels map[string]string) []TraefikRoute {
|
||||||
|
routes := map[string]*TraefikRoute{}
|
||||||
|
for key, value := range labels {
|
||||||
|
if !strings.HasPrefix(key, traefikLabelPrefix) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
suffix := strings.TrimPrefix(key, traefikLabelPrefix)
|
||||||
|
idx := strings.Index(suffix, ".")
|
||||||
|
if idx == -1 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
routerName := suffix[:idx]
|
||||||
|
property := suffix[idx:]
|
||||||
|
if property != traefikRuleSuffix {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
route := routes[routerName]
|
||||||
|
if route == nil {
|
||||||
|
route = &TraefikRoute{RouterName: routerName}
|
||||||
|
}
|
||||||
|
route.Rule = value
|
||||||
|
route.Hostnames = extractAll(hostRegex, value)
|
||||||
|
route.Paths = extractAll(pathRegex, value)
|
||||||
|
route.PathPrefixes = extractAll(pathPrefixRegex, value)
|
||||||
|
routes[routerName] = route
|
||||||
|
}
|
||||||
|
out := make([]TraefikRoute, 0, len(routes))
|
||||||
|
for _, r := range routes {
|
||||||
|
out = append(out, *r)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractAll(re *regexp.Regexp, rule string) []string {
|
||||||
|
matches := re.FindAllStringSubmatch(rule, -1)
|
||||||
|
out := make([]string, 0, len(matches))
|
||||||
|
for _, m := range matches {
|
||||||
|
if len(m) > 1 && m[1] != "" {
|
||||||
|
out = append(out, m[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExtractTraefikRoutes returns the Traefik routes declared by a
|
||||||
|
// container's labels.
|
||||||
|
func ExtractTraefikRoutes(c Container) []TraefikRoute {
|
||||||
|
return ParseTraefikLabels(c.Labels)
|
||||||
|
}
|
||||||
90
internal/compose/types.go
Обычный файл
90
internal/compose/types.go
Обычный файл
@@ -0,0 +1,90 @@
|
|||||||
|
// Package compose implements Docker Compose project discovery and
|
||||||
|
// management for the worker operator console.
|
||||||
|
//
|
||||||
|
// It talks to Docker exclusively through the `docker` / `docker compose`
|
||||||
|
// CLI over os/exec. There is no dependency on the Docker Engine SDK or
|
||||||
|
// compose-go: the CLI is the same surface deploymentd and dockge use, it
|
||||||
|
// needs no extra libraries, and it is already present on every host the
|
||||||
|
// worker manages. Discovery groups running containers by the
|
||||||
|
// `com.docker.compose.project` / `com.docker.compose.service` labels and
|
||||||
|
// merges the project-level metadata (status, compose file, working dir)
|
||||||
|
// reported by `docker compose ls`. Management runs `docker compose`
|
||||||
|
// against the project's working directory.
|
||||||
|
package compose
|
||||||
|
|
||||||
|
// Label constants written by Docker Compose onto every container it
|
||||||
|
// creates. They are the stable identity keys that map a raw container
|
||||||
|
// back to its Compose project and service.
|
||||||
|
const (
|
||||||
|
LabelComposeProject = "com.docker.compose.project"
|
||||||
|
LabelComposeService = "com.docker.compose.service"
|
||||||
|
LabelComposeWorkingDir = "com.docker.compose.project.working_dir"
|
||||||
|
LabelComposeConfigFiles = "com.docker.compose.project.config_files"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PortBinding is a single host-side port mapping for a container.
|
||||||
|
type PortBinding struct {
|
||||||
|
HostIP string `json:"host_ip"`
|
||||||
|
HostPort string `json:"host_port"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mount is a single bind/volume/tmpfs mount attached to a container.
|
||||||
|
type Mount struct {
|
||||||
|
Source string `json:"source"`
|
||||||
|
Destination string `json:"destination"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Container is the per-container view discovery collects. It is the
|
||||||
|
// union of `docker ps` (name/image/state/status/health) and
|
||||||
|
// `docker inspect` (pid/ports/mounts/full labels).
|
||||||
|
type Container struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Image string `json:"image"`
|
||||||
|
State string `json:"state"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Health string `json:"health"`
|
||||||
|
PID int `json:"pid"`
|
||||||
|
Labels map[string]string `json:"labels"`
|
||||||
|
Ports []PortBinding `json:"ports"`
|
||||||
|
Mounts []Mount `json:"mounts"`
|
||||||
|
Service string `json:"service"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Service groups the containers that belong to one Compose service.
|
||||||
|
type Service struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Containers []Container `json:"containers"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Project is a single Compose project. Status / ConfigFiles / WorkingDir
|
||||||
|
// come from `docker compose ls` and are refined from container labels
|
||||||
|
// (which carry the exact absolute paths the project was deployed with).
|
||||||
|
type Project struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
ConfigFiles string `json:"config_files"`
|
||||||
|
WorkingDir string `json:"working_dir"`
|
||||||
|
Services map[string]Service `json:"services"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DiscoveryResult is the full snapshot returned by Discover. Projects
|
||||||
|
// is keyed by project name. Errors carries non-fatal section failures
|
||||||
|
// (e.g. docker unavailable) so the UI can render a banner instead of a
|
||||||
|
// blank page.
|
||||||
|
type DiscoveryResult struct {
|
||||||
|
Projects map[string]Project `json:"projects"`
|
||||||
|
Errors []string `json:"errors,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProjectRefs resolves the Compose CLI arguments a management operation
|
||||||
|
// needs for a project: the working directory (so Compose auto-discovers
|
||||||
|
// the compose file and `.env`) and the project name (pinned with -p so
|
||||||
|
// the operation targets exactly this project even when the directory
|
||||||
|
// basename differs).
|
||||||
|
type ProjectRefs struct {
|
||||||
|
Name string
|
||||||
|
WorkingDir string
|
||||||
|
ConfigFiles string
|
||||||
|
}
|
||||||
176
internal/webapp/compose.go
Обычный файл
176
internal/webapp/compose.go
Обычный файл
@@ -0,0 +1,176 @@
|
|||||||
|
package webapp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"rocketgit.ru/rsmon/worker/internal/compose"
|
||||||
|
)
|
||||||
|
|
||||||
|
// composeRefreshInterval is the cadence the Compose project snapshot is
|
||||||
|
// rebuilt. 60s matches the process-inventory loop so the two collectors
|
||||||
|
// stay in step; management operations are synchronous POSTs that do not
|
||||||
|
// wait for the next tick.
|
||||||
|
const composeRefreshInterval = 60 * time.Second
|
||||||
|
|
||||||
|
// ComposeRefresher owns the periodic `docker compose` discovery loop and
|
||||||
|
// exposes the latest snapshot for the operator console. It is the
|
||||||
|
// Compose counterpart of Inventory: a self-contained refresher that is
|
||||||
|
// constructed in New and started in Start.
|
||||||
|
//
|
||||||
|
// When Docker is unavailable (or compose is disabled) the refresher
|
||||||
|
// stays idle and reports an empty snapshot with an "unavailable" error,
|
||||||
|
// so /compose renders a banner instead of a half-built table.
|
||||||
|
type ComposeRefresher struct {
|
||||||
|
enabled bool
|
||||||
|
log *log.Logger
|
||||||
|
disco composeDiscover
|
||||||
|
|
||||||
|
mu sync.RWMutex
|
||||||
|
snap compose.Snapshot
|
||||||
|
lastAt time.Time
|
||||||
|
stopCh chan struct{}
|
||||||
|
stopWG sync.WaitGroup
|
||||||
|
started bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// composeDiscover is the subset of the compose package the refresher
|
||||||
|
// calls. A function field so tests can inject a fixture discovery
|
||||||
|
// without exec'ing Docker.
|
||||||
|
type composeDiscover func(ctx context.Context) (*compose.DiscoveryResult, error)
|
||||||
|
|
||||||
|
// NewComposeRefresher constructs a refresher. When enabled is false the
|
||||||
|
// refresher never starts its loop and Snapshot returns an empty result;
|
||||||
|
// the page still renders (with the disabled notice). The discover hook
|
||||||
|
// defaults to compose.Discover so production needs no wiring.
|
||||||
|
func NewComposeRefresher(enabled bool, logger *log.Logger) *ComposeRefresher {
|
||||||
|
if logger == nil {
|
||||||
|
logger = log.New(os.Stderr, "webapp-compose: ", log.LstdFlags)
|
||||||
|
}
|
||||||
|
return &ComposeRefresher{
|
||||||
|
enabled: enabled,
|
||||||
|
log: logger,
|
||||||
|
disco: compose.Discover,
|
||||||
|
stopCh: make(chan struct{}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetDiscovery injects the discovery function. Used by tests; production
|
||||||
|
// leaves the compose.Discover default set by NewComposeRefresher.
|
||||||
|
func (c *ComposeRefresher) SetDiscovery(f composeDiscover) {
|
||||||
|
if f == nil {
|
||||||
|
c.disco = compose.Discover
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.disco = f
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enabled reports whether compose management is turned on.
|
||||||
|
func (c *ComposeRefresher) Enabled() bool { return c != nil && c.enabled }
|
||||||
|
|
||||||
|
// Start launches the background refresh loop. The first refresh runs
|
||||||
|
// immediately so /compose has data on the first request. Idempotent.
|
||||||
|
func (c *ComposeRefresher) Start(ctx context.Context) {
|
||||||
|
if c == nil || !c.enabled {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.mu.Lock()
|
||||||
|
if c.started {
|
||||||
|
c.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.started = true
|
||||||
|
c.mu.Unlock()
|
||||||
|
|
||||||
|
c.stopWG.Add(1)
|
||||||
|
go c.loop(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop cancels the refresh loop and waits for it to exit.
|
||||||
|
func (c *ComposeRefresher) Stop() {
|
||||||
|
if c == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.mu.Lock()
|
||||||
|
if !c.started {
|
||||||
|
c.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-c.stopCh:
|
||||||
|
default:
|
||||||
|
close(c.stopCh)
|
||||||
|
}
|
||||||
|
c.mu.Unlock()
|
||||||
|
c.stopWG.Wait()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Snapshot returns the most recent discovery snapshot. Always safe to
|
||||||
|
// call; returns a zero-value snapshot (with the unavailable notice when
|
||||||
|
// disabled) before the first refresh completes.
|
||||||
|
func (c *ComposeRefresher) Snapshot() compose.Snapshot {
|
||||||
|
if c == nil {
|
||||||
|
return compose.Snapshot{Errors: []string{"compose management is disabled"}}
|
||||||
|
}
|
||||||
|
c.mu.RLock()
|
||||||
|
defer c.mu.RUnlock()
|
||||||
|
if !c.enabled {
|
||||||
|
return compose.Snapshot{Errors: []string{"compose management is disabled"}}
|
||||||
|
}
|
||||||
|
out := c.snap
|
||||||
|
out.Projects = append([]compose.ProjectSummary(nil), c.snap.Projects...)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// LastRefreshAt returns the time of the last successful refresh.
|
||||||
|
func (c *ComposeRefresher) LastRefreshAt() time.Time {
|
||||||
|
c.mu.RLock()
|
||||||
|
defer c.mu.RUnlock()
|
||||||
|
return c.lastAt
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refs resolves the Compose CLI arguments for a project from the current
|
||||||
|
// snapshot. Returns ok=false when the project is unknown or its working
|
||||||
|
// directory was not discovered (Compose cannot be driven without it).
|
||||||
|
func (c *ComposeRefresher) Refs(project string) (compose.ProjectRefs, bool) {
|
||||||
|
p := c.Snapshot().FindProject(project)
|
||||||
|
if p == nil || p.WorkingDir == "" {
|
||||||
|
return compose.ProjectRefs{}, false
|
||||||
|
}
|
||||||
|
return compose.ProjectRefs{Name: p.Name, WorkingDir: p.WorkingDir, ConfigFiles: p.ConfigFiles}, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *ComposeRefresher) loop(ctx context.Context) {
|
||||||
|
defer c.stopWG.Done()
|
||||||
|
c.refresh(ctx)
|
||||||
|
t := time.NewTicker(composeRefreshInterval)
|
||||||
|
defer t.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-c.stopCh:
|
||||||
|
return
|
||||||
|
case <-t.C:
|
||||||
|
c.refresh(ctx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *ComposeRefresher) refresh(ctx context.Context) {
|
||||||
|
res, err := c.disco(ctx)
|
||||||
|
if err != nil || res == nil {
|
||||||
|
if err != nil {
|
||||||
|
c.log.Printf("compose refresh: %v", err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
snap := compose.Summarize(res)
|
||||||
|
c.mu.Lock()
|
||||||
|
c.snap = snap
|
||||||
|
c.lastAt = time.Now().UTC()
|
||||||
|
c.mu.Unlock()
|
||||||
|
}
|
||||||
@@ -30,6 +30,7 @@ const (
|
|||||||
envWorkerLogin = "WORKER_LOGIN"
|
envWorkerLogin = "WORKER_LOGIN"
|
||||||
envWorkerPassword = "WORKER_PASSWORD"
|
envWorkerPassword = "WORKER_PASSWORD"
|
||||||
envClusterEnabled = "WORKER_CLUSTER_ENABLED"
|
envClusterEnabled = "WORKER_CLUSTER_ENABLED"
|
||||||
|
envComposeEnabled = "WORKER_COMPOSE_ENABLED"
|
||||||
envReleaseURL = "WORKER_RELEASE_URL"
|
envReleaseURL = "WORKER_RELEASE_URL"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
280
internal/webapp/handlers_compose.go
Обычный файл
280
internal/webapp/handlers_compose.go
Обычный файл
@@ -0,0 +1,280 @@
|
|||||||
|
package webapp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"rocketgit.ru/rsmon/worker/internal/compose"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Compose management endpoints. The browser UI uses session+CSRF POSTs
|
||||||
|
// under /compose; scripted callers use HTTP basic auth under
|
||||||
|
// /web/api/compose (the /web/api/* prefix is the only one the auth
|
||||||
|
// middleware accepts basic credentials on). Read endpoints (list,
|
||||||
|
// detail, logs) are GET; lifecycle endpoints (up/down/stop/restart/pull)
|
||||||
|
// are POST. Every mutation is audited via the shared webapp audit log.
|
||||||
|
|
||||||
|
// handleComposeList renders the project list page.
|
||||||
|
func (s *Server) handleComposeList(w http.ResponseWriter, r *http.Request) {
|
||||||
|
writeNoStore(w)
|
||||||
|
sess, _ := sessionFromContext(r.Context())
|
||||||
|
data := composeListPageData{
|
||||||
|
basePageData: s.newBasePage(r, "Compose projects", sess),
|
||||||
|
Snapshot: s.compose.Snapshot(),
|
||||||
|
LastAt: s.compose.LastRefreshAt(),
|
||||||
|
Enabled: s.compose.Enabled(),
|
||||||
|
}
|
||||||
|
if err := s.templates.Execute(w, "compose.html", data); err != nil {
|
||||||
|
s.deps.Logger.Printf("render compose: %v", err)
|
||||||
|
http.Error(w, "template error", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleComposeDetail renders one project with its services, containers,
|
||||||
|
// mounts, and Traefik routes, plus the management action buttons.
|
||||||
|
func (s *Server) handleComposeDetail(w http.ResponseWriter, r *http.Request) {
|
||||||
|
writeNoStore(w)
|
||||||
|
sess, _ := sessionFromContext(r.Context())
|
||||||
|
snap := s.compose.Snapshot()
|
||||||
|
p := snap.FindProject(r.PathValue("project"))
|
||||||
|
if p == nil {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data := composeDetailPageData{
|
||||||
|
basePageData: s.newBasePage(r, "Compose: "+p.Name, sess),
|
||||||
|
Project: p,
|
||||||
|
LastAt: s.compose.LastRefreshAt(),
|
||||||
|
ActionOK: r.URL.Query().Get("ok") == "1",
|
||||||
|
ActionMessage: r.URL.Query().Get("msg"),
|
||||||
|
}
|
||||||
|
if err := s.templates.Execute(w, "compose_detail.html", data); err != nil {
|
||||||
|
s.deps.Logger.Printf("render compose detail: %v", err)
|
||||||
|
http.Error(w, "template error", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleComposeLogsPage renders the recent log tail for a project in a
|
||||||
|
// <pre> block so an operator can read it from the browser without curl.
|
||||||
|
func (s *Server) handleComposeLogsPage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
writeNoStore(w)
|
||||||
|
sess, _ := sessionFromContext(r.Context())
|
||||||
|
project := r.PathValue("project")
|
||||||
|
res, status := s.composeLogsResult(r.Context(), project)
|
||||||
|
if status != http.StatusOK {
|
||||||
|
http.Error(w, res.Output, status)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data := composeLogsPageData{
|
||||||
|
basePageData: s.newBasePage(r, "Compose logs: "+project, sess),
|
||||||
|
Project: project,
|
||||||
|
Output: res.Output,
|
||||||
|
}
|
||||||
|
if err := s.templates.Execute(w, "compose_logs.html", data); err != nil {
|
||||||
|
s.deps.Logger.Printf("render compose logs: %v", err)
|
||||||
|
http.Error(w, "template error", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleComposeProjectAction runs a lifecycle action against a project
|
||||||
|
// from the HTML form (session+CSRF) or the API (basic auth). The
|
||||||
|
// response is a redirect for the browser path and JSON for the API path.
|
||||||
|
func (s *Server) handleComposeProjectAction(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !s.compose.Enabled() {
|
||||||
|
http.Error(w, "compose management is disabled", http.StatusServiceUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !validProjectPostAction(r.PathValue("action")) {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !s.composeMutationAuthorized(w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
project := r.PathValue("project")
|
||||||
|
action := r.PathValue("action")
|
||||||
|
refs, ok := s.compose.Refs(project)
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, "compose project working directory is unknown", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
res, _ := compose.ManageProject(r.Context(), refs, compose.Action(action))
|
||||||
|
s.auditCompose(r, action, project, "")
|
||||||
|
s.respondComposeAction(w, r, project, res)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleComposeServiceAction runs a lifecycle action against a single
|
||||||
|
// service inside a project.
|
||||||
|
func (s *Server) handleComposeServiceAction(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !s.compose.Enabled() {
|
||||||
|
http.Error(w, "compose management is disabled", http.StatusServiceUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, ok := compose.ValidServiceAction(r.PathValue("action")); !ok {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !s.composeMutationAuthorized(w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
project := r.PathValue("project")
|
||||||
|
service := r.PathValue("service")
|
||||||
|
action := r.PathValue("action")
|
||||||
|
refs, ok := s.compose.Refs(project)
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, "compose project working directory is unknown", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
res, _ := compose.ManageService(r.Context(), refs, service, compose.Action(action))
|
||||||
|
s.auditCompose(r, action, project, service)
|
||||||
|
s.respondComposeAction(w, r, project, res)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleComposeAPIList returns the full discovery snapshot as JSON. The
|
||||||
|
// shape matches the /compose HTML page (a Snapshot: projects + errors).
|
||||||
|
func (s *Server) handleComposeAPIList(w http.ResponseWriter, r *http.Request) {
|
||||||
|
writeComposeJSON(w, s.compose.Snapshot())
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleComposeAPIDetail returns one project summary as JSON.
|
||||||
|
func (s *Server) handleComposeAPIDetail(w http.ResponseWriter, r *http.Request) {
|
||||||
|
p := s.compose.Snapshot().FindProject(r.PathValue("project"))
|
||||||
|
if p == nil {
|
||||||
|
http.Error(w, "compose project not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeComposeJSON(w, p)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleComposeLogsAPI returns the recent log tail for a project as JSON.
|
||||||
|
func (s *Server) handleComposeLogsAPI(w http.ResponseWriter, r *http.Request) {
|
||||||
|
res, status := s.composeLogsResult(r.Context(), r.PathValue("project"))
|
||||||
|
if status != http.StatusOK {
|
||||||
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||||
|
w.WriteHeader(status)
|
||||||
|
_ = json.NewEncoder(w).Encode(res)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeComposeJSON(w, res)
|
||||||
|
}
|
||||||
|
|
||||||
|
// composeLogsResult resolves the log tail for a project and the HTTP
|
||||||
|
// status the caller should write. A 404 means the project (or its
|
||||||
|
// working directory) is unknown; 502 means Docker refused the call.
|
||||||
|
func (s *Server) composeLogsResult(ctx context.Context, project string) (compose.ManagementResult, int) {
|
||||||
|
refs, ok := s.compose.Refs(project)
|
||||||
|
if !ok {
|
||||||
|
return compose.ManagementResult{OK: false, Action: "logs", Output: "compose project working directory is unknown"}, http.StatusNotFound
|
||||||
|
}
|
||||||
|
res, err := compose.Logs(ctx, refs, 300)
|
||||||
|
if err != nil && res.Output == "" {
|
||||||
|
return res, http.StatusBadGateway
|
||||||
|
}
|
||||||
|
return res, http.StatusOK
|
||||||
|
}
|
||||||
|
|
||||||
|
// composeMutationAuthorized enforces CSRF for browser (session) callers
|
||||||
|
// while letting basic-auth API callers through without a token (the
|
||||||
|
// middleware already validated their credentials). Returns true when the
|
||||||
|
// request may proceed; writes a 403 and returns false otherwise.
|
||||||
|
func (s *Server) composeMutationAuthorized(w http.ResponseWriter, r *http.Request) bool {
|
||||||
|
sess, hasSess := sessionFromContext(r.Context())
|
||||||
|
if !hasSess {
|
||||||
|
return true // basic-auth API path; middleware authenticated it
|
||||||
|
}
|
||||||
|
if !s.requireCSRF(sess, r) {
|
||||||
|
http.Error(w, "csrf token required", http.StatusForbidden)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// validProjectPostAction reports whether name is a POST-able project
|
||||||
|
// action. It reuses compose.ValidProjectAction but excludes "logs",
|
||||||
|
// which is served by a dedicated GET route.
|
||||||
|
func validProjectPostAction(name string) bool {
|
||||||
|
if name == string(compose.ActionLogs) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
_, ok := compose.ValidProjectAction(name)
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// respondComposeAction sends the action result to the right consumer:
|
||||||
|
// JSON for /web/api/* (scripting), a redirect with a flash banner for
|
||||||
|
// the browser path. The action's combined output is URL-encoded into the
|
||||||
|
// banner so the operator sees what Compose printed without a separate
|
||||||
|
// request; very large output is truncated to keep the URL bounded.
|
||||||
|
func (s *Server) respondComposeAction(w http.ResponseWriter, r *http.Request, project string, res compose.ManagementResult) {
|
||||||
|
if strings.HasPrefix(r.URL.Path, "/web/api/") {
|
||||||
|
writeComposeJSON(w, res)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
msg := strings.TrimSpace(res.Output)
|
||||||
|
if len(msg) > 500 {
|
||||||
|
msg = msg[:500] + "…"
|
||||||
|
}
|
||||||
|
if !res.OK && msg == "" {
|
||||||
|
msg = res.Action + " failed"
|
||||||
|
}
|
||||||
|
loc := fmt.Sprintf("/compose/%s?ok=%t&msg=%s", project, res.OK, url.QueryEscape(msg))
|
||||||
|
http.Redirect(w, r, loc, http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeComposeJSON serializes a value as no-store JSON. Used by every
|
||||||
|
// /web/api/compose endpoint so scripted callers get one consistent shape.
|
||||||
|
func writeComposeJSON(w http.ResponseWriter, v interface{}) {
|
||||||
|
writeNoStore(w)
|
||||||
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||||
|
_ = json.NewEncoder(w).Encode(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
// auditCompose records a management action in the worker audit log. The
|
||||||
|
// target encodes project and service so the audit trail reconstructs the
|
||||||
|
// exact scope (e.g. "compose:rsmon/web"); failures are still audited so
|
||||||
|
// an operator can trace a stopped service back to the action.
|
||||||
|
func (s *Server) auditCompose(r *http.Request, action, project, service string) {
|
||||||
|
target := "compose:" + project
|
||||||
|
if service != "" {
|
||||||
|
target += "/" + service
|
||||||
|
}
|
||||||
|
authMode := auditAuthModeBasic
|
||||||
|
if _, hasSess := sessionFromContext(r.Context()); hasSess {
|
||||||
|
authMode = auditAuthModeLocal
|
||||||
|
}
|
||||||
|
_ = s.store.WriteAudit(r.Context(), &AuditEntry{
|
||||||
|
Actor: auditActorLocal,
|
||||||
|
Role: auditRoleAdmin,
|
||||||
|
AuthMode: authMode,
|
||||||
|
IP: clientIP(r),
|
||||||
|
UA: r.UserAgent(),
|
||||||
|
Action: "compose_" + action,
|
||||||
|
Target: target,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
type composeListPageData struct {
|
||||||
|
basePageData
|
||||||
|
Snapshot compose.Snapshot
|
||||||
|
LastAt time.Time
|
||||||
|
Enabled bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type composeDetailPageData struct {
|
||||||
|
basePageData
|
||||||
|
Project *compose.ProjectSummary
|
||||||
|
LastAt time.Time
|
||||||
|
ActionOK bool
|
||||||
|
ActionMessage string
|
||||||
|
}
|
||||||
|
|
||||||
|
type composeLogsPageData struct {
|
||||||
|
basePageData
|
||||||
|
Project string
|
||||||
|
Output string
|
||||||
|
}
|
||||||
417
internal/webapp/handlers_compose_test.go
Обычный файл
417
internal/webapp/handlers_compose_test.go
Обычный файл
@@ -0,0 +1,417 @@
|
|||||||
|
package webapp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"rocketgit.ru/rsmon/worker/internal/compose"
|
||||||
|
)
|
||||||
|
|
||||||
|
// newComposeTestServer builds a session-auth server with the Compose
|
||||||
|
// subsystem enabled. The default newTestServer leaves ComposeEnabled
|
||||||
|
// false (zero-value Config), so the handlers would short-circuit to the
|
||||||
|
// disabled banner; flipping the refresher flag on exercises the real
|
||||||
|
// code paths without starting the background loop.
|
||||||
|
func newComposeTestServer(t *testing.T, runner WorkerView) *Server {
|
||||||
|
t.Helper()
|
||||||
|
srv := newTestServer(t, runner)
|
||||||
|
srv.compose.enabled = true
|
||||||
|
return srv
|
||||||
|
}
|
||||||
|
|
||||||
|
// newComposeTestServerBasicAuth is the basic-auth variant used by the
|
||||||
|
// /web/api/compose/* tests: those routes accept HTTP basic credentials
|
||||||
|
// in lieu of a session cookie.
|
||||||
|
func newComposeTestServerBasicAuth(t *testing.T, runner WorkerView, login, password string) *Server {
|
||||||
|
t.Helper()
|
||||||
|
srv := newTestServerWithBasicAuth(t, runner, login, password)
|
||||||
|
srv.compose.enabled = true
|
||||||
|
return srv
|
||||||
|
}
|
||||||
|
|
||||||
|
// injectComposeSnapshot seeds the refresher with a single "rsmon"
|
||||||
|
// project so the list/detail/logs handlers have something to render
|
||||||
|
// without exec'ing Docker. The project's WorkingDir is a real temp dir
|
||||||
|
// so management operations (which chdir into it) succeed under a stub
|
||||||
|
// Docker binary.
|
||||||
|
func injectComposeSnapshot(t *testing.T, srv *Server, workDir string) {
|
||||||
|
t.Helper()
|
||||||
|
res := &compose.DiscoveryResult{
|
||||||
|
Projects: map[string]compose.Project{
|
||||||
|
"rsmon": {
|
||||||
|
Name: "rsmon",
|
||||||
|
Status: "running(1)",
|
||||||
|
WorkingDir: workDir,
|
||||||
|
ConfigFiles: filepath.Join(workDir, "docker-compose.yml"),
|
||||||
|
Services: map[string]compose.Service{
|
||||||
|
"web": {Name: "web", Containers: []compose.Container{{
|
||||||
|
ID: "c1",
|
||||||
|
Name: "rsmon-web-1",
|
||||||
|
Image: "nginx:latest",
|
||||||
|
State: "running",
|
||||||
|
Status: "Up 5 minutes",
|
||||||
|
Health: "healthy",
|
||||||
|
PID: 4242,
|
||||||
|
Ports: []compose.PortBinding{{HostIP: "0.0.0.0", HostPort: "8080"}},
|
||||||
|
Mounts: []compose.Mount{{Source: filepath.Join(workDir, "data"), Destination: "/data", Type: "bind"}},
|
||||||
|
Labels: map[string]string{
|
||||||
|
compose.LabelComposeProject: "rsmon",
|
||||||
|
compose.LabelComposeService: "web",
|
||||||
|
},
|
||||||
|
}}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
snap := compose.Summarize(res)
|
||||||
|
srv.compose.mu.Lock()
|
||||||
|
srv.compose.snap = snap
|
||||||
|
srv.compose.lastAt = time.Now().UTC()
|
||||||
|
srv.compose.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// stubDockerOK points the compose package at an executable stub that
|
||||||
|
// echoes its arguments and exits 0, so management/logs handlers run the
|
||||||
|
// full exec path without a real Docker daemon. Restored on cleanup.
|
||||||
|
func stubDockerOK(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
dir := t.TempDir()
|
||||||
|
p := filepath.Join(dir, "stub-docker")
|
||||||
|
script := "#!/bin/sh\necho \"stub: $*\"\nexit 0\n"
|
||||||
|
require.NoError(t, os.WriteFile(p, []byte(script), 0o755))
|
||||||
|
compose.SetDockerBin(p)
|
||||||
|
t.Cleanup(func() { compose.SetDockerBin("") })
|
||||||
|
}
|
||||||
|
|
||||||
|
// stubDockerFail points at a stub that exits non-zero with a stderr
|
||||||
|
// message, so the !OK management path is observable.
|
||||||
|
func stubDockerFail(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
dir := t.TempDir()
|
||||||
|
p := filepath.Join(dir, "stub-docker")
|
||||||
|
script := "#!/bin/sh\necho \"compose boom\" >&2\nexit 1\n"
|
||||||
|
require.NoError(t, os.WriteFile(p, []byte(script), 0o755))
|
||||||
|
compose.SetDockerBin(p)
|
||||||
|
t.Cleanup(func() { compose.SetDockerBin("") })
|
||||||
|
}
|
||||||
|
|
||||||
|
// auditHasAction reports whether the audit log contains a row matching
|
||||||
|
// action and a target substring (e.g. "compose_restart" / "rsmon").
|
||||||
|
func auditHasAction(t *testing.T, srv *Server, action, targetSub string) bool {
|
||||||
|
t.Helper()
|
||||||
|
rows, err := srv.store.RecentAudit(context.Background(), 10)
|
||||||
|
require.NoError(t, err)
|
||||||
|
for _, r := range rows {
|
||||||
|
if r.Action == action && strings.Contains(r.Target, targetSub) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestComposeList_RendersProjects(t *testing.T) {
|
||||||
|
srv := newComposeTestServer(t, &stubRunner{id: "w-1"})
|
||||||
|
injectComposeSnapshot(t, srv, t.TempDir())
|
||||||
|
ts := newHTTPTestServer(t, srv)
|
||||||
|
|
||||||
|
c, _ := loginAsFirstRun(t, ts.URL, srv)
|
||||||
|
resp, err := c.Get(ts.URL + "/compose")
|
||||||
|
require.NoError(t, err)
|
||||||
|
body := mustBody(t, resp)
|
||||||
|
assert.Contains(t, body, "Docker Compose projects")
|
||||||
|
assert.Contains(t, body, "rsmon")
|
||||||
|
assert.Contains(t, body, "details") // link to the detail page
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestComposeList_DisabledBanner(t *testing.T) {
|
||||||
|
// Default server leaves Compose disabled.
|
||||||
|
srv := newTestServer(t, &stubRunner{id: "w-1"})
|
||||||
|
ts := newHTTPTestServer(t, srv)
|
||||||
|
|
||||||
|
c, _ := loginAsFirstRun(t, ts.URL, srv)
|
||||||
|
resp, err := c.Get(ts.URL + "/compose")
|
||||||
|
require.NoError(t, err)
|
||||||
|
body := mustBody(t, resp)
|
||||||
|
assert.Contains(t, body, "Compose management is disabled")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestComposeDetail_FoundAndNotFound(t *testing.T) {
|
||||||
|
srv := newComposeTestServer(t, &stubRunner{id: "w-1"})
|
||||||
|
injectComposeSnapshot(t, srv, t.TempDir())
|
||||||
|
ts := newHTTPTestServer(t, srv)
|
||||||
|
|
||||||
|
c, _ := loginAsFirstRun(t, ts.URL, srv)
|
||||||
|
|
||||||
|
resp, err := c.Get(ts.URL + "/compose/rsmon")
|
||||||
|
require.NoError(t, err)
|
||||||
|
body := mustBody(t, resp)
|
||||||
|
assert.Contains(t, body, "Project actions")
|
||||||
|
assert.Contains(t, body, "rsmon-web-1")
|
||||||
|
|
||||||
|
resp2, err := c.Get(ts.URL + "/compose/missing")
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer resp2.Body.Close() //nolint:errcheck
|
||||||
|
assert.Equal(t, http.StatusNotFound, resp2.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestComposeAPIList_JSON(t *testing.T) {
|
||||||
|
srv := newComposeTestServerBasicAuth(t, &stubRunner{id: "w-1"}, "alice", "s3cret")
|
||||||
|
injectComposeSnapshot(t, srv, t.TempDir())
|
||||||
|
ts := newHTTPTestServer(t, srv)
|
||||||
|
|
||||||
|
req, _ := http.NewRequest(http.MethodGet, ts.URL+"/web/api/compose", nil)
|
||||||
|
req.SetBasicAuth("alice", "s3cret")
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer resp.Body.Close() //nolint:errcheck
|
||||||
|
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
|
var snap compose.Snapshot
|
||||||
|
require.NoError(t, json.NewDecoder(resp.Body).Decode(&snap))
|
||||||
|
require.Len(t, snap.Projects, 1)
|
||||||
|
assert.Equal(t, "rsmon", snap.Projects[0].Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestComposeAPIDetail_JSONAndNotFound(t *testing.T) {
|
||||||
|
srv := newComposeTestServerBasicAuth(t, &stubRunner{id: "w-1"}, "alice", "s3cret")
|
||||||
|
injectComposeSnapshot(t, srv, t.TempDir())
|
||||||
|
ts := newHTTPTestServer(t, srv)
|
||||||
|
|
||||||
|
req, _ := http.NewRequest(http.MethodGet, ts.URL+"/web/api/compose/rsmon", nil)
|
||||||
|
req.SetBasicAuth("alice", "s3cret")
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer resp.Body.Close() //nolint:errcheck
|
||||||
|
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
var p compose.ProjectSummary
|
||||||
|
require.NoError(t, json.NewDecoder(resp.Body).Decode(&p))
|
||||||
|
assert.Equal(t, "rsmon", p.Name)
|
||||||
|
|
||||||
|
req2, _ := http.NewRequest(http.MethodGet, ts.URL+"/web/api/compose/missing", nil)
|
||||||
|
req2.SetBasicAuth("alice", "s3cret")
|
||||||
|
resp2, err := http.DefaultClient.Do(req2)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer resp2.Body.Close() //nolint:errcheck
|
||||||
|
assert.Equal(t, http.StatusNotFound, resp2.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestComposeProjectAction_RequiresCSRF(t *testing.T) {
|
||||||
|
srv := newComposeTestServer(t, &stubRunner{id: "w-1"})
|
||||||
|
injectComposeSnapshot(t, srv, t.TempDir())
|
||||||
|
ts := newHTTPTestServer(t, srv)
|
||||||
|
|
||||||
|
c, _ := loginAsFirstRun(t, ts.URL, srv)
|
||||||
|
form := url.Values{}
|
||||||
|
form.Set("csrf_token", "") // missing token
|
||||||
|
req, _ := http.NewRequest(http.MethodPost, ts.URL+"/compose/rsmon/restart", strings.NewReader(form.Encode()))
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
resp, err := c.Do(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer resp.Body.Close() //nolint:errcheck
|
||||||
|
assert.Equal(t, http.StatusForbidden, resp.StatusCode, "POST without CSRF must be 403")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestComposeProjectAction_HTMLRedirectAndAudit(t *testing.T) {
|
||||||
|
srv := newComposeTestServer(t, &stubRunner{id: "w-1"})
|
||||||
|
injectComposeSnapshot(t, srv, t.TempDir())
|
||||||
|
stubDockerOK(t)
|
||||||
|
ts := newHTTPTestServer(t, srv)
|
||||||
|
|
||||||
|
c, _ := loginAsFirstRun(t, ts.URL, srv)
|
||||||
|
|
||||||
|
// Pull the CSRF token out of the detail page's action forms.
|
||||||
|
detail, err := c.Get(ts.URL + "/compose/rsmon")
|
||||||
|
require.NoError(t, err)
|
||||||
|
csrf := extractCSRFToken(t, mustBody(t, detail))
|
||||||
|
|
||||||
|
form := url.Values{}
|
||||||
|
form.Set("csrf_token", csrf)
|
||||||
|
req, _ := http.NewRequest(http.MethodPost, ts.URL+"/compose/rsmon/restart", strings.NewReader(form.Encode()))
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
resp, err := c.Do(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer resp.Body.Close() //nolint:errcheck
|
||||||
|
assert.Equal(t, http.StatusSeeOther, resp.StatusCode)
|
||||||
|
loc := resp.Header.Get("Location")
|
||||||
|
assert.True(t, strings.HasPrefix(loc, "/compose/rsmon?ok=true"), "redirect location=%q", loc)
|
||||||
|
|
||||||
|
assert.True(t, auditHasAction(t, srv, "compose_restart", "compose:rsmon"),
|
||||||
|
"audit row for project restart expected")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestComposeProjectAction_APIBasicAuthAndAudit(t *testing.T) {
|
||||||
|
srv := newComposeTestServerBasicAuth(t, &stubRunner{id: "w-1"}, "alice", "s3cret")
|
||||||
|
injectComposeSnapshot(t, srv, t.TempDir())
|
||||||
|
stubDockerOK(t)
|
||||||
|
ts := newHTTPTestServer(t, srv)
|
||||||
|
|
||||||
|
req, _ := http.NewRequest(http.MethodPost, ts.URL+"/web/api/compose/rsmon/restart", nil)
|
||||||
|
req.SetBasicAuth("alice", "s3cret")
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer resp.Body.Close() //nolint:errcheck
|
||||||
|
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
|
var res compose.ManagementResult
|
||||||
|
require.NoError(t, json.NewDecoder(resp.Body).Decode(&res))
|
||||||
|
assert.True(t, res.OK)
|
||||||
|
assert.Contains(t, res.Output, "stub:")
|
||||||
|
|
||||||
|
assert.True(t, auditHasAction(t, srv, "compose_restart", "compose:rsmon"),
|
||||||
|
"audit row for API restart expected")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestComposeProjectAction_FailureReportsNotOK(t *testing.T) {
|
||||||
|
srv := newComposeTestServerBasicAuth(t, &stubRunner{id: "w-1"}, "alice", "s3cret")
|
||||||
|
injectComposeSnapshot(t, srv, t.TempDir())
|
||||||
|
stubDockerFail(t)
|
||||||
|
ts := newHTTPTestServer(t, srv)
|
||||||
|
|
||||||
|
req, _ := http.NewRequest(http.MethodPost, ts.URL+"/web/api/compose/rsmon/stop", nil)
|
||||||
|
req.SetBasicAuth("alice", "s3cret")
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer resp.Body.Close() //nolint:errcheck
|
||||||
|
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
|
var res compose.ManagementResult
|
||||||
|
require.NoError(t, json.NewDecoder(resp.Body).Decode(&res))
|
||||||
|
assert.False(t, res.OK)
|
||||||
|
assert.Contains(t, res.Output, "compose boom")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestComposeProjectAction_UnknownActionRejected(t *testing.T) {
|
||||||
|
srv := newComposeTestServer(t, &stubRunner{id: "w-1"})
|
||||||
|
injectComposeSnapshot(t, srv, t.TempDir())
|
||||||
|
ts := newHTTPTestServer(t, srv)
|
||||||
|
|
||||||
|
c, _ := loginAsFirstRun(t, ts.URL, srv)
|
||||||
|
for _, action := range []string{"bogus", "logs"} { // logs is GET-only
|
||||||
|
req, _ := http.NewRequest(http.MethodPost, ts.URL+"/compose/rsmon/"+action, strings.NewReader(""))
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
resp, err := c.Do(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, http.StatusNotFound, resp.StatusCode, "action %q should 404", action)
|
||||||
|
resp.Body.Close() //nolint:errcheck
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestComposeProjectAction_DisabledReturns503(t *testing.T) {
|
||||||
|
srv := newTestServer(t, &stubRunner{id: "w-1"}) // compose disabled
|
||||||
|
injectComposeSnapshot(t, srv, t.TempDir())
|
||||||
|
ts := newHTTPTestServer(t, srv)
|
||||||
|
|
||||||
|
c, _ := loginAsFirstRun(t, ts.URL, srv)
|
||||||
|
req, _ := http.NewRequest(http.MethodPost, ts.URL+"/compose/rsmon/restart", strings.NewReader(""))
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
resp, err := c.Do(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer resp.Body.Close() //nolint:errcheck
|
||||||
|
assert.Equal(t, http.StatusServiceUnavailable, resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestComposeServiceAction_HTMLRedirectAndAudit(t *testing.T) {
|
||||||
|
srv := newComposeTestServer(t, &stubRunner{id: "w-1"})
|
||||||
|
injectComposeSnapshot(t, srv, t.TempDir())
|
||||||
|
stubDockerOK(t)
|
||||||
|
ts := newHTTPTestServer(t, srv)
|
||||||
|
|
||||||
|
c, _ := loginAsFirstRun(t, ts.URL, srv)
|
||||||
|
detail, err := c.Get(ts.URL + "/compose/rsmon")
|
||||||
|
require.NoError(t, err)
|
||||||
|
csrf := extractCSRFToken(t, mustBody(t, detail))
|
||||||
|
|
||||||
|
form := url.Values{}
|
||||||
|
form.Set("csrf_token", csrf)
|
||||||
|
req, _ := http.NewRequest(http.MethodPost, ts.URL+"/compose/rsmon/service/web/restart", strings.NewReader(form.Encode()))
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
resp, err := c.Do(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer resp.Body.Close() //nolint:errcheck
|
||||||
|
assert.Equal(t, http.StatusSeeOther, resp.StatusCode)
|
||||||
|
assert.True(t, strings.HasPrefix(resp.Header.Get("Location"), "/compose/rsmon"))
|
||||||
|
assert.True(t, auditHasAction(t, srv, "compose_restart", "compose:rsmon/web"),
|
||||||
|
"audit row for service restart expected")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestComposeServiceAction_UnknownActionRejected(t *testing.T) {
|
||||||
|
srv := newComposeTestServer(t, &stubRunner{id: "w-1"})
|
||||||
|
injectComposeSnapshot(t, srv, t.TempDir())
|
||||||
|
ts := newHTTPTestServer(t, srv)
|
||||||
|
|
||||||
|
c, _ := loginAsFirstRun(t, ts.URL, srv)
|
||||||
|
req, _ := http.NewRequest(http.MethodPost, ts.URL+"/compose/rsmon/service/web/bogus", strings.NewReader(""))
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
resp, err := c.Do(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer resp.Body.Close() //nolint:errcheck
|
||||||
|
assert.Equal(t, http.StatusNotFound, resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestComposeLogs_HTMLPage(t *testing.T) {
|
||||||
|
srv := newComposeTestServer(t, &stubRunner{id: "w-1"})
|
||||||
|
injectComposeSnapshot(t, srv, t.TempDir())
|
||||||
|
stubDockerOK(t)
|
||||||
|
ts := newHTTPTestServer(t, srv)
|
||||||
|
|
||||||
|
c, _ := loginAsFirstRun(t, ts.URL, srv)
|
||||||
|
resp, err := c.Get(ts.URL + "/compose/rsmon/logs")
|
||||||
|
require.NoError(t, err)
|
||||||
|
body := mustBody(t, resp)
|
||||||
|
assert.Contains(t, body, "Compose logs: rsmon")
|
||||||
|
assert.Contains(t, body, "stub:")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestComposeLogs_API_JSONAndNotFound(t *testing.T) {
|
||||||
|
srv := newComposeTestServerBasicAuth(t, &stubRunner{id: "w-1"}, "alice", "s3cret")
|
||||||
|
injectComposeSnapshot(t, srv, t.TempDir())
|
||||||
|
stubDockerOK(t)
|
||||||
|
ts := newHTTPTestServer(t, srv)
|
||||||
|
|
||||||
|
req, _ := http.NewRequest(http.MethodGet, ts.URL+"/web/api/compose/rsmon/logs", nil)
|
||||||
|
req.SetBasicAuth("alice", "s3cret")
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer resp.Body.Close() //nolint:errcheck
|
||||||
|
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
var res compose.ManagementResult
|
||||||
|
require.NoError(t, json.NewDecoder(resp.Body).Decode(&res))
|
||||||
|
assert.Contains(t, res.Output, "stub:")
|
||||||
|
|
||||||
|
req2, _ := http.NewRequest(http.MethodGet, ts.URL+"/web/api/compose/missing/logs", nil)
|
||||||
|
req2.SetBasicAuth("alice", "s3cret")
|
||||||
|
resp2, err := http.DefaultClient.Do(req2)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer resp2.Body.Close() //nolint:errcheck
|
||||||
|
assert.Equal(t, http.StatusNotFound, resp2.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidProjectPostAction(t *testing.T) {
|
||||||
|
for _, a := range []string{"up", "down", "stop", "restart", "pull"} {
|
||||||
|
assert.True(t, validProjectPostAction(a), "%q should be POST-able", a)
|
||||||
|
}
|
||||||
|
assert.False(t, validProjectPostAction("logs"), "logs is GET-only")
|
||||||
|
assert.False(t, validProjectPostAction("bogus"), "bogus is invalid")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestComposeAPIList_RequiresAuth(t *testing.T) {
|
||||||
|
srv := newComposeTestServerBasicAuth(t, &stubRunner{id: "w-1"}, "alice", "s3cret")
|
||||||
|
injectComposeSnapshot(t, srv, t.TempDir())
|
||||||
|
ts := newHTTPTestServer(t, srv)
|
||||||
|
|
||||||
|
// No credentials: the basic-auth fast path returns 401.
|
||||||
|
resp, err := http.Get(ts.URL + "/web/api/compose")
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer resp.Body.Close() //nolint:errcheck
|
||||||
|
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
||||||
|
}
|
||||||
@@ -51,6 +51,23 @@ func (s *Server) routes() {
|
|||||||
s.mux.Handle("POST /settings/rotate-token", s.requireSession(s.handleRotateToken))
|
s.mux.Handle("POST /settings/rotate-token", s.requireSession(s.handleRotateToken))
|
||||||
s.mux.Handle("GET /updates", s.requireSession(s.handleUpdates))
|
s.mux.Handle("GET /updates", s.requireSession(s.handleUpdates))
|
||||||
|
|
||||||
|
// Docker Compose discovery + management. The list/detail/logs pages
|
||||||
|
// are session-protected HTML; the lifecycle endpoints accept either a
|
||||||
|
// session (browser, CSRF-checked in the handler) or HTTP basic auth
|
||||||
|
// (scripting, on the /web/api/compose/* prefix). Compose is disabled
|
||||||
|
// per host via WORKER_COMPOSE_ENABLED=false; when off, the management
|
||||||
|
// handlers return 503 and the list page renders a banner.
|
||||||
|
s.mux.Handle("GET /compose", s.requireSession(s.handleComposeList))
|
||||||
|
s.mux.Handle("GET /compose/{project}", s.requireSession(s.handleComposeDetail))
|
||||||
|
s.mux.Handle("GET /compose/{project}/logs", s.requireSession(s.handleComposeLogsPage))
|
||||||
|
s.mux.Handle("POST /compose/{project}/{action}", s.requireSession(s.handleComposeProjectAction))
|
||||||
|
s.mux.Handle("POST /compose/{project}/service/{service}/{action}", s.requireSession(s.handleComposeServiceAction))
|
||||||
|
s.mux.Handle("GET /web/api/compose", s.requireSession(s.handleComposeAPIList))
|
||||||
|
s.mux.Handle("GET /web/api/compose/{project}", s.requireSession(s.handleComposeAPIDetail))
|
||||||
|
s.mux.Handle("GET /web/api/compose/{project}/logs", s.requireSession(s.handleComposeLogsAPI))
|
||||||
|
s.mux.Handle("POST /web/api/compose/{project}/{action}", s.requireSession(s.handleComposeProjectAction))
|
||||||
|
s.mux.Handle("POST /web/api/compose/{project}/service/{service}/{action}", s.requireSession(s.handleComposeServiceAction))
|
||||||
|
|
||||||
// Health endpoint for the cmd health subcommand and for the
|
// Health endpoint for the cmd health subcommand and for the
|
||||||
// operator to confirm the listener is up without going through the
|
// operator to confirm the listener is up without going through the
|
||||||
// login form. Returns 200 with a tiny body.
|
// login form. Returns 200 with a tiny body.
|
||||||
|
|||||||
@@ -87,6 +87,14 @@ type Config struct {
|
|||||||
// "tag_name" field (GitHub release JSON is the canonical
|
// "tag_name" field (GitHub release JSON is the canonical
|
||||||
// shape). WORKER_RELEASE_URL sets this.
|
// shape). WORKER_RELEASE_URL sets this.
|
||||||
ReleaseURL string
|
ReleaseURL string
|
||||||
|
|
||||||
|
// ComposeEnabled turns the Docker Compose discovery + management
|
||||||
|
// subsystem on. Defaults to true (WORKER_COMPOSE_ENABLED=false to
|
||||||
|
// disable): the refresher no-ops when the Docker daemon is absent,
|
||||||
|
// so leaving it on is safe on non-Docker hosts. When enabled, the
|
||||||
|
// /compose page lists every Compose project on the host and the
|
||||||
|
// /web/api/compose/* endpoints drive up/down/stop/restart/pull.
|
||||||
|
ComposeEnabled bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// ValidateBasicAuth enforces that WORKER_LOGIN and WORKER_PASSWORD
|
// ValidateBasicAuth enforces that WORKER_LOGIN and WORKER_PASSWORD
|
||||||
@@ -177,9 +185,23 @@ func ConfigFromEnv(env map[string]string, defaultDataDir string) (Config, error)
|
|||||||
cfg.StorePath = v
|
cfg.StorePath = v
|
||||||
}
|
}
|
||||||
cfg.ReleaseURL = strings.TrimSpace(env[envReleaseURL])
|
cfg.ReleaseURL = strings.TrimSpace(env[envReleaseURL])
|
||||||
|
// Compose discovery defaults to on; only an explicit false disables it.
|
||||||
|
cfg.ComposeEnabled = !parseBoolFalseDefault(env[envComposeEnabled])
|
||||||
return cfg, nil
|
return cfg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// parseBoolFalseDefault returns true for any value that is not an
|
||||||
|
// explicit falsy literal. Used by ComposeEnabled so that the default
|
||||||
|
// (unset env var) keeps the feature on, unlike parseBool where the
|
||||||
|
// default is false.
|
||||||
|
func parseBoolFalseDefault(v string) bool {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(v)) {
|
||||||
|
case "false", "0", "no", "off", "":
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
// parseBool returns true for the strings "true", "1", "yes" (any
|
// parseBool returns true for the strings "true", "1", "yes" (any
|
||||||
// case, trimmed). Anything else is false. Used for opt-in feature
|
// case, trimmed). Anything else is false. Used for opt-in feature
|
||||||
// flags wired through env vars without dragging in a config-package
|
// flags wired through env vars without dragging in a config-package
|
||||||
@@ -201,6 +223,7 @@ func ConfigFromEnvOrDefault() Config {
|
|||||||
envWorkerHost, envWorkerPort, envWorkerURL, envWorkerLogin, envWorkerPassword,
|
envWorkerHost, envWorkerPort, envWorkerURL, envWorkerLogin, envWorkerPassword,
|
||||||
"RSMON_WEBAPP_DATA_DIR", "RSMON_WEBAPP_STORE_PATH",
|
"RSMON_WEBAPP_DATA_DIR", "RSMON_WEBAPP_STORE_PATH",
|
||||||
"WORKER_CLUSTER_ENABLED",
|
"WORKER_CLUSTER_ENABLED",
|
||||||
|
envComposeEnabled,
|
||||||
envReleaseURL,
|
envReleaseURL,
|
||||||
} {
|
} {
|
||||||
if v := os.Getenv(k); v != "" {
|
if v := os.Getenv(k); v != "" {
|
||||||
@@ -336,6 +359,7 @@ type Server struct {
|
|||||||
templates *Templates
|
templates *Templates
|
||||||
logBuffer *LogBuffer
|
logBuffer *LogBuffer
|
||||||
inventory *Inventory
|
inventory *Inventory
|
||||||
|
compose *ComposeRefresher
|
||||||
metrics *Metrics
|
metrics *Metrics
|
||||||
cluster ClusterView
|
cluster ClusterView
|
||||||
pruneStop chan struct{}
|
pruneStop chan struct{}
|
||||||
@@ -402,6 +426,7 @@ func New(cfg Config, deps *Deps) (*Server, error) { //nolint:gocritic // Config
|
|||||||
templates: tmpl,
|
templates: tmpl,
|
||||||
logBuffer: NewLogBuffer(5000),
|
logBuffer: NewLogBuffer(5000),
|
||||||
inventory: NewInventory(store, deps.Logger),
|
inventory: NewInventory(store, deps.Logger),
|
||||||
|
compose: NewComposeRefresher(cfg.ComposeEnabled, deps.Logger),
|
||||||
metrics: NewMetrics(),
|
metrics: NewMetrics(),
|
||||||
cluster: deps.Cluster,
|
cluster: deps.Cluster,
|
||||||
pruneStop: make(chan struct{}),
|
pruneStop: make(chan struct{}),
|
||||||
@@ -460,6 +485,10 @@ func (s *Server) SetCluster(c ClusterView) { s.cluster = c }
|
|||||||
// Cluster returns the attached cluster subsystem (or nil).
|
// Cluster returns the attached cluster subsystem (or nil).
|
||||||
func (s *Server) Cluster() ClusterView { return s.cluster }
|
func (s *Server) Cluster() ClusterView { return s.cluster }
|
||||||
|
|
||||||
|
// Compose returns the Compose discovery/management refresher so tests
|
||||||
|
// and the cmd binary can drive it (e.g. inject a fixture discovery).
|
||||||
|
func (s *Server) Compose() *ComposeRefresher { return s.compose }
|
||||||
|
|
||||||
// Close shuts down the HTTP listener, the prune goroutine, and the
|
// Close shuts down the HTTP listener, the prune goroutine, and the
|
||||||
// embedded store. Safe to call multiple times.
|
// embedded store. Safe to call multiple times.
|
||||||
func (s *Server) Close(ctx context.Context) error {
|
func (s *Server) Close(ctx context.Context) error {
|
||||||
@@ -472,6 +501,9 @@ func (s *Server) Close(ctx context.Context) error {
|
|||||||
close(s.pruneStop)
|
close(s.pruneStop)
|
||||||
}
|
}
|
||||||
s.pruneWG.Wait()
|
s.pruneWG.Wait()
|
||||||
|
if s.compose != nil {
|
||||||
|
s.compose.Stop()
|
||||||
|
}
|
||||||
if s.httpServer != nil {
|
if s.httpServer != nil {
|
||||||
shutdownCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
shutdownCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
@@ -493,6 +525,12 @@ func (s *Server) Start(ctx context.Context) error {
|
|||||||
s.pruneWG.Add(1)
|
s.pruneWG.Add(1)
|
||||||
go s.pruneLoop(ctx)
|
go s.pruneLoop(ctx)
|
||||||
|
|
||||||
|
// Start the Compose discovery loop alongside the listener. It no-ops
|
||||||
|
// when disabled or when Docker is absent, so it is always safe to
|
||||||
|
// start. The first refresh runs immediately so /compose has data on
|
||||||
|
// the first request.
|
||||||
|
s.compose.Start(ctx)
|
||||||
|
|
||||||
// Run ListenAndServe in a goroutine so we can race it against ctx.
|
// Run ListenAndServe in a goroutine so we can race it against ctx.
|
||||||
errCh := make(chan error, 1)
|
errCh := make(chan error, 1)
|
||||||
go func() {
|
go func() {
|
||||||
|
|||||||
44
internal/webapp/templates/compose.html
Обычный файл
44
internal/webapp/templates/compose.html
Обычный файл
@@ -0,0 +1,44 @@
|
|||||||
|
{{define "body"}}<section class="card">
|
||||||
|
<h1>Docker Compose projects</h1>
|
||||||
|
{{if not .Enabled}}
|
||||||
|
<p class="muted">Compose management is disabled (WORKER_COMPOSE_ENABLED=false). Re-enable it and restart the worker to see projects here.</p>
|
||||||
|
{{else}}
|
||||||
|
<p class="muted">Discovered from <code>docker compose ls</code> and <code>docker ps</code> labels, refreshed every 60s. Lifecycle actions (up/down/stop/restart/pull) run <code>docker compose</code> in each project's working directory.</p>
|
||||||
|
{{if .LastAt.IsZero}}
|
||||||
|
<p class="muted">No refresh yet — the first scan runs within 60s of start.</p>
|
||||||
|
{{else}}
|
||||||
|
<p class="muted">Last refresh: {{fmtTime .LastAt}}</p>
|
||||||
|
{{end}}
|
||||||
|
{{range .Snapshot.Errors}}
|
||||||
|
<p class="muted">⚠ {{.}}</p>
|
||||||
|
{{end}}
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Project</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Services</th>
|
||||||
|
<th>Containers</th>
|
||||||
|
<th>Running</th>
|
||||||
|
<th>Compose file</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .Snapshot.Projects}}
|
||||||
|
<tr>
|
||||||
|
<td>{{.Name}}</td>
|
||||||
|
<td>{{.Status}}</td>
|
||||||
|
<td>{{.ServiceCount}}</td>
|
||||||
|
<td>{{.ContainerCount}}</td>
|
||||||
|
<td>{{.RunningCount}}</td>
|
||||||
|
<td>{{.ConfigFiles}}</td>
|
||||||
|
<td><a href="/compose/{{.Name}}">details</a></td>
|
||||||
|
</tr>
|
||||||
|
{{else}}
|
||||||
|
<tr><td colspan="7" class="muted">No Compose projects discovered.</td></tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{{end}}
|
||||||
|
</section>{{end}}
|
||||||
81
internal/webapp/templates/compose_detail.html
Обычный файл
81
internal/webapp/templates/compose_detail.html
Обычный файл
@@ -0,0 +1,81 @@
|
|||||||
|
{{define "body"}}<section class="card">
|
||||||
|
<h1>{{.Project.Name}}</h1>
|
||||||
|
<p class="muted">Working dir: <code>{{.Project.WorkingDir}}</code>{{if .Project.ConfigFiles}} · compose file: <code>{{.Project.ConfigFiles}}</code>{{end}} · last refresh: {{fmtTime .LastAt}}</p>
|
||||||
|
|
||||||
|
{{if .ActionMessage}}
|
||||||
|
<p class="{{if .ActionOK}}muted{{else}}muted{{end}}">{{if .ActionOK}}✓{{else}}⚠{{end}} {{.ActionMessage}}</p>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<h2>Project actions</h2>
|
||||||
|
<div class="cards">
|
||||||
|
<form action="/compose/{{.Project.Name}}/up" method="post"><input type="hidden" name="csrf_token" value="{{.CSRFToken}}"><button type="submit">Up</button></form>
|
||||||
|
<form action="/compose/{{.Project.Name}}/restart" method="post"><input type="hidden" name="csrf_token" value="{{.CSRFToken}}"><button type="submit">Restart</button></form>
|
||||||
|
<form action="/compose/{{.Project.Name}}/pull" method="post"><input type="hidden" name="csrf_token" value="{{.CSRFToken}}"><button type="submit">Pull</button></form>
|
||||||
|
<form action="/compose/{{.Project.Name}}/stop" method="post"><input type="hidden" name="csrf_token" value="{{.CSRFToken}}"><button type="submit">Stop</button></form>
|
||||||
|
<form action="/compose/{{.Project.Name}}/down" method="post"><input type="hidden" name="csrf_token" value="{{.CSRFToken}}"><button type="submit">Down</button></form>
|
||||||
|
<a href="/compose/{{.Project.Name}}/logs"><button type="button">Logs</button></a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>Services ({{.Project.ServiceCount}})</h2>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Service</th><th>Container</th><th>Image</th><th>State</th><th>Status</th><th>Health</th><th>PID</th><th>Ports</th><th>Actions</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{{$root := .}}
|
||||||
|
{{range .Project.Services}}
|
||||||
|
<tr>
|
||||||
|
<td>{{.Name}}</td>
|
||||||
|
<td>{{range .Containers}}{{.Name}}<br>{{end}}</td>
|
||||||
|
<td>{{range .Containers}}{{.Image}}<br>{{end}}</td>
|
||||||
|
<td>{{range .Containers}}{{.State}}<br>{{end}}</td>
|
||||||
|
<td>{{range .Containers}}{{.Status}}<br>{{end}}</td>
|
||||||
|
<td>{{range .Containers}}{{.Health}}<br>{{end}}</td>
|
||||||
|
<td>{{range .Containers}}{{.PID}}<br>{{end}}</td>
|
||||||
|
<td>{{range .Containers}}{{range .Ports}}{{.HostPort}}<br>{{end}}{{end}}</td>
|
||||||
|
<td>
|
||||||
|
<form action="/compose/{{$root.Project.Name}}/service/{{.Name}}/up" method="post" style="display:inline"><input type="hidden" name="csrf_token" value="{{$root.CSRFToken}}"><button type="submit">up</button></form>
|
||||||
|
<form action="/compose/{{$root.Project.Name}}/service/{{.Name}}/restart" method="post" style="display:inline"><input type="hidden" name="csrf_token" value="{{$root.CSRFToken}}"><button type="submit">restart</button></form>
|
||||||
|
<form action="/compose/{{$root.Project.Name}}/service/{{.Name}}/stop" method="post" style="display:inline"><input type="hidden" name="csrf_token" value="{{$root.CSRFToken}}"><button type="submit">stop</button></form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{{else}}
|
||||||
|
<tr><td colspan="9" class="muted">No services discovered for this project.</td></tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
{{if .Project.GroupedMounts}}
|
||||||
|
<h2>Mounts</h2>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Source</th><th>Destination</th><th>Type</th><th>Used by</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .Project.GroupedMounts}}
|
||||||
|
<tr>
|
||||||
|
<td>{{.Source}}</td>
|
||||||
|
<td>{{.Destination}}</td>
|
||||||
|
<td>{{.Type}}</td>
|
||||||
|
<td>{{.SharedMountSummary}}</td>
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{if .Project.AllTraefikRoutes}}
|
||||||
|
<h2>Traefik routes</h2>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Router</th><th>Service</th><th>Container</th><th>Hostnames</th><th>Prefixes</th><th>Rule</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .Project.AllTraefikRoutes}}
|
||||||
|
<tr>
|
||||||
|
<td>{{.RouterName}}</td>
|
||||||
|
<td>{{.Service}}</td>
|
||||||
|
<td>{{.Container}}</td>
|
||||||
|
<td>{{range .Hostnames}}{{.}}<br>{{end}}</td>
|
||||||
|
<td>{{range .PathPrefixes}}{{.}}<br>{{end}}</td>
|
||||||
|
<td><code>{{.Rule}}</code></td>
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{{end}}
|
||||||
|
</section>{{end}}
|
||||||
6
internal/webapp/templates/compose_logs.html
Обычный файл
6
internal/webapp/templates/compose_logs.html
Обычный файл
@@ -0,0 +1,6 @@
|
|||||||
|
{{define "body"}}<section class="card">
|
||||||
|
<h1>Compose logs: {{.Project}}</h1>
|
||||||
|
<p class="muted">Last 300 lines from <code>docker compose logs --no-color --tail 300</code>. Streaming tails are not supported in the web UI yet.</p>
|
||||||
|
<pre class="logs">{{if .Output}}{{.Output}}{{else}}<span class="muted">(no output)</span>{{end}}</pre>
|
||||||
|
<p><a href="/compose/{{.Project}}">← back to {{.Project}}</a></p>
|
||||||
|
</section>{{end}}
|
||||||
@@ -14,6 +14,7 @@
|
|||||||
<nav>
|
<nav>
|
||||||
<a href="/overview">Overview</a>
|
<a href="/overview">Overview</a>
|
||||||
<a href="/apps">Apps</a>
|
<a href="/apps">Apps</a>
|
||||||
|
<a href="/compose">Compose</a>
|
||||||
<a href="/checks">Checks</a>
|
<a href="/checks">Checks</a>
|
||||||
<a href="/notifications">Notifications</a>
|
<a href="/notifications">Notifications</a>
|
||||||
<a href="/logs">Logs</a>
|
<a href="/logs">Logs</a>
|
||||||
|
|||||||
Ссылка в новой задаче
Block a user