@@ -0,0 +1,315 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"connectrpc.com/connect"
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
homelabv1 "pwa-homelab-mon/gen/homelab/v1"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// dockerC is one container as seen by `docker inspect`.
|
||||
type dockerC struct {
|
||||
name, project, status, policy string
|
||||
}
|
||||
|
||||
// --- services: systemd user units + docker containers, one flat list ---
|
||||
|
||||
// Service names double as file/unit/container selectors — no separators, no traversal.
|
||||
var svcNameRe = regexp.MustCompile(`^[a-z0-9_.-]+$`)
|
||||
|
||||
const svcTimeout = 8 * time.Second
|
||||
|
||||
func (s *server) services(ctx context.Context) []*homelabv1.Service {
|
||||
if s.mockSvcs != nil {
|
||||
return s.mockSvcs
|
||||
}
|
||||
return append(s.systemdServices(ctx), s.dockerServices(ctx)...)
|
||||
}
|
||||
|
||||
// logProbeChange logs poll-path probe failures once per change: the poll repeats every 3s per client,
|
||||
// and the same error every tick is noise. msg "" = probe healthy, logs a single recovery line.
|
||||
func (s *server) logProbeChange(key, msg string) {
|
||||
prev, loaded := s.probeErr.LoadOrStore(key, msg)
|
||||
if !loaded {
|
||||
if msg != "" {
|
||||
log.Printf("probe %s: %s", key, msg)
|
||||
}
|
||||
return
|
||||
}
|
||||
if prev.(string) == msg {
|
||||
return
|
||||
}
|
||||
s.probeErr.Store(key, msg)
|
||||
if msg == "" {
|
||||
log.Printf("probe %s: recovered", key)
|
||||
} else {
|
||||
log.Printf("probe %s: %s", key, msg)
|
||||
}
|
||||
}
|
||||
|
||||
func dockerOut(ctx context.Context, args ...string) (string, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, svcTimeout)
|
||||
defer cancel()
|
||||
out, err := exec.CommandContext(ctx, "docker", args...).Output()
|
||||
return string(out), err
|
||||
}
|
||||
|
||||
func (s *server) dockerContainers(ctx context.Context) ([]dockerC, error) {
|
||||
ids, err := dockerOut(ctx, "ps", "-aq")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if strings.TrimSpace(ids) == "" {
|
||||
return nil, nil
|
||||
}
|
||||
out, err := dockerOut(ctx, append([]string{"inspect", "-f",
|
||||
`{{index .Config.Labels "com.docker.compose.project"}}|{{.Name}}|{{.State.Status}}|{{.HostConfig.RestartPolicy.Name}}`},
|
||||
strings.Fields(ids)...)...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var cs []dockerC
|
||||
for _, line := range strings.Split(strings.TrimSpace(out), "\n") {
|
||||
f := strings.Split(line, "|")
|
||||
if len(f) != 4 {
|
||||
continue
|
||||
}
|
||||
cs = append(cs, dockerC{project: f[0], name: strings.TrimPrefix(f[1], "/"), status: f[2], policy: f[3]})
|
||||
}
|
||||
sort.Slice(cs, func(i, j int) bool { return cs[i].name < cs[j].name })
|
||||
return cs, nil
|
||||
}
|
||||
|
||||
// working drops one-shot helpers (restart policy no + already exited: *-init, migrations). They belong to
|
||||
// their project's card but must not drag its aggregate down, and must not be re-run by the start button.
|
||||
func working(cs []dockerC) []dockerC {
|
||||
out := make([]dockerC, 0, len(cs))
|
||||
for _, c := range cs {
|
||||
if c.policy == "no" && c.status != "running" {
|
||||
continue
|
||||
}
|
||||
out = append(out, c)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *server) dockerServices(ctx context.Context) []*homelabv1.Service {
|
||||
cs, err := s.dockerContainers(ctx)
|
||||
if err != nil {
|
||||
s.logProbeChange("docker", err.Error())
|
||||
return []*homelabv1.Service{{Kind: homelabv1.ServiceKind_DOCKER, Name: "docker", Error: err.Error()}}
|
||||
}
|
||||
s.logProbeChange("docker", "")
|
||||
groups := map[string][]dockerC{}
|
||||
var keys []string
|
||||
for _, c := range cs {
|
||||
key := c.project
|
||||
if key == "" {
|
||||
key = c.name // docker run without a compose label -> its own card
|
||||
}
|
||||
if _, ok := groups[key]; !ok {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
groups[key] = append(groups[key], c)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
svcs := make([]*homelabv1.Service, 0, len(keys))
|
||||
for _, k := range keys {
|
||||
if svc := dockerCard(k, working(groups[k])); svc != nil {
|
||||
svcs = append(svcs, svc)
|
||||
}
|
||||
}
|
||||
return svcs
|
||||
}
|
||||
|
||||
// dockerCard aggregates a project: all running -> active, none -> inactive, else partial (up/total).
|
||||
func dockerCard(name string, cs []dockerC) *homelabv1.Service {
|
||||
if len(cs) == 0 {
|
||||
return nil
|
||||
}
|
||||
up := 0
|
||||
names := make([]string, 0, len(cs))
|
||||
for _, c := range cs {
|
||||
if c.status == "running" {
|
||||
up++
|
||||
}
|
||||
names = append(names, c.name)
|
||||
}
|
||||
state := "partial"
|
||||
switch {
|
||||
case up == len(cs):
|
||||
state = "active"
|
||||
case up == 0:
|
||||
state = "inactive"
|
||||
}
|
||||
return &homelabv1.Service{Kind: homelabv1.ServiceKind_DOCKER, Name: name, State: state,
|
||||
Substate: fmt.Sprintf("%d/%d", up, len(cs)), Containers: names}
|
||||
}
|
||||
|
||||
func (s *server) units() ([]string, error) {
|
||||
entries, err := os.ReadDir(s.srvDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
units := make([]string, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
if n := strings.TrimSuffix(e.Name(), ".service"); n != e.Name() && svcNameRe.MatchString(n) {
|
||||
units = append(units, n)
|
||||
}
|
||||
}
|
||||
sort.Strings(units)
|
||||
return units, nil
|
||||
}
|
||||
|
||||
func (s *server) systemdServices(ctx context.Context) []*homelabv1.Service {
|
||||
units, err := s.units()
|
||||
if err != nil {
|
||||
s.logProbeChange("units", err.Error())
|
||||
return []*homelabv1.Service{{Kind: homelabv1.ServiceKind_SYSTEMD, Name: s.srvDir, Error: err.Error()}}
|
||||
}
|
||||
s.logProbeChange("units", "")
|
||||
svcs := make([]*homelabv1.Service, 0, len(units))
|
||||
for _, u := range units {
|
||||
svcs = append(svcs, s.systemdStatus(ctx, u))
|
||||
}
|
||||
return svcs
|
||||
}
|
||||
|
||||
// ponytail: one systemctl per unit (~26 per 3s poll, ~10ms each). Batch into a single
|
||||
// `systemctl show u1 u2 ... -p ...` if the poll ever shows up in a slow trace.
|
||||
func (s *server) systemdStatus(ctx context.Context, unit string) *homelabv1.Service {
|
||||
svc := &homelabv1.Service{Kind: homelabv1.ServiceKind_SYSTEMD, Name: unit}
|
||||
ctx, cancel := context.WithTimeout(ctx, svcTimeout)
|
||||
defer cancel()
|
||||
// show is the machine-readable probe (always exit 0, unlike status). LoadState=not-found means the user
|
||||
// disabled the unit on purpose — a state, not an error.
|
||||
out, err := exec.CommandContext(ctx, "systemctl", "--user", "show", unit+".service",
|
||||
"-p", "ActiveState,SubState,LoadState,UnitFileState").Output()
|
||||
// systemctl prints properties in its own fixed table order (not the -p order), so parse by key
|
||||
props := map[string]string{}
|
||||
for _, line := range strings.Split(string(out), "\n") {
|
||||
if k, v, ok := strings.Cut(strings.TrimSpace(line), "="); ok {
|
||||
props[k] = v
|
||||
}
|
||||
}
|
||||
svc.State, svc.Substate = props["ActiveState"], props["SubState"]
|
||||
svc.LoadState, svc.UnitFileState = props["LoadState"], props["UnitFileState"]
|
||||
if svc.LoadState == "" {
|
||||
svc.LoadState = "not-found"
|
||||
}
|
||||
if err != nil {
|
||||
svc.Error = err.Error()
|
||||
s.logProbeChange("systemctl/"+unit, svc.Error)
|
||||
} else {
|
||||
s.logProbeChange("systemctl/"+unit, "")
|
||||
}
|
||||
return svc
|
||||
}
|
||||
|
||||
// ServiceAction runs a fixed systemctl/docker command; only `name` is user input and it is regex-checked,
|
||||
// never interpolated into a shell string.
|
||||
func (s *server) ServiceAction(ctx context.Context, req *connect.Request[homelabv1.ServiceActionRequest]) (*connect.Response[homelabv1.ServiceActionResponse], error) {
|
||||
name, kind := req.Msg.GetName(), req.Msg.GetKind()
|
||||
if !svcNameRe.MatchString(name) {
|
||||
return nil, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("bad service name %q", name))
|
||||
}
|
||||
var args []string
|
||||
switch req.Msg.GetOp() {
|
||||
case homelabv1.ServiceOp_START:
|
||||
args = []string{"start"}
|
||||
case homelabv1.ServiceOp_STOP:
|
||||
args = []string{"stop"}
|
||||
case homelabv1.ServiceOp_RESTART:
|
||||
args = []string{"restart"}
|
||||
default:
|
||||
return nil, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("bad op for %q", name))
|
||||
}
|
||||
|
||||
if s.mockSvcs != nil {
|
||||
return connect.NewResponse(&homelabv1.ServiceActionResponse{Output: s.mockAct(name, kind, args[0])}), nil
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(ctx, svcTimeout)
|
||||
defer cancel()
|
||||
|
||||
var cmd *exec.Cmd
|
||||
if kind == homelabv1.ServiceKind_DOCKER {
|
||||
targets, err := s.dockerTargets(ctx, name, args[0])
|
||||
if err != nil {
|
||||
return nil, connect.NewError(connect.CodeInternal, err)
|
||||
}
|
||||
if len(targets) == 0 {
|
||||
return connect.NewResponse(&homelabv1.ServiceActionResponse{Output: "no containers to " + args[0]}), nil
|
||||
}
|
||||
cmd = exec.CommandContext(ctx, "docker", append([]string{args[0]}, targets...)...)
|
||||
} else {
|
||||
cmd = exec.CommandContext(ctx, "systemctl", "--user", args[0], name+".service")
|
||||
}
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
log.Printf("service %s/%s %s: ERROR %v: %s", kind, name, args[0], err, out)
|
||||
return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("%v: %s", err, out))
|
||||
}
|
||||
log.Printf("service %s/%s %s -> %s", kind, name, args[0], strings.TrimSpace(string(out)))
|
||||
return connect.NewResponse(&homelabv1.ServiceActionResponse{Output: strings.TrimSpace(string(out))}), nil
|
||||
}
|
||||
|
||||
// dockerTargets resolves a card name (compose project, or bare container name) to the containers an op applies to.
|
||||
func (s *server) dockerTargets(ctx context.Context, name, op string) ([]string, error) {
|
||||
cs, err := s.dockerContainers(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var targets []string
|
||||
for _, c := range working(cs) {
|
||||
if c.project != name && c.name != name {
|
||||
continue
|
||||
}
|
||||
if running := c.status == "running"; (op == "start" && !running) || (op != "start" && running) {
|
||||
targets = append(targets, c.name)
|
||||
}
|
||||
}
|
||||
return targets, nil
|
||||
}
|
||||
|
||||
// ServiceInfo returns raw status output for the modal: `systemctl status` / `docker ps -a` for the card.
|
||||
func (s *server) ServiceInfo(ctx context.Context, req *connect.Request[homelabv1.ServiceInfoRequest]) (*connect.Response[homelabv1.ServiceInfoResponse], error) {
|
||||
name, kind := req.Msg.GetName(), req.Msg.GetKind()
|
||||
if !svcNameRe.MatchString(name) {
|
||||
return nil, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("bad service name %q", name))
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, svcTimeout)
|
||||
defer cancel()
|
||||
|
||||
if s.mockSvcs != nil {
|
||||
return connect.NewResponse(&homelabv1.ServiceInfoResponse{Output: s.mockInfo(name, kind)}), nil
|
||||
}
|
||||
|
||||
var out string
|
||||
var err error
|
||||
if kind == homelabv1.ServiceKind_DOCKER {
|
||||
// compose project first, bare container (no label) as fallback
|
||||
out, err = dockerOut(ctx, "ps", "-a", "--filter", "label=com.docker.compose.project="+name)
|
||||
if strings.TrimSpace(out) == "" {
|
||||
out, err = dockerOut(ctx, "ps", "-a", "--filter", "name="+name)
|
||||
}
|
||||
} else {
|
||||
// status exits non-zero for inactive units (code 3) — that is valid output, not an error
|
||||
c := exec.CommandContext(ctx, "systemctl", "--user", "status", name+".service", "--no-pager", "-n", "0")
|
||||
b, cerr := c.CombinedOutput()
|
||||
out, err = string(b), cerr
|
||||
}
|
||||
if err != nil && strings.TrimSpace(out) == "" {
|
||||
log.Printf("service info %s/%s: ERROR %v", kind, name, err)
|
||||
return nil, connect.NewError(connect.CodeInternal, err)
|
||||
}
|
||||
log.Printf("service info %s/%s ok", kind, name)
|
||||
return connect.NewResponse(&homelabv1.ServiceInfoResponse{Output: strings.TrimRight(out, "\n")}), nil
|
||||
}
|
||||
Reference in New Issue
Block a user