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}) }