Files
pulse/internal/agentexec/types.go
T
rcourtman 4c7b1a2434 Fix Docker-in-LXC probe storm against slow Proxmox hosts
The guest Docker socket probe hung minipc hard enough to need a power
cycle (2026-08-20): ~100 orphaned pct exec children, load 133, sshd and
pveproxy starved. Three bugs chained, each fixed here:

1. Dispatcher re-issued a probe while the previous one was still
   executing. The poll cycle's enrichment context had expired, so
   ExecuteCommand dispatched, returned the context error 50ms later,
   and the next 3s cycle sent the identical command again — unbounded
   concurrency against a host that was slow to begin with. The
   monitoring dispatcher now takes a per-guest in-flight claim before
   dispatching probe or inventory commands (completed probes release
   it; abandoned ones hold it for a 2-minute window), and both dispatch
   paths bail out under a dead context.

2. The host agent never got the July process-leak fix: 45480a5cc
   landed only on pulse/v6-release, so main-line agents killed just the
   direct shell on timeout, orphaning pct exec → lxc-attach children
   and blocking Wait on their inherited pipes (10s timeouts reported as
   300s+ durations). Port it: run each command in its own process
   group, SIGKILL the group on cancel, bound Wait with WaitDelay, and
   treat ErrWaitDelay after a clean exit as success.

3. Server-side abandonment never reached the agent. ExecuteCommand and
   ReadFile now refuse to dispatch under an already-expired context,
   and send a best-effort cancel_command when they stop waiting; the
   agent cancels the in-flight execution (killing its process group)
   and reports "command canceled". Older agents ignore the unknown
   message type.

Also add a per-node circuit breaker: three consecutive command failures
on one node suspend all Docker probe/inventory dispatch to it on the
existing 1m→30m backoff schedule, so a host-level stall (NFS flapping)
stops the probing entirely instead of failing guest by guest.

Regression tests simulate the storm without hardware: a never-returning
executor is not re-issued across poll cycles, an expired context
dispatches nothing and records no failure, abandoned probes hold their
claim, the breaker blocks new guests on a failing node, and the agent
kills the whole process group on timeout and on server-issued cancel.

Contract-Neutral: monitor.go delta is three private struct fields holding Docker probe dispatch state; host-agent deletion/re-enrollment lifecycle untouched — contracts and all other proofs are staged
2026-08-20 23:33:36 +01:00

613 lines
27 KiB
Go

