mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-04-29 03:50:18 +00:00
515 lines
17 KiB
Go
515 lines
17 KiB
Go
package tools
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/agentexec"
|
|
"github.com/rs/zerolog/log"
|
|
)
|
|
|
|
// registerDockerTools registers the pulse_docker tool
|
|
func (e *PulseToolExecutor) registerDockerTools() {
|
|
e.registry.Register(RegisteredTool{
|
|
Definition: Tool{
|
|
Name: "pulse_docker",
|
|
Description: `Manage Docker containers, updates, and Swarm services. Actions: control, updates, check_updates, update, services, tasks, swarm.`,
|
|
InputSchema: InputSchema{
|
|
Type: "object",
|
|
Properties: map[string]PropertySchema{
|
|
"action": {
|
|
Type: "string",
|
|
Description: "Docker action to perform",
|
|
Enum: []string{"control", "updates", "check_updates", "update", "services", "tasks", "swarm"},
|
|
},
|
|
"container": {
|
|
Type: "string",
|
|
Description: "Container name or ID (for control, update)",
|
|
},
|
|
"host": {
|
|
Type: "string",
|
|
Description: "Docker host name or ID",
|
|
},
|
|
"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"},
|
|
},
|
|
},
|
|
Handler: func(ctx context.Context, exec *PulseToolExecutor, args map[string]interface{}) (CallToolResult, error) {
|
|
return exec.executeDocker(ctx, args)
|
|
},
|
|
})
|
|
}
|
|
|
|
// 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 "control":
|
|
return e.executeDockerControl(ctx, args)
|
|
case "updates":
|
|
return e.executeListDockerUpdates(ctx, args)
|
|
case "check_updates":
|
|
return e.executeCheckDockerUpdates(ctx, args)
|
|
case "update":
|
|
return e.executeUpdateDockerContainer(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("unknown action: %s. Use: control, updates, check_updates, update, services, tasks, swarm", 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)
|
|
|
|
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
|
|
}
|
|
|
|
// Check if this is a pre-approved execution
|
|
preApproved := isPreApproved(args)
|
|
|
|
container, dockerHost, err := e.resolveDockerContainer(containerName, hostName)
|
|
if err != nil {
|
|
return NewTextResult(fmt.Sprintf("Could not find Docker container '%s': %v", containerName, err)), nil
|
|
}
|
|
|
|
command := fmt.Sprintf("docker %s %s", operation, container.Name)
|
|
|
|
// 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 := createApprovalRecord(command, "docker", container.Name, 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 := createApprovalRecord(command, "docker", container.Name, 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.agentServer.ExecuteCommand(ctx, routing.AgentID, agentexec.ExecuteCommandPayload{
|
|
Command: command,
|
|
TargetType: routing.TargetType,
|
|
TargetID: routing.TargetID,
|
|
})
|
|
if err != nil {
|
|
return NewErrorResult(err), nil
|
|
}
|
|
|
|
output := result.Stdout
|
|
if result.Stderr != "" {
|
|
output += "\n" + result.Stderr
|
|
}
|
|
|
|
if result.ExitCode == 0 {
|
|
return NewTextResult(fmt.Sprintf("Successfully executed 'docker %s' on container '%s' (host: %s). State updates in ~10s.\n%s", operation, container.Name, dockerHost.Hostname, output)), nil
|
|
}
|
|
|
|
return NewTextResult(fmt.Sprintf("Command failed (exit code %d):\n%s", result.ExitCode, output)), nil
|
|
}
|
|
|
|
// ========== Docker Updates Handler Implementations ==========
|
|
|
|
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 := DockerUpdatesResponse{
|
|
Updates: updates,
|
|
Total: len(updates),
|
|
HostID: hostID,
|
|
}
|
|
|
|
return NewJSONResult(response), nil
|
|
}
|
|
|
|
func (e *PulseToolExecutor) executeCheckDockerUpdates(_ 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.updatesProvider.TriggerUpdateCheck(hostID)
|
|
if err != nil {
|
|
return NewTextResult(fmt.Sprintf("Failed to trigger update check: %v", err)), nil
|
|
}
|
|
|
|
response := DockerCheckUpdatesResponse{
|
|
Success: true,
|
|
HostID: 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 := 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.updatesProvider.UpdateContainer(dockerHost.ID, container.ID, containerName)
|
|
if err != nil {
|
|
return NewTextResult(fmt.Sprintf("Failed to queue update command: %v", err)), nil
|
|
}
|
|
|
|
response := DockerUpdateContainerResponse{
|
|
Success: true,
|
|
HostID: dockerHost.ID,
|
|
ContainerID: container.ID,
|
|
ContainerName: containerName,
|
|
CommandID: cmdStatus.ID,
|
|
Message: fmt.Sprintf("Update command queued for container '%s'. The agent will pull the latest image and recreate the container.", containerName),
|
|
Command: cmdStatus,
|
|
}
|
|
|
|
return NewJSONResult(response), nil
|
|
}
|
|
|
|
// Helper methods for Docker updates
|
|
|
|
func (e *PulseToolExecutor) resolveDockerHostID(hostArg string) string {
|
|
if hostArg == "" {
|
|
return ""
|
|
}
|
|
if e.stateProvider == nil {
|
|
return hostArg
|
|
}
|
|
|
|
state := e.stateProvider.GetState()
|
|
for _, host := range state.DockerHosts {
|
|
if host.ID == hostArg || host.Hostname == hostArg || host.DisplayName == hostArg {
|
|
return host.ID
|
|
}
|
|
}
|
|
return hostArg // Return as-is if not found (provider will handle error)
|
|
}
|
|
|
|
func (e *PulseToolExecutor) getDockerHostName(hostID string) string {
|
|
if e.stateProvider == nil {
|
|
return hostID
|
|
}
|
|
|
|
state := e.stateProvider.GetState()
|
|
for _, host := range state.DockerHosts {
|
|
if host.ID == hostID {
|
|
if host.DisplayName != "" {
|
|
return host.DisplayName
|
|
}
|
|
return host.Hostname
|
|
}
|
|
}
|
|
return hostID
|
|
}
|
|
|
|
func formatDockerUpdateApprovalNeeded(containerName, hostName, approvalID string) string {
|
|
payload := map[string]interface{}{
|
|
"type": "approval_required",
|
|
"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.",
|
|
"do_not_retry": true,
|
|
}
|
|
b, _ := json.Marshal(payload)
|
|
return "APPROVAL_REQUIRED: " + string(b)
|
|
}
|
|
|
|
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) {
|
|
if e.stateProvider == nil {
|
|
return NewTextResult("State provider not available."), nil
|
|
}
|
|
|
|
hostArg, _ := args["host"].(string)
|
|
if hostArg == "" {
|
|
return NewErrorResult(fmt.Errorf("host is required")), nil
|
|
}
|
|
|
|
state := e.stateProvider.GetState()
|
|
|
|
for _, host := range state.DockerHosts {
|
|
if host.ID == hostArg || host.Hostname == hostArg || host.DisplayName == hostArg || host.CustomDisplayName == hostArg {
|
|
if host.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: host.Swarm.NodeID,
|
|
NodeRole: host.Swarm.NodeRole,
|
|
LocalState: host.Swarm.LocalState,
|
|
ControlAvailable: host.Swarm.ControlAvailable,
|
|
ClusterID: host.Swarm.ClusterID,
|
|
ClusterName: host.Swarm.ClusterName,
|
|
Error: host.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) {
|
|
if e.stateProvider == nil {
|
|
return NewTextResult("State provider not available."), nil
|
|
}
|
|
|
|
hostArg, _ := args["host"].(string)
|
|
if hostArg == "" {
|
|
return NewErrorResult(fmt.Errorf("host is required")), nil
|
|
}
|
|
|
|
stackFilter, _ := args["stack"].(string)
|
|
|
|
state := e.stateProvider.GetState()
|
|
|
|
for _, host := range state.DockerHosts {
|
|
if host.ID == hostArg || host.Hostname == hostArg || host.DisplayName == hostArg || host.CustomDisplayName == hostArg {
|
|
if len(host.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 services []DockerServiceSummary
|
|
filteredCount := 0
|
|
|
|
for _, svc := range host.Services {
|
|
if stackFilter != "" && svc.Stack != stackFilter {
|
|
continue
|
|
}
|
|
|
|
filteredCount++
|
|
|
|
updateStatus := ""
|
|
if svc.UpdateStatus != nil {
|
|
updateStatus = svc.UpdateStatus.State
|
|
}
|
|
|
|
services = append(services, DockerServiceSummary{
|
|
ID: svc.ID,
|
|
Name: svc.Name,
|
|
Stack: svc.Stack,
|
|
Image: svc.Image,
|
|
Mode: svc.Mode,
|
|
DesiredTasks: svc.DesiredTasks,
|
|
RunningTasks: svc.RunningTasks,
|
|
UpdateStatus: updateStatus,
|
|
})
|
|
}
|
|
|
|
if services == nil {
|
|
services = []DockerServiceSummary{}
|
|
}
|
|
|
|
response := DockerServicesResponse{
|
|
Host: host.Hostname,
|
|
Services: services,
|
|
Total: len(host.Services),
|
|
Filtered: filteredCount,
|
|
}
|
|
|
|
return NewJSONResult(response), nil
|
|
}
|
|
}
|
|
|
|
return NewTextResult(fmt.Sprintf("Docker host '%s' not found.", hostArg)), nil
|
|
}
|
|
|
|
func (e *PulseToolExecutor) executeListDockerTasks(_ context.Context, args map[string]interface{}) (CallToolResult, error) {
|
|
if e.stateProvider == nil {
|
|
return NewTextResult("State provider not available."), nil
|
|
}
|
|
|
|
hostArg, _ := args["host"].(string)
|
|
if hostArg == "" {
|
|
return NewErrorResult(fmt.Errorf("host is required")), nil
|
|
}
|
|
|
|
serviceFilter, _ := args["service"].(string)
|
|
|
|
state := e.stateProvider.GetState()
|
|
|
|
for _, host := range state.DockerHosts {
|
|
if host.ID == hostArg || host.Hostname == hostArg || host.DisplayName == hostArg || host.CustomDisplayName == hostArg {
|
|
if len(host.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 tasks []DockerTaskSummary
|
|
|
|
for _, task := range host.Tasks {
|
|
if serviceFilter != "" && task.ServiceID != serviceFilter && task.ServiceName != serviceFilter {
|
|
continue
|
|
}
|
|
|
|
tasks = append(tasks, DockerTaskSummary{
|
|
ID: task.ID,
|
|
ServiceName: task.ServiceName,
|
|
NodeName: task.NodeName,
|
|
DesiredState: task.DesiredState,
|
|
CurrentState: task.CurrentState,
|
|
Error: task.Error,
|
|
StartedAt: task.StartedAt,
|
|
})
|
|
}
|
|
|
|
if tasks == nil {
|
|
tasks = []DockerTaskSummary{}
|
|
}
|
|
|
|
response := DockerTasksResponse{
|
|
Host: host.Hostname,
|
|
Service: serviceFilter,
|
|
Tasks: tasks,
|
|
Total: len(tasks),
|
|
}
|
|
|
|
return NewJSONResult(response), nil
|
|
}
|
|
}
|
|
|
|
return NewTextResult(fmt.Sprintf("Docker host '%s' not found.", hostArg)), nil
|
|
}
|