mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-08-05 22:03:12 +00:00
Contract-Neutral: dupl lint dedupe refactor; no behavior or contract delta (typed host-operation dispatch and receipt-identity validation extracted to shared generic helpers)
452 lines
21 KiB
Go
452 lines
21 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 {
|
|
return validateHostAPTReceiptForIdentity(
|
|
identity, HostUpdateOperationInstall, result.RequestID, result.ActionID,
|
|
func() error { return ValidateHostUpdateResultPayload(&result) },
|
|
result.Verification == HostUpdateVerificationVerified || result.Verification == HostUpdateVerificationFailed,
|
|
func() error {
|
|
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)
|
|
},
|
|
)
|
|
}
|
|
|
|
// validateHostAPTReceiptForIdentity is the shared receipt-binding core of the
|
|
// per-operation ReceiptForIdentity validators: bind the terminal result to the
|
|
// exact admitted identity, validate the result payload, and — only for
|
|
// evidence-bearing verifications — re-validate against a request reconstructed
|
|
// from the admitted identity.
|
|
func validateHostAPTReceiptForIdentity(
|
|
identity operationreceipt.Identity,
|
|
operationKind string,
|
|
resultRequestID, resultActionID string,
|
|
validateResultPayload func() error,
|
|
evidenceBearing bool,
|
|
validateEvidence func() error,
|
|
) error {
|
|
normalized, err := operationreceipt.NormalizeIdentity(identity)
|
|
if err != nil || normalized != identity {
|
|
return operationreceipt.ErrBindingConflict
|
|
}
|
|
if identity.OperationKind != operationKind || identity.OperationVersion != HostAPTOperationVersion || resultRequestID != identity.AttemptID || resultActionID != identity.ActionID {
|
|
return operationreceipt.ErrBindingConflict
|
|
}
|
|
if err := validateResultPayload(); err != nil {
|
|
return err
|
|
}
|
|
if !evidenceBearing {
|
|
return nil
|
|
}
|
|
return validateEvidence()
|
|
}
|
|
|
|
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 {
|
|
return validateHostAPTReceiptForIdentity(
|
|
identity, HostStorageCleanupOperationPackageCache, result.RequestID, result.ActionID,
|
|
func() error { return ValidateHostStorageCleanupResultPayload(&result) },
|
|
result.Verification == HostStorageCleanupVerificationVerified || result.Verification == HostStorageCleanupVerificationFailed,
|
|
func() error {
|
|
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))
|
|
}
|