package agentexec
import (
"encoding/json"
"errors"
"strings"
"time"
)
// MessageType identifies the type of WebSocket message
type MessageType string
const (
// Agent -> Server messages
MsgTypeAgentRegister MessageType = "agent_register"
MsgTypeAgentPing MessageType = "agent_ping"
MsgTypeCommandResult MessageType = "command_result"
MsgTypeHostStorageCleanupResult MessageType = "host_storage_cleanup_result"
MsgTypeHostUpdateResult MessageType = "host_update_result"
MsgTypeDockerContainerLifecycleResult MessageType = "docker_container_lifecycle_result"
MsgTypeDockerContainerUpdateResult MessageType = "docker_container_update_result"
MsgTypeActionPreflightResult MessageType = "action_preflight_result"
MsgTypeOperationQueryResult MessageType = "agent_operation_query_result"
// Server -> Agent messages
MsgTypeRegistered MessageType = "registered"
MsgTypePong MessageType = "pong"
MsgTypeExecuteCmd MessageType = "execute_command"
MsgTypeHostStorageCleanup MessageType = "host_storage_cleanup"
MsgTypeReadFile MessageType = "read_file"
MsgTypeHostUpdate MessageType = "host_update"
MsgTypeDockerContainerLifecycle MessageType = "docker_container_lifecycle"
MsgTypeDockerContainerUpdate MessageType = "docker_container_update"
MsgTypeActionPreflight MessageType = "action_preflight"
MsgTypeOperationQuery MessageType = "agent_operation_query"
MsgTypeDeployPreflight MessageType = "deploy_preflight"
MsgTypeDeployInstall MessageType = "deploy_install"
MsgTypeDeployCancelJob MessageType = "deploy_cancel"
MsgTypeCancelCmd MessageType = "cancel_command"
// Agent -> Server messages (deploy)
MsgTypeDeployProgress MessageType = "deploy_progress"
)
// Message is the envelope for all WebSocket messages
type Message struct {
Type MessageType `json:"type"`
ID string `json:"id,omitempty"` // Unique message ID for request/response correlation
Timestamp time.Time `json:"timestamp"`
Payload json.RawMessage `json:"payload,omitempty"`
}
// NewMessage creates a message envelope with a safely marshaled payload.
func NewMessage(messageType MessageType, id string, payload any) (Message, error) {
msg := Message{
Type: messageType,
ID: id,
Timestamp: time.Now(),
}
if err := msg.SetPayload(payload); err != nil {
return Message{}, err
}
return msg, nil
}
// SetPayload marshals and sets the payload for this message.
func (m *Message) SetPayload(payload any) error {
if payload == nil {
m.Payload = nil
return nil
}
encoded, err := json.Marshal(payload)
if err != nil {
return err
}
m.Payload = encoded
return nil
}
// DecodePayload unmarshals the message payload into target.
func (m Message) DecodePayload(target any) error {
if len(m.Payload) == 0 {
return errors.New("message payload is empty")
}
return json.Unmarshal(m.Payload, target)
}
// AgentRegisterPayload is sent by agent on connection
type AgentRegisterPayload struct {
AgentID string `json:"agent_id"`
Hostname string `json:"hostname"`
Version string `json:"version"`
Platform string `json:"platform"` // "linux", "windows", "darwin"
Tags []string `json:"tags,omitempty"`
Token string `json:"token"` // API token for authentication
OperationReceiptVersion int `json:"operation_receipt_version,omitempty"`
ActionPreflightVersion int `json:"action_preflight_version,omitempty"`
}
// RegisteredPayload is sent by server after successful registration
type RegisteredPayload struct {
Success bool `json:"success"`
Message string `json:"message,omitempty"`
}
// CancelCommandPayload is sent by the server when it stops waiting for a
// previously dispatched execute_command request (caller context expired or
// the server-side timeout fired). Agents that understand it abort the running
// command and reap its whole process tree; older agents ignore the message
// and rely on their own per-command timeout.
type CancelCommandPayload struct {
RequestID string `json:"request_id"`
}
// ExecuteCommandPayload is sent by server to request command execution
type ExecuteCommandPayload struct {
RequestID string `json:"request_id"`
Command string `json:"command"`
ApprovalID string `json:"approval_id,omitempty"`
ApprovalGrant *CommandApprovalGrant `json:"approval_grant,omitempty"`
TargetType string `json:"target_type"` // "agent", "container", "vm"
TargetID string `json:"target_id,omitempty"` // VMID for container/VM
Timeout int `json:"timeout,omitempty"` // seconds, 0 = default
// Trusted marks a payload as originating from a Pulse-internal subsystem
// whose command catalog is hardcoded and vetted (e.g. servicediscovery
// deep scans wrap read-only inspections in `docker exec`). When set, the
// server's command policy approval gate is bypassed for this command.
// This field must only be set by trusted internal call sites — never
// from a deserialised HTTP body or any user-driven path.
Trusted bool `json:"trusted,omitempty"`
authorization *commandAuthorizationContext
}
type commandAuthorizationContext struct {
OrgID string
ActionID string
}
// BindCommandAuthorization attaches server-owned approval scope to a command.
// The scope is deliberately not serializable, so provider arguments, HTTP
// payloads, relay callers, and agent wire messages cannot manufacture it.
func (p *ExecuteCommandPayload) BindCommandAuthorization(orgID, actionID string) {
if p == nil {
return
}
p.authorization = &commandAuthorizationContext{
OrgID: strings.TrimSpace(orgID),
ActionID: strings.TrimSpace(actionID),
}
}
// ReadFilePayload is sent by server to request file content
type ReadFilePayload struct {
RequestID string `json:"request_id"`
Path string `json:"path"`
TargetType string `json:"target_type"`
TargetID string `json:"target_id,omitempty"`
MaxBytes int64 `json:"max_bytes,omitempty"` // 0 = default (1MB)
}
// CommandResultPayload is sent by agent with command execution result
type CommandResultPayload struct {
RequestID string `json:"request_id"`
Success bool `json:"success"`
Stdout string `json:"stdout,omitempty"`
Stderr string `json:"stderr,omitempty"`
ExitCode int `json:"exit_code"`
Error string `json:"error,omitempty"`
Duration int64 `json:"duration_ms"`
}
const HostUpdateOperationInstall = "install_os_updates"
const (
DockerContainerOperationStart = "start_container"
DockerContainerOperationStop = "stop_container"
DockerContainerOperationRestart = "restart_container"
DockerContainerOperationUpdate = "update_container"
)
// DockerContainerLifecyclePayload is a closed container lifecycle operation.
// It carries an immutable container ID and request-bound before state, never
// command text, flags, a shell fragment, or a user-controlled container name.
type DockerContainerLifecyclePayload struct {
RequestID string `json:"request_id"`
ActionID string `json:"action_id"`
Operation string `json:"operation"`
OperationVersion int `json:"operation_version"`
RequestDigest string `json:"request_digest"`
Runtime string `json:"runtime"`
ContainerID string `json:"container_id"`
ExpectedState string `json:"expected_state"`
ExpectedStartedAt time.Time `json:"expected_started_at,omitempty"`
Timeout int `json:"timeout,omitempty"`
}
type DockerContainerLifecycleSnapshot struct {
ContainerID string `json:"container_id"`
State string `json:"state"`
Running bool `json:"running"`
StartedAt time.Time `json:"started_at,omitempty"`
RestartCount int `json:"restart_count"`
ObservedAt time.Time `json:"observed_at"`
}
type DockerContainerLifecycleResultPayload struct {
RequestID string `json:"request_id"`
ActionID string `json:"action_id"`
Operation string `json:"operation"`
OperationVersion int `json:"operation_version"`
RequestDigest string `json:"request_digest"`
ContainerID string `json:"container_id"`
ExecutionPhase string `json:"execution_phase"`
MutationStarted bool `json:"mutation_started"`
MutationCompleted bool `json:"mutation_completed"`
ReadbackRan bool `json:"readback_ran"`
Before DockerContainerLifecycleSnapshot `json:"before"`
After DockerContainerLifecycleSnapshot `json:"after"`
ReasonCode string `json:"reason_code,omitempty"`
Error string `json:"error,omitempty"`
Duration int64 `json:"duration_ms"`
}
const (
DockerContainerPhasePreflight = "preflight"
DockerContainerPhaseMutate = "mutate"
DockerContainerPhaseVerify = "verify"
DockerContainerPhaseComplete = "complete"
)
// DockerContainerUpdatePayload is the closed, typed container image update
// operation. It carries an immutable container ID and the exact image digest
// the plan observed; the agent refuses when the container no longer runs that
// digest, then pulls the container's own configured image reference and
// recreates it from its inspected configuration. It never carries command
// text, flags, a pull reference, or a user-controlled container name.
type DockerContainerUpdatePayload struct {
RequestID string `json:"request_id"`
ActionID string `json:"action_id"`
Operation string `json:"operation"`
OperationVersion int `json:"operation_version"`
RequestDigest string `json:"request_digest"`
Runtime string `json:"runtime"`
ContainerID string `json:"container_id"`
ExpectedImageDigest string `json:"expected_image_digest"`
Timeout int `json:"timeout,omitempty"`
}
// DockerContainerUpdateResultPayload reports an update outcome. ContainerID
// is the container the request targeted; a successful update recreates it, so
// NewContainerID carries the replacement identity and the compensation fields
// say whether a backup existed and whether rollback was applied on failure.
type DockerContainerUpdateResultPayload struct {
RequestID string `json:"request_id"`
ActionID string `json:"action_id"`
Operation string `json:"operation"`
OperationVersion int `json:"operation_version"`
RequestDigest string `json:"request_digest"`
ContainerID string `json:"container_id"`
ExecutionPhase string `json:"execution_phase"`
MutationStarted bool `json:"mutation_started"`
MutationCompleted bool `json:"mutation_completed"`
ReadbackRan bool `json:"readback_ran"`
NewContainerID string `json:"new_container_id,omitempty"`
ContainerName string `json:"container_name,omitempty"`
OldImageDigest string `json:"old_image_digest,omitempty"`
NewImageDigest string `json:"new_image_digest,omitempty"`
BackupCreated bool `json:"backup_created"`
BackupContainer string `json:"backup_container,omitempty"`
RollbackAttempted bool `json:"rollback_attempted"`
RolledBack bool `json:"rolled_back"`
After DockerContainerLifecycleSnapshot `json:"after"`
ReasonCode string `json:"reason_code,omitempty"`
Error string `json:"error,omitempty"`
Duration int64 `json:"duration_ms"`
}
// DockerContainerUpdateOutcome is the neutral bridge between the Docker
// module's update implementation and the typed command channel, so the host
// command client does not import the Docker module directly.
type DockerContainerUpdateOutcome struct {
Success bool
ContainerName string
OldContainerID string
NewContainerID string
OldImageDigest string
NewImageDigest string
BackupCreated bool
BackupContainer string
RollbackAttempted bool
RolledBack bool
Error string
}
// HostUpdatePayload is the closed, typed host-package operation sent to a
// Unified Agent. It intentionally has no command or package-name fields: the
// agent owns the package-manager command catalog and always updates the whole
// bounded set returned by its own preflight simulation.
type HostUpdatePayload struct {
RequestID string `json:"request_id"`
ActionID string `json:"action_id"`
Operation string `json:"operation"`
OperationVersion int `json:"operation_version"`
RequestDigest string `json:"request_digest"`
ExpectedInventoryHash string `json:"expected_inventory_hash"`
Timeout int `json:"timeout,omitempty"`
}
type HostPackageUpdate struct {
Name string `json:"name"`
InstalledVersion string `json:"installed_version,omitempty"`
AvailableVersion string `json:"available_version,omitempty"`
}
type HostPackageUpdateSnapshot struct {
Supported bool `json:"supported"`
Manager string `json:"manager,omitempty"`
InventoryHash string `json:"inventory_hash,omitempty"`
PendingCount int `json:"pending_count"`
Packages []HostPackageUpdate `json:"packages,omitempty"`
CheckedAt time.Time `json:"checked_at,omitempty"`
RebootRequired bool `json:"reboot_required,omitempty"`
Error string `json:"error,omitempty"`
}
const (
HostUpdateVerificationVerified = "verified"
HostUpdateVerificationFailed = "failed"
HostUpdateVerificationInconclusive = "inconclusive"
)
// HostUpdateResultPayload carries execution and read-after-write evidence
// independently. Success means the package-manager mutation completed;
// Verification states whether the postcondition was actually observed.
type HostUpdateResultPayload struct {
RequestID string `json:"request_id"`
ActionID string `json:"action_id"`
Success bool `json:"success"`
ExecutionPhase string `json:"execution_phase"`
MutationStarted bool `json:"mutation_started"`
Before HostPackageUpdateSnapshot `json:"before"`
After HostPackageUpdateSnapshot `json:"after"`
HealthChecked bool `json:"health_checked"`
PackageManagerHealthy bool `json:"package_manager_healthy"`
RecoveryRequired bool `json:"recovery_required"`
Verification string `json:"verification"`
ReasonCode string `json:"reason_code,omitempty"`
Error string `json:"error,omitempty"`
Duration int64 `json:"duration_ms"`
}
const (
HostUpdatePhasePreflight = "preflight"
HostUpdatePhaseRefresh = "refresh"
HostUpdatePhaseInstall = "install"
HostUpdatePhaseVerify = "verify"
HostUpdatePhaseComplete = "complete"
)
const HostStorageCleanupOperationPackageCache = "clean_package_cache"
const HostStorageCleanupMaxReportedBytes int64 = 1 << 60
// HostStorageCleanupPayload is the closed storage-pressure operation sent to
// a Unified Agent. The model cannot select paths or commands: the agent owns
// the single APT package-cache target and command catalog.
type HostStorageCleanupPayload struct {
RequestID string `json:"request_id"`
ActionID string `json:"action_id"`
Operation string `json:"operation"`
OperationVersion int `json:"operation_version"`
RequestDigest string `json:"request_digest"`
ExpectedFingerprint string `json:"expected_fingerprint"`
Timeout int `json:"timeout,omitempty"`
}
type HostStorageCleanupSnapshot struct {
Supported bool `json:"supported"`
Provider string `json:"provider,omitempty"`
Fingerprint string `json:"fingerprint,omitempty"`
ReclaimableBytes int64 `json:"reclaimable_bytes"`
CheckedAt time.Time `json:"checked_at,omitempty"`
Error string `json:"error,omitempty"`
}
const (
HostStorageCleanupVerificationVerified = "verified"
HostStorageCleanupVerificationFailed = "failed"
HostStorageCleanupVerificationInconclusive = "inconclusive"
)
// HostStorageCleanupResultPayload separates mutation success from the
// read-after-write observation of reclaimed cache bytes.
type HostStorageCleanupResultPayload struct {
RequestID string `json:"request_id"`
ActionID string `json:"action_id"`
Success bool `json:"success"`
ExecutionPhase string `json:"execution_phase"`
MutationStarted bool `json:"mutation_started"`
Before HostStorageCleanupSnapshot `json:"before"`
After HostStorageCleanupSnapshot `json:"after"`
ReclaimedBytes int64 `json:"reclaimed_bytes"`
Verification string `json:"verification"`
ReasonCode string `json:"reason_code,omitempty"`
Error string `json:"error,omitempty"`
Duration int64 `json:"duration_ms"`
}
// ActionPreflightPayload carries exactly one already-bound typed operation to
// the Unified Agent for a read-only feasibility decision. It grants no durable
// dispatch authority and must never enter the operation receipt store.
type ActionPreflightPayload struct {
RequestID string `json:"request_id"`
ProtocolVersion int `json:"protocol_version"`
HostUpdate *HostUpdatePayload `json:"host_update,omitempty"`
StorageCleanup *HostStorageCleanupPayload `json:"storage_cleanup,omitempty"`
DockerLifecycle *DockerContainerLifecyclePayload `json:"docker_lifecycle,omitempty"`
DockerUpdate *DockerContainerUpdatePayload `json:"docker_update,omitempty"`
}
// ActionPreflightResultPayload is bounded agent evidence about the exact
// operation digest. No provider output, paths, package names, or command text
// cross this boundary.
type ActionPreflightResultPayload struct {
RequestID string `json:"request_id"`
ProtocolVersion int `json:"protocol_version"`
Operation string `json:"operation"`
OperationVersion int `json:"operation_version"`
RequestDigest string `json:"request_digest"`
Feasible bool `json:"feasible"`
ReasonCode string `json:"reason_code,omitempty"`
CheckedAt time.Time `json:"checked_at"`
}
const (
HostStorageCleanupPhasePreflight = "preflight"
HostStorageCleanupPhaseClean = "clean"
HostStorageCleanupPhaseVerify = "verify"
HostStorageCleanupPhaseComplete = "complete"
)
// Stable pre-mutation refusal codes cross the Unified Agent boundary without
// exposing command output, package names, paths, or provider error text. The
// server uses these codes for operator presentation and aggregate telemetry;
// model reasoning continues to consume the typed capability catalog rather
// than a hard-coded diagnostic tree.
const (
ActionRefusalContractInvalid = "action_contract_invalid"
ActionRefusalCapabilityUnavailable = "agent_capability_unavailable"
ActionRefusalTargetInspectionUnavailable = "target_inspection_unavailable"
ActionRefusalTargetStateChanged = "target_state_changed"
ActionRefusalTargetPreconditionFailed = "target_precondition_failed"
ActionRefusalPackageManagerBusy = "package_manager_busy"
ActionRefusalPackageIndexRefreshFailed = "package_index_refresh_failed"
ActionRefusalPackagePreflightFailed = "package_preflight_failed"
ActionRefusalPackageInventoryChanged = "package_inventory_changed"
ActionRefusalPackageManagerUnhealthy = "package_manager_unhealthy"
ActionRefusalCleanupPreflightFailed = "cleanup_preflight_failed"
ActionRefusalCleanupInventoryChanged = "cleanup_inventory_changed"
)
// IsActionRefusalReasonCode validates the bounded machine-code shape accepted
// from an agent result. It intentionally validates shape rather than a closed
// enum so a newer agent can report a new safe category to an older server.
func IsActionRefusalReasonCode(code string) bool {
code = strings.TrimSpace(code)
if len(code) == 0 || len(code) > 64 {
return false
}
for _, r := range code {
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '_' {
continue
}
return false
}
return true
}
// ConnectedAgent represents an agent connected via WebSocket
type ConnectedAgent struct {
OrganizationID string
TokenID string
AgentID string
Hostname string
Version string
Platform string
Tags []string
ConnectedAt time.Time
OperationReceiptVersion int
ActionPreflightVersion int
}
// --- Deploy protocol payloads ---
const MaxDeployParallel = 10
// NormalizeDeployMaxParallel applies the shared server/agent concurrency
// contract before the value reaches worker or channel allocation boundaries.
func NormalizeDeployMaxParallel(value, defaultValue int) int {
if value <= 0 {
value = defaultValue
}
if value > MaxDeployParallel {
return MaxDeployParallel
}
return value
}
// DeployPreflightPayload is sent by the server to request SSH preflight checks
// against cluster peer nodes.
type DeployPreflightPayload struct {
RequestID string `json:"request_id"`
JobID string `json:"job_id"`
Targets []DeployPreflightTarget `json:"targets"`
PulseURL string `json:"pulse_url"` // URL peers must reach
MaxParallel int `json:"max_parallel,omitempty"` // 0 = sequential
Timeout int `json:"timeout,omitempty"` // per-target seconds, 0 = 120
}
// DeployPreflightTarget identifies a single node to preflight-check via SSH.
type DeployPreflightTarget struct {
TargetID string `json:"target_id"` // deploy.Target.ID
NodeName string `json:"node_name"` // Proxmox node name
NodeIP string `json:"node_ip"` // IP to SSH into
}
// DeployInstallPayload is sent by the server to request agent installation
// on cluster peer nodes via SSH.
type DeployInstallPayload struct {
RequestID string `json:"request_id"`
JobID string `json:"job_id"`
Targets []DeployInstallTarget `json:"targets"`
PulseURL string `json:"pulse_url"`
MaxParallel int `json:"max_parallel,omitempty"`
Timeout int `json:"timeout,omitempty"` // per-target seconds, 0 = 300
}
// DeployInstallTarget identifies a single node for agent installation via SSH.
type DeployInstallTarget struct {
TargetID string `json:"target_id"` // deploy.Target.ID
NodeName string `json:"node_name"`
NodeIP string `json:"node_ip"`
Arch string `json:"arch"` // from preflight: amd64, arm64
BootstrapToken string `json:"bootstrap_token"` // per-target enrollment token
}
// DeployCancelPayload is sent by the server to cancel an in-flight deploy job.
type DeployCancelPayload struct {
RequestID string `json:"request_id"`
JobID string `json:"job_id"`
}
// DeployProgressPayload is sent by the agent to stream progress events for
// preflight checks and install operations. Multiple progress messages are
// sent per request, with the final message having Final=true.
type DeployProgressPayload struct {
RequestID string `json:"request_id"`
JobID string `json:"job_id"`
TargetID string `json:"target_id,omitempty"` // empty for job-level events
Phase DeployProgressPhase `json:"phase"`
Status DeployStepStatus `json:"status"`
Message string `json:"message"`
Data string `json:"data,omitempty"` // JSON blob for structured results
Final bool `json:"final"` // true = last message for this request
}
// DeployProgressPhase identifies which stage of the deploy lifecycle this
// progress event belongs to.
type DeployProgressPhase string
const (
DeployPhasePreflightSSH DeployProgressPhase = "preflight_ssh"
DeployPhasePreflightArch DeployProgressPhase = "preflight_arch"
DeployPhasePreflightReach DeployProgressPhase = "preflight_reachability"
DeployPhasePreflightAgent DeployProgressPhase = "preflight_existing_agent"
DeployPhasePreflightComplete DeployProgressPhase = "preflight_complete"
DeployPhaseInstallTransfer DeployProgressPhase = "install_transfer"
DeployPhaseInstallExecute DeployProgressPhase = "install_execute"
DeployPhaseInstallEnrollWait DeployProgressPhase = "install_enroll_wait"
DeployPhaseInstallComplete DeployProgressPhase = "install_complete"
DeployPhaseCanceled DeployProgressPhase = "canceled"
DeployPhaseJobComplete DeployProgressPhase = "job_complete"
)
// DeployStepStatus is the outcome of a single progress step.
type DeployStepStatus string
const (
DeployStepStarted DeployStepStatus = "started"
DeployStepOK DeployStepStatus = "ok"
DeployStepFailed DeployStepStatus = "failed"
DeployStepSkipped DeployStepStatus = "skipped"
)
// PreflightResultData is the structured data for a completed preflight check.
// Serialized as JSON in DeployProgressPayload.Data.
type PreflightResultData struct {
Arch string `json:"arch,omitempty"`
HasAgent bool `json:"has_agent"`
PulseReachable bool `json:"pulse_reachable"`
SSHReachable bool `json:"ssh_reachable"`
ErrorDetail string `json:"error_detail,omitempty"`
}
// InstallResultData is the structured data for a completed install step.
// Serialized as JSON in DeployProgressPayload.Data.
type InstallResultData struct {
ExitCode int `json:"exit_code"`
Output string `json:"output,omitempty"`
}