mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-08-24 16:33:34 +00:00
871 lines
29 KiB
Go
871 lines
29 KiB
Go
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
|
|
}
|