package tools import ( "context" "fmt" "strings" "time" "github.com/rcourtman/pulse-go-rewrite/internal/agentcapabilities" "github.com/rcourtman/pulse-go-rewrite/internal/agentexec" "github.com/rcourtman/pulse-go-rewrite/internal/ai/safety" "github.com/rcourtman/pulse-go-rewrite/internal/models" "github.com/rs/zerolog/log" ) const ( // dockerUpdateQueueMaxAttempts bounds retries for queuing update-related commands. dockerUpdateQueueMaxAttempts = 3 // dockerUpdateQueueRetryBaseDelay is the initial exponential-backoff delay for retryable queue errors. dockerUpdateQueueRetryBaseDelay = 25 * time.Millisecond // dockerUpdateQueueRetryMaxDelay caps exponential backoff for queue retries. dockerUpdateQueueRetryMaxDelay = 250 * time.Millisecond // dockerUpdateVerifyMaxAttempts bounds post-queue verification polling of the // update command status. Combined with dockerUpdateVerifyPollInterval this // gives the agent one report cycle (~30s) to pick the command up plus time // for small-image updates to finish; slower updates report as inconclusive. dockerUpdateVerifyMaxAttempts = 30 // dockerUpdateVerifyPollInterval is the delay between verification polls. dockerUpdateVerifyPollInterval = 2 * time.Second ) // Terminal docker host command statuses, mirroring the DockerCommandStatus* // constants in internal/monitoring. Duplicated as literals because the tools // package sits below monitoring in the wiring (monitoring satisfies the // UpdatesCommandRunner interface defined here). const ( dockerCommandStatusCompleted = "completed" dockerCommandStatusFailed = "failed" dockerCommandStatusExpired = "expired" ) var ( dockerUpdateQueueSleepFn = sleepWithContext dockerUpdateVerifySleepFn = sleepWithContext ) // registerDockerTools registers the pulse_docker tool func (e *PulseToolExecutor) registerDockerTools() { e.registry.registerBuiltin(RegisteredTool{ Definition: Tool{ Name: agentcapabilities.PulseDockerToolName, Description: `Read host-scoped Docker update posture and Swarm state. Every advertised action requires the Docker host name or ID; use the host identity from the current resource context. Container control is planned through pulse_control using a canonical resource capability. Container updates remain unavailable until durable delivery and compensation contracts are complete.`, InputSchema: InputSchema{ Type: "object", Properties: map[string]PropertySchema{ "action": { Type: "string", Description: "Docker action to perform; every action is scoped to the required host", Enum: []string{"updates", "check_updates", "services", "tasks", "swarm"}, }, "container": { Type: "string", Description: "Container name or ID (for control, update)", }, "host": { Type: "string", Description: "Required Docker host name or ID for every action", }, "operation": { Type: "string", Description: "Control operation: start, stop, restart (for action: control)", Enum: []string{"start", "stop", "restart"}, }, "service": { Type: "string", Description: "Filter by service name or ID (for tasks)", }, "stack": { Type: "string", Description: "Filter by stack name (for services)", }, }, Required: []string{"action", "host"}, }, }, Handler: func(ctx context.Context, exec *PulseToolExecutor, args map[string]interface{}) (CallToolResult, error) { return exec.executeDocker(ctx, args) }, Governance: ToolGovernance{ ActionMode: ToolActionRead, ApprovalPolicy: ToolApprovalScopeOnly, Summary: "Reads Docker state; mutations use typed resource actions or remain denied.", }, }) } // executeDocker routes to the appropriate docker handler based on action func (e *PulseToolExecutor) executeDocker(ctx context.Context, args map[string]interface{}) (CallToolResult, error) { action, _ := args["action"].(string) switch action { case "updates": return e.executeListDockerUpdates(ctx, args) case "check_updates": return e.executeCheckDockerUpdates(ctx, args) case "services": return e.executeListDockerServices(ctx, args) case "tasks": return e.executeListDockerTasks(ctx, args) case "swarm": return e.executeGetSwarmStatus(ctx, args) default: return NewErrorResult(fmt.Errorf("Docker action %q is unavailable; use read actions or pulse_control type=resource for advertised lifecycle capabilities", action)), nil } } // executeDockerControl handles start/stop/restart of Docker containers func (e *PulseToolExecutor) executeDockerControl(ctx context.Context, args map[string]interface{}) (CallToolResult, error) { containerName, _ := args["container"].(string) hostName, _ := args["host"].(string) operation, _ := args["operation"].(string) approvalID := agentcapabilities.ApprovalArgument(args) if containerName == "" { return NewErrorResult(fmt.Errorf("container name is required")), nil } if operation == "" { return NewErrorResult(fmt.Errorf("operation is required (start, stop, restart)")), nil } validOperations := map[string]bool{"start": true, "stop": true, "restart": true} if !validOperations[operation] { return NewErrorResult(fmt.Errorf("invalid operation: %s. Use start, stop, or restart", operation)), nil } // Check if read-only mode if e.controlLevel == ControlLevelReadOnly { return NewTextResult("Docker control actions are not available in read-only mode."), nil } container, dockerHost, err := e.resolveDockerContainer(containerName, hostName) if err != nil { return NewTextResult(fmt.Sprintf("Could not find Docker container '%s': %v", containerName, err)), nil } runtimeCommand := dockerRuntimeCommand(dockerHost) command := fmt.Sprintf("%s %s %s", runtimeCommand, operation, shellEscape(container.Name)) approvalTargetID := fmt.Sprintf("%s:%s", dockerHost.ID, container.Name) if dockerHost.ID == "" { approvalTargetID = fmt.Sprintf("%s:%s", dockerHost.Hostname, container.Name) } // Check if this is a pre-approved execution (validated + single-use). preApproved := consumeApprovalWithValidation(args, e.orgID, command, "docker", approvalTargetID) requiresApproval := !e.isAutonomous && (e.controlLevel == ControlLevelControlled || (e.policy != nil && e.policy.Evaluate(command) == agentexec.PolicyRequireApproval)) // Get the agent hostname for approval records agentHostname := e.getAgentHostnameForDockerHost(dockerHost) // Skip approval checks if pre-approved if !preApproved && e.policy != nil { decision := e.policy.Evaluate(command) if decision == agentexec.PolicyBlock { return NewTextResult(formatPolicyBlocked(command, "This command is blocked by security policy")), nil } if decision == agentexec.PolicyRequireApproval && !e.isAutonomous { approvalID := e.createApprovalRecord(command, "docker", approvalTargetID, agentHostname, fmt.Sprintf("%s Docker container %s", operation, container.Name)) return NewTextResult(formatDockerApprovalNeeded(container.Name, dockerHost.Hostname, operation, command, approvalID)), nil } } // Check control level if !preApproved && e.controlLevel == ControlLevelControlled { approvalID := e.createApprovalRecord(command, "docker", approvalTargetID, agentHostname, fmt.Sprintf("%s Docker container %s", operation, container.Name)) return NewTextResult(formatDockerApprovalNeeded(container.Name, dockerHost.Hostname, operation, command, approvalID)), nil } if e.agentServer == nil { return NewErrorResult(fmt.Errorf("no agent server available")), nil } // Resolve the Docker host to the correct agent and routing info (with full provenance) routing := e.resolveDockerHostRoutingFull(dockerHost) if routing.AgentID == "" { if routing.TargetType == "container" || routing.TargetType == "vm" { return NewTextResult(fmt.Sprintf("Docker host '%s' is a %s but no agent is available on its Proxmox host. Install Pulse Unified Agent on the Proxmox node.", dockerHost.Hostname, routing.TargetType)), nil } return NewTextResult(fmt.Sprintf("No agent available on Docker host '%s'. Install Pulse Unified Agent on the host to enable control.", dockerHost.Hostname)), nil } log.Debug(). Str("docker_host", dockerHost.Hostname). Str("agent_id", routing.AgentID). Str("agent_host", routing.AgentHostname). Str("resolved_kind", routing.ResolvedKind). Str("resolved_node", routing.ResolvedNode). Str("transport", routing.Transport). Str("target_type", routing.TargetType). Str("target_id", routing.TargetID). Msg("[pulse_docker] Routing docker command execution") result, err := e.executeCommandWithAudit( ctx, "pulse_docker", func() string { if dockerHost.ID != "" { return fmt.Sprintf("%s:%s", dockerHost.ID, container.Name) } return fmt.Sprintf("%s:%s", dockerHost.Hostname, container.Name) }(), approvalID, requiresApproval, routing.AgentID, agentexec.ExecuteCommandPayload{ Command: command, TargetType: routing.TargetType, TargetID: routing.TargetID, }, "pulse_docker", fmt.Sprintf("%s docker container %s", operation, container.Name), ) if err != nil { return NewErrorResult(err), nil } output := result.Stdout if result.Stderr != "" { output += "\n" + result.Stderr } if redacted, n := safety.RedactSensitiveText(output); n > 0 { output = redacted + fmt.Sprintf("\n\n[redacted %d sensitive value(s)]", n) } verify := e.verifyDockerContainerState(ctx, routing, runtimeCommand, container.ID, operation) verify["ok"] = result.ExitCode == 0 response := map[string]interface{}{ "success": result.ExitCode == 0, "action": "control", "operation": operation, "container": container.Name, "container_id": container.ID, "host": dockerHost.Hostname, "command": command, "exit_code": result.ExitCode, "output": output, "verification": verify, } return NewJSONResultWithIsError(response, result.ExitCode != 0), nil } func dockerRuntimeCommand(dockerHost *models.DockerHost) string { if dockerHost != nil { switch strings.ToLower(strings.TrimSpace(dockerHost.Runtime)) { case "podman": return "podman" case "docker": return "docker" } } return "docker" } // ========== Docker Updates Handler Implementations ========== func (e *PulseToolExecutor) verifyDockerContainerState(ctx context.Context, routing CommandRoutingResult, runtimeCommand, containerID, operation string) map[string]interface{} { expectRunning := false switch operation { case "start", "restart": expectRunning = true case "stop": expectRunning = false } runtimeCommand = strings.TrimSpace(runtimeCommand) if runtimeCommand == "" { runtimeCommand = "docker" } inspectCmd := fmt.Sprintf("%s inspect -f '{{.State.Status}} {{.State.Running}}' %s", runtimeCommand, shellEscape(containerID)) var lastOut string var lastExit int for attempt := 1; attempt <= 3; attempt++ { res, err := e.agentServer.ExecuteCommand(ctx, routing.AgentID, agentexec.ExecuteCommandPayload{ Command: inspectCmd, TargetType: routing.TargetType, TargetID: routing.TargetID, }) if err != nil { return map[string]interface{}{"confirmed": false, "method": runtimeCommand + "_inspect", "command": inspectCmd, "note": err.Error()} } lastExit = res.ExitCode lastOut = strings.TrimSpace(res.Stdout + "\n" + res.Stderr) fields := strings.Fields(strings.ToLower(lastOut)) observedRunning := false observedStatus := "" if len(fields) >= 1 { observedStatus = fields[0] } if len(fields) >= 2 { observedRunning = fields[1] == "true" } if res.ExitCode == 0 && observedStatus != "" && observedRunning == expectRunning { return map[string]interface{}{ "confirmed": true, "method": runtimeCommand + "_inspect", "command": inspectCmd, "expected": map[string]interface{}{"running": expectRunning}, "observed": map[string]interface{}{"status": observedStatus, "running": observedRunning}, } } // Allow a brief settle window for restart/start/stop to propagate. waitTimer := time.NewTimer(500 * time.Millisecond) select { case <-ctx.Done(): if !waitTimer.Stop() { select { case <-waitTimer.C: default: } } return map[string]interface{}{"confirmed": false, "method": runtimeCommand + "_inspect", "command": inspectCmd, "note": "context canceled", "raw": lastOut, "exit_code": lastExit} case <-waitTimer.C: } } return map[string]interface{}{ "confirmed": false, "method": runtimeCommand + "_inspect", "command": inspectCmd, "expected": map[string]interface{}{"running": expectRunning}, "raw": lastOut, "exit_code": lastExit, } } func sleepWithContext(ctx context.Context, duration time.Duration) error { if ctx == nil { time.Sleep(duration) return nil } timer := time.NewTimer(duration) defer timer.Stop() select { case <-ctx.Done(): return ctx.Err() case <-timer.C: return nil } } func dockerUpdateQueueRetryDelay(attempt int) time.Duration { if attempt <= 0 { return dockerUpdateQueueRetryBaseDelay } delay := dockerUpdateQueueRetryBaseDelay * time.Duration(1<<(attempt-1)) if delay > dockerUpdateQueueRetryMaxDelay { return dockerUpdateQueueRetryMaxDelay } return delay } func isTransientUpdateQueueError(err error) bool { if err == nil { return false } if isTransientError(err) { return true } msg := strings.ToLower(err.Error()) transientPatterns := []string{ "temporary failure", "queue full", "resource busy", "database is locked", "deadlock", "unexpected eof", "eof", "try again", } for _, pattern := range transientPatterns { if strings.Contains(msg, pattern) { return true } } return false } func (e *PulseToolExecutor) queueDockerUpdateCommandWithRetry(ctx context.Context, operation string, run func() (DockerCommandStatus, error)) (DockerCommandStatus, error) { var lastErr error for attempt := 1; attempt <= dockerUpdateQueueMaxAttempts; attempt++ { status, err := run() if err == nil { return status, nil } lastErr = err if !isTransientUpdateQueueError(err) { return DockerCommandStatus{}, err } if attempt == dockerUpdateQueueMaxAttempts { break } backoff := dockerUpdateQueueRetryDelay(attempt) log.Warn(). Err(err). Str("operation", operation). Int("attempt", attempt). Dur("retry_in", backoff). Msg("[pulse_docker] transient update queue failure, retrying") if sleepErr := dockerUpdateQueueSleepFn(ctx, backoff); sleepErr != nil { return DockerCommandStatus{}, fmt.Errorf("%s canceled while waiting to retry: %w", operation, sleepErr) } } if lastErr == nil { return DockerCommandStatus{}, fmt.Errorf("%s failed without an error", operation) } return DockerCommandStatus{}, fmt.Errorf("%s failed after %d attempts: %w", operation, dockerUpdateQueueMaxAttempts, lastErr) } func (e *PulseToolExecutor) executeListDockerUpdates(_ context.Context, args map[string]interface{}) (CallToolResult, error) { if e.updatesProvider == nil { return NewTextResult("Docker update information not available. Ensure updates provider is configured."), nil } hostFilter, _ := args["host"].(string) // Resolve host name to ID if needed hostID := e.resolveDockerHostID(hostFilter) updates := e.updatesProvider.GetPendingUpdates(hostID) // Ensure non-nil slice if updates == nil { updates = []ContainerUpdateInfo{} } response := EmptyDockerUpdatesResponse() response.Updates = updates response.Total = len(updates) response.TargetID = hostID return NewJSONResult(response.NormalizeCollections()), nil } func (e *PulseToolExecutor) executeCheckDockerUpdates(ctx context.Context, args map[string]interface{}) (CallToolResult, error) { if e.updatesProvider == nil { return NewTextResult("Docker update checking not available. Ensure updates provider is configured."), nil } hostArg, _ := args["host"].(string) if hostArg == "" { return NewErrorResult(fmt.Errorf("host is required")), nil } // Resolve host name to ID hostID := e.resolveDockerHostID(hostArg) if hostID == "" { return NewTextResult(fmt.Sprintf("Docker host '%s' not found.", hostArg)), nil } hostName := e.getDockerHostName(hostID) // Trigger the update check cmdStatus, err := e.queueDockerUpdateCommandWithRetry(ctx, "trigger update check", func() (DockerCommandStatus, error) { return e.updatesProvider.TriggerUpdateCheck(hostID) }) if err != nil { return NewTextResult(fmt.Sprintf("Failed to trigger update check: %v", err)), nil } response := DockerCheckUpdatesResponse{ Success: true, TargetID: hostID, HostName: hostName, CommandID: cmdStatus.ID, Message: "Update check command queued. Results will be available after the next agent report cycle (~30 seconds).", Command: cmdStatus, } return NewJSONResult(response), nil } func (e *PulseToolExecutor) executeUpdateDockerContainer(ctx context.Context, args map[string]interface{}) (CallToolResult, error) { if e.updatesProvider == nil { return NewTextResult("Docker update functionality not available. Ensure updates provider is configured."), nil } containerArg, _ := args["container"].(string) hostArg, _ := args["host"].(string) if containerArg == "" { return NewErrorResult(fmt.Errorf("container is required")), nil } if hostArg == "" { return NewErrorResult(fmt.Errorf("host is required")), nil } // Check if update actions are enabled if !e.updatesProvider.IsUpdateActionsEnabled() { return NewTextResult("Docker container updates are disabled by server configuration. Set PULSE_DISABLE_DOCKER_UPDATE_ACTIONS=false or enable in Settings to allow updates."), nil } // Resolve container and host container, dockerHost, err := e.resolveDockerContainer(containerArg, hostArg) if err != nil { return NewTextResult(fmt.Sprintf("Could not find container '%s' on host '%s': %v", containerArg, hostArg, err)), nil } containerName := trimContainerName(container.Name) // Controlled mode - require approval if e.controlLevel == ControlLevelControlled { command := fmt.Sprintf("docker update %s", containerName) agentHostname := e.getAgentHostnameForDockerHost(dockerHost) approvalID := e.createApprovalRecord(command, "docker", container.ID, agentHostname, fmt.Sprintf("Update container %s to latest image", containerName)) return NewTextResult(formatDockerUpdateApprovalNeeded(containerName, dockerHost.Hostname, approvalID)), nil } // Autonomous mode - execute directly cmdStatus, err := e.queueDockerUpdateCommandWithRetry(ctx, "queue container update", func() (DockerCommandStatus, error) { return e.updatesProvider.UpdateContainer(dockerHost.ID, container.ID, containerName) }) if err != nil { return NewTextResult(fmt.Sprintf("Failed to queue update command: %v", err)), nil } // The agent executes the update asynchronously: it pulls the image, // recreates the container, verifies the replacement is running and healthy // (rolling back otherwise), then acknowledges a terminal command status. // Poll that status so we report verified success, verified failure, or an // honest inconclusive - never unverified success. verify := e.verifyDockerUpdateCommand(ctx, cmdStatus.ID, containerName) outcome, _ := verify["outcome"].(string) var message string switch outcome { case "verified": message = fmt.Sprintf("Update verified: container '%s' was updated, recreated, and is running again.", containerName) case "failed": reason := "" if observed, ok := verify["observed"].(map[string]interface{}); ok { if fr, ok := observed["failure_reason"].(string); ok && fr != "" { reason = fr } else if msg, ok := observed["message"].(string); ok { reason = msg } } if reason == "" { reason = "the agent reported the update command as failed" } message = fmt.Sprintf("Update of container '%s' failed: %s", containerName, reason) default: message = fmt.Sprintf("Update command queued for container '%s', but it had not reached a terminal state within the verification window. Completion is NOT yet verified - re-check with pulse_docker action 'updates' or inspect the container before reporting success.", containerName) } response := DockerUpdateContainerResponse{ Success: outcome != "failed", TargetID: dockerHost.ID, ContainerID: container.ID, ContainerName: containerName, CommandID: cmdStatus.ID, Message: message, Command: cmdStatus, Verification: verify, } return NewJSONResultWithIsError(response, outcome == "failed"), nil } // verifyDockerUpdateCommand polls the queued update command's status until it // reaches a terminal state or the bounded verification window elapses. The // returned map follows the same verification shape as // verifyDockerContainerState / verifyGuestAction: confirmed only on observed // terminal success, with an explicit outcome of "verified", "failed", or // "inconclusive". func (e *PulseToolExecutor) verifyDockerUpdateCommand(ctx context.Context, commandID, containerName string) map[string]interface{} { verification := map[string]interface{}{ "confirmed": false, "method": "command_status", "command_id": commandID, } if commandID == "" { verification["outcome"] = "inconclusive" verification["note"] = "queue returned no command ID, so the update outcome cannot be tracked" return verification } var lastObserved map[string]interface{} for attempt := 1; attempt <= dockerUpdateVerifyMaxAttempts; attempt++ { status, ok := e.updatesProvider.GetCommandStatus(commandID) if ok { lastObserved = map[string]interface{}{"status": status.Status} if status.Message != "" { lastObserved["message"] = status.Message } if status.FailureReason != "" { lastObserved["failure_reason"] = status.FailureReason } switch status.Status { case dockerCommandStatusCompleted: verification["confirmed"] = true verification["outcome"] = "verified" verification["observed"] = lastObserved verification["note"] = fmt.Sprintf("agent confirmed container '%s' was recreated and is running", containerName) return verification case dockerCommandStatusFailed, dockerCommandStatusExpired: verification["outcome"] = "failed" verification["observed"] = lastObserved return verification } } if attempt == dockerUpdateVerifyMaxAttempts { break } if err := dockerUpdateVerifySleepFn(ctx, dockerUpdateVerifyPollInterval); err != nil { verification["outcome"] = "inconclusive" verification["note"] = "verification canceled before the update reached a terminal state" if lastObserved != nil { verification["observed"] = lastObserved } return verification } } verification["outcome"] = "inconclusive" if lastObserved != nil { verification["observed"] = lastObserved verification["note"] = "update command did not reach a terminal state within the verification window; completion is unverified" } else { verification["note"] = "update command status was not trackable within the verification window; completion is unverified" } return verification } // Helper methods for Docker updates func (e *PulseToolExecutor) resolveDockerHostID(hostArg string) string { if hostArg == "" { return "" } rs, err := e.readStateForControl() if err != nil { return hostArg } for _, host := range rs.DockerHosts() { if host.ID() == hostArg || host.HostSourceID() == hostArg || host.Hostname() == hostArg || host.Name() == hostArg { // Return source ID when available (updates provider uses raw model IDs) if sid := host.HostSourceID(); sid != "" { return sid } return host.ID() } } return hostArg // Return as-is if not found } func (e *PulseToolExecutor) getDockerHostName(hostID string) string { rs, err := e.readStateForControl() if err != nil { return hostID } for _, host := range rs.DockerHosts() { if host.ID() == hostID || host.HostSourceID() == hostID { if host.Name() != "" { return host.Name() } if host.Hostname() != "" { return host.Hostname() } return host.ID() } } return hostID } func formatDockerUpdateApprovalNeeded(containerName, hostName, approvalID string) string { payload := map[string]interface{}{ "approval_id": approvalID, "container_name": containerName, "docker_host": hostName, "action": "update", "command": fmt.Sprintf("docker update %s (pull latest + recreate)", containerName), "how_to_approve": "Click the approval button in the chat to execute this update.", } payload = enrichApprovalRequiredPayload(payload, approvalID) return agentcapabilities.FormatApprovalRequiredToolMarker(payload) } func trimLeadingSlash(name string) string { if len(name) > 0 && name[0] == '/' { return name[1:] } return name } // ========== Docker Swarm Handler Implementations ========== func (e *PulseToolExecutor) executeGetSwarmStatus(_ context.Context, args map[string]interface{}) (CallToolResult, error) { hostArg, _ := args["host"].(string) if hostArg == "" { return NewErrorResult(fmt.Errorf("host is required")), nil } rs, err := e.readStateForControl() if err != nil { return NewErrorResult(err), nil } for _, host := range rs.DockerHosts() { if host.ID() == hostArg || host.Hostname() == hostArg { swarm := host.Swarm() if swarm == nil { return NewTextResult(fmt.Sprintf("Docker host '%s' is not part of a Swarm cluster.", host.Hostname())), nil } response := SwarmStatusResponse{ Host: host.Hostname(), Status: DockerSwarmSummary{ NodeID: swarm.NodeID, NodeRole: swarm.NodeRole, LocalState: swarm.LocalState, ControlAvailable: swarm.ControlAvailable, ClusterID: swarm.ClusterID, ClusterName: swarm.ClusterName, Error: swarm.Error, }, } return NewJSONResult(response), nil } } return NewTextResult(fmt.Sprintf("Docker host '%s' not found.", hostArg)), nil } func (e *PulseToolExecutor) executeListDockerServices(_ context.Context, args map[string]interface{}) (CallToolResult, error) { hostArg, _ := args["host"].(string) if hostArg == "" { return NewErrorResult(fmt.Errorf("host is required")), nil } stackFilter, _ := args["stack"].(string) rs, err := e.readStateForControl() if err != nil { return NewErrorResult(err), nil } for _, host := range rs.DockerHosts() { if host.ID() == hostArg || host.Hostname() == hostArg { services := host.Services() if len(services) == 0 { return NewTextResult(fmt.Sprintf("No Docker services found on host '%s'. The host may not be a Swarm manager.", host.Hostname())), nil } var summaries []DockerServiceSummary filteredCount := 0 for _, svc := range services { if stackFilter != "" && svc.Stack != stackFilter { continue } filteredCount++ updateStatus := "" if svc.UpdateStatus != nil { updateStatus = svc.UpdateStatus.State } summaries = append(summaries, DockerServiceSummary{ ID: svc.ID, Name: svc.Name, Stack: svc.Stack, Image: svc.Image, Mode: svc.Mode, DesiredTasks: svc.DesiredTasks, RunningTasks: svc.RunningTasks, UpdateStatus: updateStatus, }) } response := EmptyDockerServicesResponse() response.Host = host.Hostname() response.Services = summaries response.Total = len(services) response.Filtered = filteredCount return NewJSONResult(response.NormalizeCollections()), nil } } return NewTextResult(fmt.Sprintf("Docker host '%s' not found.", hostArg)), nil } func (e *PulseToolExecutor) executeListDockerTasks(_ context.Context, args map[string]interface{}) (CallToolResult, error) { hostArg, _ := args["host"].(string) if hostArg == "" { return NewErrorResult(fmt.Errorf("host is required")), nil } serviceFilter, _ := args["service"].(string) rs, err := e.readStateForControl() if err != nil { return NewErrorResult(err), nil } for _, host := range rs.DockerHosts() { if host.ID() == hostArg || host.Hostname() == hostArg { tasks := host.Tasks() if len(tasks) == 0 { return NewTextResult(fmt.Sprintf("No Docker tasks found on host '%s'. The host may not be a Swarm manager.", host.Hostname())), nil } var summaries []DockerTaskSummary for _, task := range tasks { if serviceFilter != "" && task.ServiceID != serviceFilter && task.ServiceName != serviceFilter { continue } summaries = append(summaries, DockerTaskSummary{ ID: task.ID, ServiceName: task.ServiceName, NodeName: task.NodeName, DesiredState: task.DesiredState, CurrentState: task.CurrentState, Error: task.Error, StartedAt: task.StartedAt, }) } response := EmptyDockerTasksResponse() response.Host = host.Hostname() response.Service = serviceFilter response.Tasks = summaries response.Total = len(summaries) return NewJSONResult(response.NormalizeCollections()), nil } } return NewTextResult(fmt.Sprintf("Docker host '%s' not found.", hostArg)), nil }