Files
jarvis-home/cmd/homehub/docker_controller.go
T
2026-08-29 17:02:35 +02:00

181 lines
5.9 KiB
Go

package main
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/url"
"sort"
"strings"
"time"
)
type dockerContainerSummary struct {
ID string `json:"Id"`
Names []string `json:"Names"`
Image string `json:"Image"`
State string `json:"State"`
Status string `json:"Status"`
Labels map[string]string `json:"Labels"`
}
type dockerSkillService struct {
ID string `json:"id"`
Name string `json:"name"`
Image string `json:"image"`
State string `json:"state"`
Status string `json:"status"`
Runtime string `json:"runtime,omitempty"`
WorkerName string `json:"worker_name,omitempty"`
Labels map[string]string `json:"labels,omitempty"`
}
type dockerControllerStatus struct {
Enabled bool `json:"enabled"`
Available bool `json:"available"`
Socket string `json:"socket"`
LabelKey string `json:"label_key"`
LabelValue string `json:"label_value"`
Error string `json:"error,omitempty"`
Services []dockerSkillService `json:"services"`
}
type dockerSkillController struct {
app *app
enabled bool
socket string
labelKey string
labelValue string
client *http.Client
}
func newDockerSkillController(a *app) *dockerSkillController {
socket := env("JARVIS_DOCKER_SOCKET", "/var/run/docker.sock")
tr := &http.Transport{DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
return (&net.Dialer{Timeout: 3 * time.Second}).DialContext(ctx, "unix", socket)
}}
return &dockerSkillController{
app: a,
enabled: envBool("JARVIS_DOCKER_CONTROLLER_ENABLED", false),
socket: socket,
labelKey: strings.TrimSpace(env("JARVIS_DOCKER_SKILL_LABEL", "com.jarvis.skill-service")),
labelValue: strings.TrimSpace(env("JARVIS_DOCKER_SKILL_LABEL_VALUE", "true")),
client: &http.Client{Transport: tr, Timeout: 8 * time.Second},
}
}
func (d *dockerSkillController) Status(ctx context.Context) dockerControllerStatus {
st := dockerControllerStatus{Enabled: d.enabled, Socket: d.socket, LabelKey: d.labelKey, LabelValue: d.labelValue}
if d == nil || !d.enabled {
return st
}
list, err := d.list(ctx)
if err != nil {
st.Error = err.Error()
return st
}
st.Available = true
st.Services = list
return st
}
func (d *dockerSkillController) list(ctx context.Context) ([]dockerSkillService, error) {
if d == nil || !d.enabled {
return nil, errors.New("Docker-Controller deaktiviert")
}
filters := map[string][]string{"label": {d.labelKey + "=" + d.labelValue}}
fb, _ := json.Marshal(filters)
endpoint := "http://docker/containers/json?all=1&filters=" + url.QueryEscape(string(fb))
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
resp, err := d.client.Do(req)
if err != nil {
return nil, fmt.Errorf("docker.sock nicht erreichbar: %w", err)
}
defer resp.Body.Close()
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 2<<20))
if resp.StatusCode != 200 {
return nil, fmt.Errorf("Docker API HTTP %d: %s", resp.StatusCode, clipSkillString(string(raw), 800))
}
var cs []dockerContainerSummary
if err := json.Unmarshal(raw, &cs); err != nil {
return nil, err
}
out := make([]dockerSkillService, 0, len(cs))
for _, c := range cs {
if c.Labels[d.labelKey] != d.labelValue {
continue
}
name := ""
if len(c.Names) > 0 {
name = strings.TrimPrefix(c.Names[0], "/")
}
out = append(out, dockerSkillService{ID: c.ID, Name: name, Image: c.Image, State: c.State, Status: c.Status, Runtime: c.Labels["com.jarvis.skill-runtime"], WorkerName: c.Labels["com.jarvis.worker-name"], Labels: map[string]string{d.labelKey: c.Labels[d.labelKey], "com.jarvis.skill-runtime": c.Labels["com.jarvis.skill-runtime"], "com.jarvis.worker-name": c.Labels["com.jarvis.worker-name"]}})
}
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return out, nil
}
func (d *dockerSkillController) control(ctx context.Context, idv, action string) error {
if d == nil || !d.enabled {
return errors.New("Docker-Controller deaktiviert")
}
services, err := d.list(ctx)
if err != nil {
return err
}
var selected *dockerSkillService
for i := range services {
if services[i].ID == idv {
selected = &services[i]
break
}
}
if selected == nil {
return errors.New("Container ist nicht für JARVIS Skill Control freigegeben")
}
var path string
switch action {
case "start":
path = "/containers/" + url.PathEscape(idv) + "/start"
case "stop":
path = "/containers/" + url.PathEscape(idv) + "/stop?t=10"
case "restart":
path = "/containers/" + url.PathEscape(idv) + "/restart?t=10"
default:
return errors.New("unbekannte Docker-Aktion")
}
req, _ := http.NewRequestWithContext(ctx, http.MethodPost, "http://docker"+path, bytes.NewReader(nil))
started := time.Now()
resp, err := d.client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("Docker API HTTP %d: %s", resp.StatusCode, clipSkillString(strings.TrimSpace(string(raw)), 600))
}
if d.app != nil {
d.app.traceTimed(ctx, "docker", "skill_service_control", "output", map[string]any{"container_id": idv, "container": selected.Name, "action": action}, started)
}
return nil
}
func (d *dockerSkillController) handleList(w http.ResponseWriter, r *http.Request) {
writeJSON(w, 200, d.Status(r.Context()))
}
func (d *dockerSkillController) handleControl(w http.ResponseWriter, r *http.Request) {
action := r.PathValue("action")
idv := r.PathValue("id")
if err := d.control(r.Context(), idv, action); err != nil {
writeJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
return
}
writeJSON(w, 200, map[string]any{"ok": true, "action": action, "id": idv})
}