mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-08-24 00:14:39 +00:00
v6.1.0-rc.1 retired the legacy update endpoints before a replacement existed, so the UI's Update button failed with an internal-jargon 410 (issue #1564). This lands the replacement end to end: update_container is a typed agentexec operation with its own strict codec, durable receipts, and a request digest bound to the image digest the plan observed; the unified agent bridges execution to the Docker module's existing pull/backup/recreate/verify/rollback implementation (which now reports rollback attempt and outcome); and the container action executor plans, dispatches, and reconciles the operation with declared backup/rollback compensation truth. Containers advertise an admin-approval update capability while an image update with a stated current digest is detected. The legacy endpoints stay retired but return actionable copy. Proven live against a Colima daemon: single-container update, the issue-1564 shared-network-namespace update, and the full UI journey (Update button, governed review, approve, run) all completed with the namespace preserved and the backup retained.
435 lines
20 KiB
Go
435 lines
20 KiB
Go
package agentexec
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/operationreceipt"
|
|
)
|
|
|
|
func decodeStrictAPTPayload(data []byte, target any) error {
|
|
if len(bytes.TrimSpace(data)) == 0 {
|
|
return fmt.Errorf("APT payload is empty")
|
|
}
|
|
decoder := json.NewDecoder(bytes.NewReader(data))
|
|
decoder.DisallowUnknownFields()
|
|
if err := decoder.Decode(target); err != nil {
|
|
return err
|
|
}
|
|
if err := decoder.Decode(&struct{}{}); err != io.EOF {
|
|
if err == nil {
|
|
return fmt.Errorf("APT payload contains trailing JSON")
|
|
}
|
|
return fmt.Errorf("APT payload contains trailing data: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func DecodeHostUpdatePayload(data []byte) (HostUpdatePayload, error) {
|
|
var payload HostUpdatePayload
|
|
if err := decodeStrictAPTPayload(data, &payload); err != nil {
|
|
return HostUpdatePayload{}, err
|
|
}
|
|
if err := ValidateHostUpdatePayload(&payload); err != nil {
|
|
return HostUpdatePayload{}, err
|
|
}
|
|
return payload, nil
|
|
}
|
|
|
|
func DecodeHostUpdateResultPayload(data []byte) (HostUpdateResultPayload, error) {
|
|
var payload HostUpdateResultPayload
|
|
if err := decodeStrictAPTPayload(data, &payload); err != nil {
|
|
return HostUpdateResultPayload{}, err
|
|
}
|
|
if err := ValidateHostUpdateResultPayload(&payload); err != nil {
|
|
return HostUpdateResultPayload{}, err
|
|
}
|
|
return payload, nil
|
|
}
|
|
|
|
func DecodeHostStorageCleanupPayload(data []byte) (HostStorageCleanupPayload, error) {
|
|
var payload HostStorageCleanupPayload
|
|
if err := decodeStrictAPTPayload(data, &payload); err != nil {
|
|
return HostStorageCleanupPayload{}, err
|
|
}
|
|
if err := ValidateHostStorageCleanupPayload(&payload); err != nil {
|
|
return HostStorageCleanupPayload{}, err
|
|
}
|
|
return payload, nil
|
|
}
|
|
|
|
func DecodeHostStorageCleanupResultPayload(data []byte) (HostStorageCleanupResultPayload, error) {
|
|
var payload HostStorageCleanupResultPayload
|
|
if err := decodeStrictAPTPayload(data, &payload); err != nil {
|
|
return HostStorageCleanupResultPayload{}, err
|
|
}
|
|
if err := ValidateHostStorageCleanupResultPayload(&payload); err != nil {
|
|
return HostStorageCleanupResultPayload{}, err
|
|
}
|
|
return payload, nil
|
|
}
|
|
|
|
func ValidateHostUpdatePayload(payload *HostUpdatePayload) error {
|
|
return validateHostUpdatePayload(payload)
|
|
}
|
|
|
|
func ValidateHostStorageCleanupPayload(payload *HostStorageCleanupPayload) error {
|
|
return validateHostStorageCleanupPayload(payload)
|
|
}
|
|
|
|
const HostAPTOperationVersion = 1
|
|
|
|
const (
|
|
HostUpdateReceiptKind = "pulse.host_update_result"
|
|
HostStorageCleanupReceiptKind = "pulse.host_storage_cleanup_result"
|
|
HostAPTReceiptVersion = 1
|
|
)
|
|
|
|
// ValidateOperationQueryResultForIdentity validates a durable receipt against
|
|
// its immutable dispatch identity. Receipt replay can happen long after the
|
|
// agent observed the terminal result, so evidence freshness is anchored to the
|
|
// durable agent-authored terminal boundary rather than the later query time.
|
|
// receivedAt remains the server's transport receipt time and is used only to
|
|
// reject an implausibly future agent terminal timestamp.
|
|
func ValidateOperationQueryResultForIdentity(result operationreceipt.QueryResult, identity operationreceipt.Identity, receivedAt time.Time) error {
|
|
if result.Version != operationreceipt.ProtocolVersion {
|
|
return fmt.Errorf("unsupported operation query result version %d", result.Version)
|
|
}
|
|
if result.Status == operationreceipt.QueryNotFound {
|
|
if result.Record != nil {
|
|
return fmt.Errorf("not-found operation query result contains a record")
|
|
}
|
|
return nil
|
|
}
|
|
if result.Record == nil || result.Record.Identity != identity {
|
|
return fmt.Errorf("operation query result identity mismatch")
|
|
}
|
|
if err := operationreceipt.ValidateRecord(*result.Record); err != nil {
|
|
return err
|
|
}
|
|
if result.Status != operationreceipt.QueryFoundTerminal {
|
|
if result.Status != operationreceipt.QueryFoundInterrupted || result.Record.State == operationreceipt.StateTerminal {
|
|
return fmt.Errorf("operation query result status and record state mismatch")
|
|
}
|
|
return nil
|
|
}
|
|
if result.Record.State != operationreceipt.StateTerminal {
|
|
return fmt.Errorf("terminal operation query result requires terminal record")
|
|
}
|
|
if receivedAt.IsZero() || result.Record.TerminalAt.After(receivedAt.UTC().Add(5*time.Minute)) {
|
|
return fmt.Errorf("operation query terminal time is invalid or implausibly future")
|
|
}
|
|
switch identity.OperationKind {
|
|
case HostUpdateOperationInstall:
|
|
if result.Record.ResultKind != HostUpdateReceiptKind || result.Record.ResultVersion != HostAPTReceiptVersion {
|
|
return fmt.Errorf("host update query result envelope mismatch")
|
|
}
|
|
payload, err := DecodeHostUpdateResultPayload(result.Record.Result)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := ValidateHostUpdateReceiptForIdentity(identity, payload); err != nil {
|
|
return err
|
|
}
|
|
return validateDurableAPTObservation(payload.Before.CheckedAt, payload.After.CheckedAt, result.Record.TerminalAt)
|
|
case HostStorageCleanupOperationPackageCache:
|
|
if result.Record.ResultKind != HostStorageCleanupReceiptKind || result.Record.ResultVersion != HostAPTReceiptVersion {
|
|
return fmt.Errorf("host cleanup query result envelope mismatch")
|
|
}
|
|
payload, err := DecodeHostStorageCleanupResultPayload(result.Record.Result)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := ValidateHostStorageCleanupReceiptForIdentity(identity, payload); err != nil {
|
|
return err
|
|
}
|
|
return validateDurableAPTObservation(payload.Before.CheckedAt, payload.After.CheckedAt, result.Record.TerminalAt)
|
|
case DockerContainerOperationStart, DockerContainerOperationStop, DockerContainerOperationRestart:
|
|
if result.Record.ResultKind != DockerContainerLifecycleReceiptKind || result.Record.ResultVersion != DockerContainerLifecycleReceiptVersion {
|
|
return fmt.Errorf("docker container lifecycle query result envelope mismatch")
|
|
}
|
|
payload, err := DecodeDockerContainerLifecycleResultPayload(result.Record.Result)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if payload.RequestID != identity.AttemptID || payload.ActionID != identity.ActionID || payload.Operation != identity.OperationKind || payload.OperationVersion != identity.OperationVersion || payload.RequestDigest != identity.RequestDigest {
|
|
return operationreceipt.ErrBindingConflict
|
|
}
|
|
if payload.ReadbackRan && (payload.After.ObservedAt.IsZero() || result.Record.TerminalAt.Before(payload.After.ObservedAt)) {
|
|
return fmt.Errorf("docker container lifecycle readback has invalid terminal chronology")
|
|
}
|
|
return nil
|
|
case DockerContainerOperationUpdate:
|
|
if result.Record.ResultKind != DockerContainerUpdateReceiptKind || result.Record.ResultVersion != DockerContainerUpdateReceiptVersion {
|
|
return fmt.Errorf("docker container update query result envelope mismatch")
|
|
}
|
|
payload, err := DecodeDockerContainerUpdateResultPayload(result.Record.Result)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if payload.RequestID != identity.AttemptID || payload.ActionID != identity.ActionID || payload.Operation != identity.OperationKind || payload.OperationVersion != identity.OperationVersion || payload.RequestDigest != identity.RequestDigest {
|
|
return operationreceipt.ErrBindingConflict
|
|
}
|
|
if payload.ReadbackRan && (payload.After.ObservedAt.IsZero() || result.Record.TerminalAt.Before(payload.After.ObservedAt)) {
|
|
return fmt.Errorf("docker container update readback has invalid terminal chronology")
|
|
}
|
|
return nil
|
|
default:
|
|
return fmt.Errorf("unsupported operation query kind %q", identity.OperationKind)
|
|
}
|
|
}
|
|
|
|
func validateDurableAPTObservation(before, after, terminalAt time.Time) error {
|
|
if !validAPTResultObservationChronology(before, after) || terminalAt.IsZero() || terminalAt.Before(after) || after.Before(terminalAt.Add(-hostAPTResultFreshness)) {
|
|
return fmt.Errorf("durable APT result observation is stale or has invalid terminal chronology")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func BindHostUpdatePayload(payload *HostUpdatePayload) error {
|
|
if payload == nil {
|
|
return fmt.Errorf("host update payload is required")
|
|
}
|
|
payload.OperationVersion = HostAPTOperationVersion
|
|
digest, err := hostUpdateRequestDigest(*payload)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
payload.RequestDigest = digest
|
|
return nil
|
|
}
|
|
|
|
func hostUpdateRequestDigest(payload HostUpdatePayload) (string, error) {
|
|
return operationreceipt.DigestCanonicalJSON(struct {
|
|
ActionID string `json:"action_id"`
|
|
Operation string `json:"operation"`
|
|
OperationVersion int `json:"operation_version"`
|
|
ExpectedInventoryHash string `json:"expected_inventory_hash"`
|
|
}{strings.TrimSpace(payload.ActionID), strings.TrimSpace(payload.Operation), payload.OperationVersion, strings.TrimSpace(payload.ExpectedInventoryHash)})
|
|
}
|
|
|
|
func BindHostStorageCleanupPayload(payload *HostStorageCleanupPayload) error {
|
|
if payload == nil {
|
|
return fmt.Errorf("host storage cleanup payload is required")
|
|
}
|
|
payload.OperationVersion = HostAPTOperationVersion
|
|
digest, err := hostStorageCleanupRequestDigest(*payload)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
payload.RequestDigest = digest
|
|
return nil
|
|
}
|
|
|
|
func hostStorageCleanupRequestDigest(payload HostStorageCleanupPayload) (string, error) {
|
|
return operationreceipt.DigestCanonicalJSON(struct {
|
|
ActionID string `json:"action_id"`
|
|
Operation string `json:"operation"`
|
|
OperationVersion int `json:"operation_version"`
|
|
ExpectedFingerprint string `json:"expected_fingerprint"`
|
|
}{strings.TrimSpace(payload.ActionID), strings.TrimSpace(payload.Operation), payload.OperationVersion, strings.TrimSpace(payload.ExpectedFingerprint)})
|
|
}
|
|
|
|
func HostUpdateOperationIdentity(agentID string, payload HostUpdatePayload) operationreceipt.Identity {
|
|
return operationreceipt.Identity{AttemptID: payload.RequestID, ActionID: payload.ActionID, OperationKind: payload.Operation, OperationVersion: payload.OperationVersion, RequestDigest: payload.RequestDigest, AgentID: agentID}
|
|
}
|
|
|
|
func HostStorageCleanupOperationIdentity(agentID string, payload HostStorageCleanupPayload) operationreceipt.Identity {
|
|
return operationreceipt.Identity{AttemptID: payload.RequestID, ActionID: payload.ActionID, OperationKind: payload.Operation, OperationVersion: payload.OperationVersion, RequestDigest: payload.RequestDigest, AgentID: agentID}
|
|
}
|
|
|
|
func ValidateHostUpdateResultPayload(result *HostUpdateResultPayload) error {
|
|
if err := validateHostUpdateResultPayload(result); err != nil {
|
|
return err
|
|
}
|
|
result.ActionID = strings.TrimSpace(result.ActionID)
|
|
result.ExecutionPhase = strings.TrimSpace(result.ExecutionPhase)
|
|
if result.ActionID == "" || len(result.ActionID) > maxRequestIDLength {
|
|
return fmt.Errorf("invalid action id")
|
|
}
|
|
switch result.ExecutionPhase {
|
|
case HostUpdatePhasePreflight, HostUpdatePhaseRefresh, HostUpdatePhaseInstall, HostUpdatePhaseVerify, HostUpdatePhaseComplete:
|
|
default:
|
|
return fmt.Errorf("unsupported host update execution phase %q", result.ExecutionPhase)
|
|
}
|
|
if result.Success && result.ExecutionPhase != HostUpdatePhaseVerify && result.ExecutionPhase != HostUpdatePhaseComplete {
|
|
return fmt.Errorf("successful host update mutation must be in verify or complete phase")
|
|
}
|
|
if (result.Verification == HostUpdateVerificationVerified || result.Verification == HostUpdateVerificationFailed) && !validAPTResultObservationChronology(result.Before.CheckedAt, result.After.CheckedAt) {
|
|
return fmt.Errorf("evidence-bearing host update observation timestamps are invalid")
|
|
}
|
|
if result.MutationStarted && result.ExecutionPhase != HostUpdatePhaseInstall && result.ExecutionPhase != HostUpdatePhaseVerify && result.ExecutionPhase != HostUpdatePhaseComplete {
|
|
return fmt.Errorf("host update mutation state conflicts with execution phase")
|
|
}
|
|
if result.RecoveryRequired && !result.MutationStarted {
|
|
return fmt.Errorf("host update recovery requirement conflicts with mutation state")
|
|
}
|
|
if result.PackageManagerHealthy && !result.HealthChecked {
|
|
return fmt.Errorf("healthy package manager claim requires a completed health check")
|
|
}
|
|
if result.HealthChecked && !result.PackageManagerHealthy && result.MutationStarted && !result.RecoveryRequired {
|
|
return fmt.Errorf("unhealthy package manager after mutation requires recovery")
|
|
}
|
|
if result.ExecutionPhase == HostUpdatePhaseInstall && result.MutationStarted && !result.Success && !result.RecoveryRequired {
|
|
return fmt.Errorf("partial host update install requires recovery")
|
|
}
|
|
if result.Success && result.ExecutionPhase == HostUpdatePhaseComplete && result.RecoveryRequired {
|
|
return fmt.Errorf("successful host update completion cannot require recovery")
|
|
}
|
|
if result.Verification == HostUpdateVerificationVerified && result.ExecutionPhase != HostUpdatePhaseComplete {
|
|
return fmt.Errorf("verified host update must be complete")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func ValidateHostStorageCleanupResultPayload(result *HostStorageCleanupResultPayload) error {
|
|
if err := validateHostStorageCleanupResultPayload(result); err != nil {
|
|
return err
|
|
}
|
|
result.ActionID = strings.TrimSpace(result.ActionID)
|
|
result.ExecutionPhase = strings.TrimSpace(result.ExecutionPhase)
|
|
if result.ActionID == "" || len(result.ActionID) > maxRequestIDLength {
|
|
return fmt.Errorf("invalid action id")
|
|
}
|
|
switch result.ExecutionPhase {
|
|
case HostStorageCleanupPhasePreflight, HostStorageCleanupPhaseClean, HostStorageCleanupPhaseVerify, HostStorageCleanupPhaseComplete:
|
|
default:
|
|
return fmt.Errorf("unsupported host storage cleanup execution phase %q", result.ExecutionPhase)
|
|
}
|
|
if result.Success && result.ExecutionPhase != HostStorageCleanupPhaseVerify && result.ExecutionPhase != HostStorageCleanupPhaseComplete {
|
|
return fmt.Errorf("successful host storage cleanup mutation must be in verify or complete phase")
|
|
}
|
|
if result.Verification == HostStorageCleanupVerificationVerified && result.ExecutionPhase != HostStorageCleanupPhaseComplete {
|
|
return fmt.Errorf("verified host storage cleanup must be complete")
|
|
}
|
|
if (result.Verification == HostStorageCleanupVerificationVerified || result.Verification == HostStorageCleanupVerificationFailed) && !validAPTResultObservationChronology(result.Before.CheckedAt, result.After.CheckedAt) {
|
|
return fmt.Errorf("evidence-bearing host storage cleanup observation timestamps are invalid")
|
|
}
|
|
if result.MutationStarted && result.ExecutionPhase != HostStorageCleanupPhaseClean && result.ExecutionPhase != HostStorageCleanupPhaseVerify && result.ExecutionPhase != HostStorageCleanupPhaseComplete {
|
|
return fmt.Errorf("host storage cleanup mutation state conflicts with execution phase")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validAPTResultObservationChronology(before, after time.Time) bool {
|
|
return !before.IsZero() && !after.IsZero() && !after.Before(before)
|
|
}
|
|
|
|
func ValidateHostUpdateResultForRequest(req HostUpdatePayload, result HostUpdateResultPayload) error {
|
|
if err := ValidateHostUpdateResultPayload(&result); err != nil {
|
|
return err
|
|
}
|
|
if result.RequestID != req.RequestID || result.ActionID != req.ActionID {
|
|
return fmt.Errorf("host update result identity does not match request")
|
|
}
|
|
if result.Verification == HostUpdateVerificationVerified || result.Verification == HostUpdateVerificationFailed {
|
|
if result.Before.InventoryHash != req.ExpectedInventoryHash {
|
|
return fmt.Errorf("evidence-bearing host update before-state does not match request")
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ValidateHostUpdateReceiptForIdentity validates a terminal result against the
|
|
// immutable identity admitted by the generic operation-receipt store. An
|
|
// inconclusive drift result intentionally reports the newly observed inventory
|
|
// in Before, so that observation must not be substituted for the originally
|
|
// authorized inventory when checking the admitted request digest. The store
|
|
// itself binds Complete and Query to the exact admitted identity. Evidence-
|
|
// bearing results additionally prove the original before-state by deriving the
|
|
// digest from their matching observation.
|
|
func ValidateHostUpdateReceiptForIdentity(identity operationreceipt.Identity, result HostUpdateResultPayload) error {
|
|
normalized, err := operationreceipt.NormalizeIdentity(identity)
|
|
if err != nil || normalized != identity {
|
|
return operationreceipt.ErrBindingConflict
|
|
}
|
|
if identity.OperationKind != HostUpdateOperationInstall || identity.OperationVersion != HostAPTOperationVersion || result.RequestID != identity.AttemptID || result.ActionID != identity.ActionID {
|
|
return operationreceipt.ErrBindingConflict
|
|
}
|
|
if err := ValidateHostUpdateResultPayload(&result); err != nil {
|
|
return err
|
|
}
|
|
if result.Verification != HostUpdateVerificationVerified && result.Verification != HostUpdateVerificationFailed {
|
|
return nil
|
|
}
|
|
req := HostUpdatePayload{RequestID: identity.AttemptID, ActionID: identity.ActionID, Operation: identity.OperationKind, OperationVersion: identity.OperationVersion, RequestDigest: identity.RequestDigest, ExpectedInventoryHash: result.Before.InventoryHash}
|
|
if err := ValidateHostUpdatePayload(&req); err != nil {
|
|
return err
|
|
}
|
|
return ValidateHostUpdateResultForRequest(req, result)
|
|
}
|
|
|
|
func ValidateHostUpdateResultForRequestAt(req HostUpdatePayload, result HostUpdateResultPayload, receivedAt time.Time) error {
|
|
if err := ValidateHostUpdateResultForRequest(req, result); err != nil {
|
|
return err
|
|
}
|
|
if (result.Verification == HostUpdateVerificationVerified || result.Verification == HostUpdateVerificationFailed) && !validAPTResultObservationAt(result.Before.CheckedAt, result.After.CheckedAt, receivedAt) {
|
|
return fmt.Errorf("host update result observation is stale or skewed")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func ValidateHostStorageCleanupResultForRequest(req HostStorageCleanupPayload, result HostStorageCleanupResultPayload) error {
|
|
if err := ValidateHostStorageCleanupResultPayload(&result); err != nil {
|
|
return err
|
|
}
|
|
if result.RequestID != req.RequestID || result.ActionID != req.ActionID {
|
|
return fmt.Errorf("host storage cleanup result identity does not match request")
|
|
}
|
|
if result.Verification == HostStorageCleanupVerificationVerified || result.Verification == HostStorageCleanupVerificationFailed {
|
|
if result.Before.Fingerprint != req.ExpectedFingerprint {
|
|
return fmt.Errorf("evidence-bearing host storage cleanup before-state does not match request")
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ValidateHostStorageCleanupReceiptForIdentity is the cleanup counterpart to
|
|
// ValidateHostUpdateReceiptForIdentity. Fingerprint drift is terminal,
|
|
// inconclusive evidence and may report a different observed Before value while
|
|
// the generic store continues to bind the receipt to the admitted digest.
|
|
func ValidateHostStorageCleanupReceiptForIdentity(identity operationreceipt.Identity, result HostStorageCleanupResultPayload) error {
|
|
normalized, err := operationreceipt.NormalizeIdentity(identity)
|
|
if err != nil || normalized != identity {
|
|
return operationreceipt.ErrBindingConflict
|
|
}
|
|
if identity.OperationKind != HostStorageCleanupOperationPackageCache || identity.OperationVersion != HostAPTOperationVersion || result.RequestID != identity.AttemptID || result.ActionID != identity.ActionID {
|
|
return operationreceipt.ErrBindingConflict
|
|
}
|
|
if err := ValidateHostStorageCleanupResultPayload(&result); err != nil {
|
|
return err
|
|
}
|
|
if result.Verification != HostStorageCleanupVerificationVerified && result.Verification != HostStorageCleanupVerificationFailed {
|
|
return nil
|
|
}
|
|
req := HostStorageCleanupPayload{RequestID: identity.AttemptID, ActionID: identity.ActionID, Operation: identity.OperationKind, OperationVersion: identity.OperationVersion, RequestDigest: identity.RequestDigest, ExpectedFingerprint: result.Before.Fingerprint}
|
|
if err := ValidateHostStorageCleanupPayload(&req); err != nil {
|
|
return err
|
|
}
|
|
return ValidateHostStorageCleanupResultForRequest(req, result)
|
|
}
|
|
|
|
func ValidateHostStorageCleanupResultForRequestAt(req HostStorageCleanupPayload, result HostStorageCleanupResultPayload, receivedAt time.Time) error {
|
|
if err := ValidateHostStorageCleanupResultForRequest(req, result); err != nil {
|
|
return err
|
|
}
|
|
if (result.Verification == HostStorageCleanupVerificationVerified || result.Verification == HostStorageCleanupVerificationFailed) && !validAPTResultObservationAt(result.Before.CheckedAt, result.After.CheckedAt, receivedAt) {
|
|
return fmt.Errorf("host storage cleanup result observation is stale or skewed")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
const hostAPTResultFreshness = 15 * time.Minute
|
|
|
|
func validAPTResultObservationAt(before, after, receivedAt time.Time) bool {
|
|
if receivedAt.IsZero() || !validAPTResultObservationChronology(before, after) {
|
|
return false
|
|
}
|
|
receivedAt = receivedAt.UTC()
|
|
after = after.UTC()
|
|
return !after.Before(receivedAt.Add(-hostAPTResultFreshness)) && !after.After(receivedAt.Add(5*time.Minute))
|
|
}
|