mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 18:45:53 +00:00
292 lines
13 KiB
Go
292 lines
13 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"regexp"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/actionlifecycle"
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/agentexec"
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/operationreceipt"
|
|
unified "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
|
|
)
|
|
|
|
const (
|
|
hostPackageUpdateActionHandler = "host.package_updates"
|
|
hostPackageUpdateCapability = "install_os_updates"
|
|
)
|
|
|
|
var hostPackageUpdateInventoryHashPattern = regexp.MustCompile(`^sha256:[a-f0-9]{64}$`)
|
|
|
|
type hostUpdateAgentCommander interface {
|
|
ExecuteHostUpdate(ctx context.Context, agentID string, req agentexec.HostUpdatePayload) (*agentexec.HostUpdateResultPayload, error)
|
|
IsAgentConnected(agentID string) bool
|
|
}
|
|
|
|
type operationReceiptQuerier interface {
|
|
QueryAgentOperation(context.Context, string, operationreceipt.Identity) (operationreceipt.QueryResult, error)
|
|
}
|
|
type agentOperationReceiptCapability interface{ AgentOperationReceiptVersion(string) int }
|
|
|
|
func (e hostUpdateActionExecutor) BindActionDispatch(ctx context.Context, record unified.ActionAuditRecord, attempt unified.ActionDispatchAttempt) (unified.ActionDispatchAttempt, error) {
|
|
resource, err := e.currentResource(ctx, record.Request.ResourceID)
|
|
if err != nil {
|
|
return unified.ActionDispatchAttempt{}, err
|
|
}
|
|
req := agentexec.HostUpdatePayload{RequestID: attempt.ID, ActionID: record.ID, Operation: agentexec.HostUpdateOperationInstall, ExpectedInventoryHash: resource.Agent.PackageUpdates.InventoryHash}
|
|
if err := agentexec.BindHostUpdatePayload(&req); err != nil {
|
|
return unified.ActionDispatchAttempt{}, err
|
|
}
|
|
return unified.BindActionDispatchAttempt(attempt, unified.ActionDispatchBinding{OperationKind: req.Operation, OperationVersion: req.OperationVersion, RequestDigest: req.RequestDigest, AgentID: resource.Agent.AgentID})
|
|
}
|
|
|
|
type hostUpdateActionExecutor struct {
|
|
resources *ResourceHandlers
|
|
agents hostUpdateAgentCommander
|
|
now func() time.Time
|
|
}
|
|
|
|
func newHostUpdateActionExecutor(resources *ResourceHandlers, agents hostUpdateAgentCommander) ActionExecutor {
|
|
if resources == nil || agents == nil {
|
|
return nil
|
|
}
|
|
return hostUpdateActionExecutor{resources: resources, agents: agents, now: time.Now}
|
|
}
|
|
|
|
func (e hostUpdateActionExecutor) ActionHandlerNames() []string {
|
|
return []string{hostPackageUpdateActionHandler}
|
|
}
|
|
|
|
func (e hostUpdateActionExecutor) CheckActionAvailable(ctx context.Context, req unified.ActionRequest, resource unified.Resource) unified.ResourceActionReadiness {
|
|
if strings.TrimSpace(req.CapabilityName) != hostPackageUpdateCapability {
|
|
return unified.ResourceActionReadiness{}
|
|
}
|
|
capability, ok := resourceCapabilityByName(resource.Capabilities, hostPackageUpdateCapability)
|
|
if !ok || capability.InternalHandler != hostPackageUpdateActionHandler {
|
|
return unified.ResourceActionReadiness{}
|
|
}
|
|
if err := e.validateResource(resource); err != nil {
|
|
return unified.ResourceActionReadiness{
|
|
Name: hostPackageUpdateCapability,
|
|
Available: false,
|
|
ReasonCode: hostUpdateUnavailableReasonCode(err),
|
|
Reason: hostUpdateUnavailableReason(err),
|
|
}
|
|
}
|
|
return unified.ResourceActionReadiness{Name: hostPackageUpdateCapability, Available: true}
|
|
}
|
|
|
|
func (e hostUpdateActionExecutor) ExecuteAction(ctx context.Context, record unified.ActionAuditRecord) (*unified.ExecutionResult, error) {
|
|
attempt, ok := actionlifecycle.DispatchAttemptFromContext(ctx)
|
|
if !ok || attempt.ActionID != record.ID {
|
|
return nil, fmt.Errorf("committed action dispatch authority is required")
|
|
}
|
|
record, err := unified.NormalizeActionAuditRecord(record)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if strings.TrimSpace(record.Request.CapabilityName) != hostPackageUpdateCapability {
|
|
return nil, fmt.Errorf("unsupported host update capability %q", record.Request.CapabilityName)
|
|
}
|
|
resource, err := e.currentResource(ctx, record.Request.ResourceID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
agentID := strings.TrimSpace(resource.Agent.AgentID)
|
|
req := agentexec.HostUpdatePayload{
|
|
RequestID: attempt.ID,
|
|
ActionID: record.ID,
|
|
Operation: agentexec.HostUpdateOperationInstall,
|
|
ExpectedInventoryHash: resource.Agent.PackageUpdates.InventoryHash,
|
|
Timeout: 900,
|
|
}
|
|
if err := agentexec.BindHostUpdatePayload(&req); err != nil {
|
|
return nil, err
|
|
}
|
|
if agentexec.HostUpdateOperationIdentity(agentID, req) != (operationreceipt.Identity{AttemptID: attempt.ID, ActionID: attempt.ActionID, OperationKind: attempt.OperationKind, OperationVersion: attempt.OperationVersion, RequestDigest: attempt.RequestDigest, AgentID: attempt.AgentID}) {
|
|
return nil, fmt.Errorf("host update dispatch binding drift")
|
|
}
|
|
result, err := e.agents.ExecuteHostUpdate(ctx, agentID, req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if result == nil {
|
|
return nil, fmt.Errorf("host update agent returned no result")
|
|
}
|
|
receivedAt := e.currentTime()
|
|
if err := agentexec.ValidateHostUpdateResultForRequestAt(req, *result, receivedAt); err != nil {
|
|
return nil, fmt.Errorf("invalid host update agent result: %w", err)
|
|
}
|
|
|
|
output := hostUpdateResultSummary(*result)
|
|
beforeBound := result.Before.InventoryHash == resource.Agent.PackageUpdates.InventoryHash
|
|
return hostAPTExecutionResult(record.Request.ResourceID, agentID, agentexec.HostUpdateOperationInstall, output, result.Success, result.MutationStarted, result.Verification, beforeBound, true, result.HealthChecked, result.PackageManagerHealthy, result.RecoveryRequired, result.Before.CheckedAt, result.After.CheckedAt, receivedAt, receivedAt)
|
|
}
|
|
|
|
func (e hostUpdateActionExecutor) ReconcileActionDispatch(ctx context.Context, record unified.ActionAuditRecord, attempt unified.ActionDispatchAttempt) (*unified.ExecutionResult, unified.ActionDispatchReceipt, bool, error) {
|
|
identity := operationreceipt.Identity{AttemptID: attempt.ID, ActionID: attempt.ActionID, OperationKind: attempt.OperationKind, OperationVersion: attempt.OperationVersion, RequestDigest: attempt.RequestDigest, AgentID: attempt.AgentID}
|
|
querier, ok := e.agents.(operationReceiptQuerier)
|
|
if !ok {
|
|
return nil, unified.ActionDispatchReceipt{}, false, nil
|
|
}
|
|
query, err := querier.QueryAgentOperation(ctx, attempt.AgentID, identity)
|
|
if err != nil {
|
|
return nil, unified.ActionDispatchReceipt{}, false, err
|
|
}
|
|
if query.Status != operationreceipt.QueryFoundTerminal {
|
|
return nil, unified.ActionDispatchReceipt{}, false, nil
|
|
}
|
|
receivedAt := e.currentTime()
|
|
if err := agentexec.ValidateOperationQueryResultForIdentity(query, identity, receivedAt); err != nil {
|
|
return nil, unified.ActionDispatchReceipt{}, false, err
|
|
}
|
|
result, err := agentexec.DecodeHostUpdateResultPayload(query.Record.Result)
|
|
if err != nil {
|
|
return nil, unified.ActionDispatchReceipt{}, false, err
|
|
}
|
|
req := agentexec.HostUpdatePayload{RequestID: attempt.ID, ActionID: record.ID, Operation: attempt.OperationKind, OperationVersion: attempt.OperationVersion, RequestDigest: attempt.RequestDigest, ExpectedInventoryHash: result.Before.InventoryHash}
|
|
if err := agentexec.ValidateHostUpdateResultForRequest(req, result); err != nil {
|
|
return nil, unified.ActionDispatchReceipt{}, false, err
|
|
}
|
|
output := hostUpdateResultSummary(result)
|
|
execution, buildErr := hostAPTExecutionResult(record.Request.ResourceID, attempt.AgentID, attempt.OperationKind, output, result.Success, result.MutationStarted, result.Verification, true, true, result.HealthChecked, result.PackageManagerHealthy, result.RecoveryRequired, result.Before.CheckedAt, result.After.CheckedAt, query.Record.TerminalAt, receivedAt)
|
|
if buildErr != nil {
|
|
return nil, unified.ActionDispatchReceipt{}, false, buildErr
|
|
}
|
|
receipt := unified.ActionDispatchReceipt{AttemptID: attempt.ID, ActionID: record.ID, TransportRequestID: attempt.ID, ReceivedAt: receivedAt}
|
|
return execution, receipt, true, nil
|
|
}
|
|
|
|
func hostUpdateResultSummary(result agentexec.HostUpdateResultPayload) string {
|
|
health := "unknown"
|
|
if result.HealthChecked {
|
|
health = "unhealthy"
|
|
if result.PackageManagerHealthy {
|
|
health = "healthy"
|
|
}
|
|
}
|
|
return fmt.Sprintf("APT package updates: phase=%s; %d pending before, %d pending after; package manager health: %s; recovery required: %t; reboot required: %t", result.ExecutionPhase, result.Before.PendingCount, result.After.PendingCount, health, result.RecoveryRequired, result.After.RebootRequired)
|
|
}
|
|
|
|
func (e hostUpdateActionExecutor) currentResource(ctx context.Context, resourceID string) (unified.Resource, error) {
|
|
if e.resources == nil {
|
|
return unified.Resource{}, fmt.Errorf("resource handler unavailable")
|
|
}
|
|
registry, err := e.resources.buildRegistry(GetOrgID(ctx))
|
|
if err != nil {
|
|
return unified.Resource{}, err
|
|
}
|
|
resource, ok := registry.Get(resourceID)
|
|
if !ok || resource == nil {
|
|
return unified.Resource{}, fmt.Errorf("resource %q is no longer present", resourceID)
|
|
}
|
|
if err := e.validateResource(*resource); err != nil {
|
|
return unified.Resource{}, err
|
|
}
|
|
return *resource, nil
|
|
}
|
|
|
|
func (e hostUpdateActionExecutor) validateResource(resource unified.Resource) error {
|
|
if resource.Type != unified.ResourceTypeAgent || resource.Agent == nil {
|
|
return fmt.Errorf("resource is not an agent-managed host")
|
|
}
|
|
capability, ok := resourceCapabilityByName(resource.Capabilities, hostPackageUpdateCapability)
|
|
if !ok || capability.InternalHandler != hostPackageUpdateActionHandler {
|
|
return fmt.Errorf("host does not currently advertise typed OS updates")
|
|
}
|
|
if !resource.Agent.CommandsEnabled {
|
|
return fmt.Errorf("host command operations are disabled")
|
|
}
|
|
if resource.Agent.OperationReceiptVersion != operationreceipt.ProtocolVersion {
|
|
return fmt.Errorf("durable operation receipt protocol is unsupported")
|
|
}
|
|
status := resource.Agent.PackageUpdates
|
|
if status == nil || !status.Supported || strings.TrimSpace(status.Manager) != "apt" {
|
|
return fmt.Errorf("host package manager is unsupported")
|
|
}
|
|
if strings.TrimSpace(status.Error) != "" {
|
|
return fmt.Errorf("host package update inventory has an error")
|
|
}
|
|
if !hostPackageUpdateInventoryHashPattern.MatchString(strings.TrimSpace(status.InventoryHash)) {
|
|
return fmt.Errorf("host package update inventory fingerprint is invalid")
|
|
}
|
|
if !unified.HostPackageUpdateTelemetryFresh(status, e.currentTime()) {
|
|
return fmt.Errorf("host package update inventory is stale")
|
|
}
|
|
if status.PendingCount <= 0 {
|
|
return fmt.Errorf("host has no pending package updates")
|
|
}
|
|
agentID := strings.TrimSpace(resource.Agent.AgentID)
|
|
liveCapability, supported := e.agents.(agentOperationReceiptCapability)
|
|
if agentID == "" || e.agents == nil || !e.agents.IsAgentConnected(agentID) {
|
|
return fmt.Errorf("host command agent is disconnected")
|
|
}
|
|
if !supported || liveCapability.AgentOperationReceiptVersion(agentID) != operationreceipt.ProtocolVersion {
|
|
return fmt.Errorf("live durable operation receipt protocol is unsupported")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (e hostUpdateActionExecutor) currentTime() time.Time {
|
|
if e.now != nil {
|
|
return e.now().UTC()
|
|
}
|
|
return time.Now().UTC()
|
|
}
|
|
|
|
func hostUpdateUnavailableReasonCode(err error) string {
|
|
if err == nil {
|
|
return "unavailable"
|
|
}
|
|
message := strings.ToLower(err.Error())
|
|
switch {
|
|
case strings.Contains(message, "not an agent-managed host"):
|
|
return "unsupported_resource"
|
|
case strings.Contains(message, "disabled"):
|
|
return "host_commands_disabled"
|
|
case strings.Contains(message, "receipt protocol"):
|
|
return "operation_receipt_unsupported"
|
|
case strings.Contains(message, "unsupported"):
|
|
return "unsupported_package_manager"
|
|
case strings.Contains(message, "inventory") && strings.Contains(message, "error"):
|
|
return "package_inventory_error"
|
|
case strings.Contains(message, "fingerprint"):
|
|
return "invalid_package_inventory"
|
|
case strings.Contains(message, "stale"):
|
|
return "stale_package_inventory"
|
|
case strings.Contains(message, "no pending"):
|
|
return "no_pending_updates"
|
|
case strings.Contains(message, "disconnected"):
|
|
return "command_agent_disconnected"
|
|
default:
|
|
return "capability_unavailable"
|
|
}
|
|
}
|
|
|
|
func hostUpdateUnavailableReason(err error) string {
|
|
switch hostUpdateUnavailableReasonCode(err) {
|
|
case "unsupported_resource":
|
|
return "This resource is not an agent-managed host."
|
|
case "host_commands_disabled":
|
|
return "Typed host operations are disabled for this agent."
|
|
case "operation_receipt_unsupported":
|
|
return "This agent does not support durable typed-operation receipts."
|
|
case "unsupported_package_manager":
|
|
return "This host does not expose a supported package manager."
|
|
case "package_inventory_error":
|
|
return "Pulse could not inspect the host's package update state."
|
|
case "invalid_package_inventory":
|
|
return "The host package inventory cannot be verified safely."
|
|
case "stale_package_inventory":
|
|
return "The host package inventory is too old to update safely."
|
|
case "no_pending_updates":
|
|
return "The host has no pending standard package updates."
|
|
case "command_agent_disconnected":
|
|
return "The host command agent is not connected."
|
|
default:
|
|
return "Typed host package updates are not currently available."
|
|
}
|
|
}
|