mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-08-31 02:14:29 +00:00
Fix availability check identity projection
This commit is contained in:
parent
b4877b4b44
commit
8d23529c02
35 changed files with 1461 additions and 177 deletions
11
docs/API.md
11
docs/API.md
|
|
@ -1368,9 +1368,14 @@ Target payload fields:
|
|||
- `linkedResourceId` - Optional resource id hint for attaching the probe facet to an existing resource.
|
||||
|
||||
An explicit `linkedResourceId` is authoritative and fails closed when it
|
||||
cannot resolve. Without it, Pulse attaches only on one exact normalized IP or
|
||||
hostname match. Zero matches remain standalone and multiple matches remain
|
||||
ambiguous; Pulse does not guess.
|
||||
cannot resolve. Without it, Pulse correlates only on one exact normalized IP
|
||||
or hostname match. Zero matches remain standalone and multiple matches remain
|
||||
ambiguous; Pulse does not guess. Every configured target remains a distinct
|
||||
`network-endpoint` in `/api/resources`, including correlated targets. A
|
||||
correlated target also projects an additive `availability` /
|
||||
`availabilityChecks` facet onto the matched resource and exposes an outgoing
|
||||
`checks` relationship from the check resource. The check row remains the owner
|
||||
of probe status, incidents, evidence, and history.
|
||||
|
||||
Example ping-only target:
|
||||
|
||||
|
|
|
|||
|
|
@ -80,9 +80,12 @@ request per row.
|
|||
An availability check attaches to an existing unified resource only when an
|
||||
explicit link resolves or identity correlation yields exactly one candidate.
|
||||
The relationship carries a stable relationship ID and the evidence ID that
|
||||
supports it. Ambiguous checks remain standalone. An attached check appears on
|
||||
the owning platform row and detail; it is not duplicated as another inventory
|
||||
resource.
|
||||
supports it. Every configured check remains a source-owned inventory resource,
|
||||
including an attached check. Correlation additionally projects its bounded
|
||||
facet onto the matched platform row and detail; it never replaces the check
|
||||
identity or copies the check's incident and service identity into the machine.
|
||||
Ambiguous and unresolved checks remain visible with their typed correlation
|
||||
state.
|
||||
|
||||
Availability success is time-bounded. A stale successful observation is
|
||||
`stale`, not healthy. A failure enters the same alert lifecycle and Patrol
|
||||
|
|
|
|||
|
|
@ -198,11 +198,13 @@ Unified Resources is now the canonical model and endpoint family:
|
|||
|
||||
Availability checks now attach to an existing canonical resource when an
|
||||
explicit `linkedResourceId` resolves or one normalized IP/hostname match is
|
||||
unambiguous. Attached checks disappear from the standalone Availability checks
|
||||
inventory and appear on the owning platform row/detail instead. API consumers
|
||||
should accept the additive `availabilityChecks`, correlation, evidence, and
|
||||
`checks` relationship fields; the existing singular `availability` field
|
||||
remains as a compatibility summary. Ambiguous or invalid links stay
|
||||
unambiguous. Every configured check remains individually visible in the
|
||||
Availability checks inventory; an attached check also appears as an additive
|
||||
facet on the owning platform row/detail. API consumers should treat the
|
||||
source-owned `network-endpoint` as the check identity and accept the additive
|
||||
`availabilityChecks`, correlation, evidence, and outgoing `checks`
|
||||
relationship fields; the existing singular `availability` field remains as a
|
||||
compatibility summary. Ambiguous or invalid links stay visible as
|
||||
standalone/unresolved and are never guessed.
|
||||
|
||||
### License and Entitlements
|
||||
|
|
|
|||
|
|
@ -145,7 +145,8 @@ This specification does not authorize:
|
|||
6. a universal recoverability score that hides missing evidence
|
||||
7. automatic merging of ambiguous resources
|
||||
8. a second alert engine inside Patrol
|
||||
9. availability checks as duplicate resources when a canonical target is known
|
||||
9. replacing a configured availability check's source-owned identity with a
|
||||
correlated target or creating more than one check row for one saved target
|
||||
10. platform-specific lifecycle states in primary APIs
|
||||
11. hiding stale, partial-permission, or collection-error states
|
||||
12. MSP multi-tenancy, fleet-wide delegation, or policy inheritance beyond
|
||||
|
|
@ -780,7 +781,8 @@ exposure, but they must not create two writable sources of truth.
|
|||
6. recovery evidence changes protection posture and the related attention item
|
||||
7. notification audit links to the exact transition
|
||||
8. an action executes once and only closes after verified recovery evidence
|
||||
9. an attached availability check does not create a duplicate resource
|
||||
9. an attached availability target keeps exactly one source-owned check
|
||||
resource and one additive facet projection without duplicate incidents
|
||||
10. authorization and tenant boundaries hold across evidence and actions
|
||||
|
||||
### Browser proof
|
||||
|
|
|
|||
|
|
@ -0,0 +1,83 @@
|
|||
# Operational Trust: Availability Check Identity Correction
|
||||
|
||||
Date: 2026-07-23
|
||||
|
||||
## Decision
|
||||
|
||||
Every configured availability target is a source-owned canonical
|
||||
`network-endpoint` resource. Correlation is additive: a uniquely matched VM,
|
||||
host, workload, or service may also carry the check as an availability facet,
|
||||
but that projection never replaces or hides the configured check row.
|
||||
|
||||
The check resource owns current probe state, incidents, alert lifecycle,
|
||||
history, canonical evidence, and the outgoing `checks` relationship. A matched
|
||||
resource receives only the availability facet and a cloned evidence envelope
|
||||
bound to that resource. Projection does not merge the checked service address,
|
||||
name, status, incidents, tags, metrics, or last-seen value into the matched
|
||||
resource.
|
||||
|
||||
## Root Cause Corrected
|
||||
|
||||
The original registry path used the generic identity merge when correlation
|
||||
found a resource. That merge mapped the availability source target directly to
|
||||
the VM or host, copied unrelated service identity and incident state into it,
|
||||
and returned before creating the source-owned endpoint. The frontend then
|
||||
excluded `attached` availability resources, codifying the missing row and
|
||||
incorrect count.
|
||||
|
||||
The corrected registry path creates or replaces the deterministic
|
||||
source-owned endpoint first, projects only its facet onto the match, and stores
|
||||
the relationship on the check. Rehydration seeds availability source mappings
|
||||
only from availability-owned endpoints, and manual resource links cannot fold
|
||||
those endpoints into adjacent resources.
|
||||
|
||||
## Runtime Contract
|
||||
|
||||
- Two services correlated to one monitored VM produce two availability rows
|
||||
and two facets on that VM.
|
||||
- Standalone, ambiguous, unresolved, disabled, and attached targets all retain
|
||||
one row per configured target.
|
||||
- REST and websocket payloads preserve both source-owned rows and plural host
|
||||
projections.
|
||||
- Atomic refresh, reload, and restart preserve the check ID. Editing replaces
|
||||
stale endpoint state and moves the projection; deletion removes both the row
|
||||
and only that target's projection.
|
||||
- Availability incidents and alerts remain single-owned by the check. The
|
||||
matched resource does not receive a duplicate incident.
|
||||
- Check creation, configuration changes, health transitions, and deletion are
|
||||
recorded against the check ID. Observation timestamps and latency changes
|
||||
alone do not create history churn.
|
||||
- Resource registries and browser caches remain tenant-scoped. Identical target
|
||||
IDs in different tenants resolve only against resources in their own tenant.
|
||||
|
||||
## Proof
|
||||
|
||||
Backend regression coverage:
|
||||
|
||||
- `internal/unifiedresources/availability_link_test.go`
|
||||
- `internal/unifiedresources/monitor_adapter_read_state_test.go`
|
||||
- `internal/unifiedresources/change_emission_test.go`
|
||||
- `internal/alerts/unified_incidents_test.go`
|
||||
- `internal/monitoring/canonical_guardrails_test.go`
|
||||
|
||||
Frontend regression coverage:
|
||||
|
||||
- `frontend-modern/src/features/standalone/__tests__/standalonePageModel.test.ts`
|
||||
- `frontend-modern/src/features/standalone/__tests__/StandalonePageSurface.test.tsx`
|
||||
- `frontend-modern/src/hooks/__tests__/useUnifiedResources.test.ts`
|
||||
|
||||
The governed browser scenario covers two attached services on one monitored
|
||||
host plus standalone endpoints and verifies that the Availability checks tab
|
||||
shows the configured total while the host retains both facet details.
|
||||
|
||||
## User Evidence
|
||||
|
||||
- [#1568: Machines - Availability Checks does not show all checks](https://github.com/rcourtman/Pulse/issues/1568)
|
||||
- [#1460: Simple ping-based monitoring](https://github.com/rcourtman/Pulse/issues/1460)
|
||||
- [#1565: UDP/service availability without an agent](https://github.com/rcourtman/Pulse/issues/1565)
|
||||
|
||||
## Governance
|
||||
|
||||
- Owning lane: L13
|
||||
- Dependent contracts: monitoring, unified resources, alerts, API contracts,
|
||||
frontend primitives, and performance/scalability
|
||||
|
|
@ -2,6 +2,12 @@
|
|||
|
||||
Date: 2026-07-19
|
||||
|
||||
Status: Superseded on 2026-07-23 by
|
||||
`operational-trust-availability-check-identity-correction-2026-07-23.md`.
|
||||
This record is retained as historical evidence for the original facet
|
||||
decision and its browser proof; its rule that attached checks disappear from
|
||||
primary inventory is not the current contract.
|
||||
|
||||
## Decision
|
||||
|
||||
Availability is evidence about a canonical resource, not a parallel inventory
|
||||
|
|
|
|||
|
|
@ -28,6 +28,10 @@ smartctl retry modes merge rather than replace evidence, and partial NVMe JSON
|
|||
must preserve field presence so omitted health counters are not fabricated as
|
||||
zero. Direct SATA, SAS, NVMe, and controller-member inventory must all survive
|
||||
one compressed unified-agent report without a collector-side suffix cap.
|
||||
`internal/monitoring/monitor.go` also serializes shared unified-resource
|
||||
websocket payloads. Carrying plural availability facets through that serializer
|
||||
is an adjacent monitoring/API projection and does not change agent enrollment,
|
||||
report admission, removal, update, profile, or command authority.
|
||||
|
||||
## Canonical Files
|
||||
|
||||
|
|
|
|||
|
|
@ -32,6 +32,11 @@ state must be explicitly true. Exit code 137 alone is only SIGKILL evidence;
|
|||
explicit false and unavailable/legacy OOM state both fail closed without an OOM
|
||||
alert. Recovery clears an existing OOM alert when the authoritative predicate
|
||||
is no longer true.
|
||||
Availability incident and alert identity belongs to the source-owned
|
||||
`network-endpoint` check. Correlation may project probe evidence onto a matched
|
||||
machine, but it must not copy the check incident onto that machine or create a
|
||||
second alert lifecycle. Failure, recovery, history, acknowledgement, and
|
||||
notification routing therefore remain stable under relinking and restart.
|
||||
|
||||
## Canonical Files
|
||||
|
||||
|
|
|
|||
|
|
@ -30,6 +30,13 @@ pages until the response's declared `totalPages` is exhausted; no client-side
|
|||
page ceiling may silently truncate a wide fleet. Disk history lookup resolves
|
||||
an exact canonical resource ID first and accepts a metrics target, serial, or
|
||||
WWN only when the match is unique and the hardware identifier is usable.
|
||||
Unified availability responses preserve one source-owned `network-endpoint`
|
||||
resource per configured target, including checks correlated to an existing
|
||||
machine. A correlated machine may carry the same check as an additive
|
||||
availability facet, but that projection is not availability-source ownership
|
||||
and must not suppress the endpoint from type filters, totals, REST reloads, or
|
||||
websocket snapshots. Plural machine projections use `availabilityChecks`; the
|
||||
singular `availability` field remains only a compatibility projection.
|
||||
|
||||
## Canonical Files
|
||||
|
||||
|
|
|
|||
|
|
@ -5072,8 +5072,11 @@ Plural attached checks render as repeated bounded cards from
|
|||
`availabilityChecks`, while the row keeps one compatibility summary. Expired
|
||||
successful evidence must render an amber `Stale` state with no green
|
||||
`Responding normally` copy, and a never-observed check must render
|
||||
`Not checked`. A resource whose correlation state is `attached` must not also
|
||||
appear as a primary row in the Machines `Availability checks` tab.
|
||||
`Not checked`. A matched machine or service carrying an attached projection
|
||||
must not appear as a primary row in the Machines `Availability checks` tab;
|
||||
the distinct source-owned `network-endpoint` for that configured check must
|
||||
appear there regardless of whether its correlation state is `attached`,
|
||||
`standalone`, `ambiguous`, or `unresolved`.
|
||||
Operational navigation for those agentless endpoints belongs to the
|
||||
frontend-primitives-owned Machines surface as a focused Availability checks tab
|
||||
rather than a new primary nav item. The page may show availability checks beside
|
||||
|
|
|
|||
|
|
@ -720,17 +720,21 @@ the default low-overhead check, while TCP and HTTP are canonical fallbacks for
|
|||
devices or runtimes where ICMP is unavailable or the useful signal is a port or
|
||||
web interface.
|
||||
Supplemental records carry the saved target's optional `LinkedResourceID`
|
||||
forward into `AvailabilityData` so the unified-resource registry can attach
|
||||
the probe facet onto the referenced resource. Monitoring does not perform the
|
||||
attach decision itself; it only forwards the link hint for the registry to
|
||||
resolve. Every completed probe also authors an operational-trust
|
||||
forward into `AvailabilityData` so the unified-resource registry can correlate
|
||||
and project the probe facet onto the referenced resource. Every saved target
|
||||
continues to emit its own `network-endpoint` supplemental record regardless of
|
||||
that correlation outcome; monitoring never substitutes a matched host or
|
||||
service identity for the configured check. Monitoring does not perform the
|
||||
correlation decision itself; it only forwards the link hint for the registry
|
||||
to resolve. Every completed probe also authors an operational-trust
|
||||
`EvidenceEnvelope` with provider `availability`, collector
|
||||
`availability-poller`, the saved target as its provider reference, the exact
|
||||
observation/ingest times, and a validity window of twice the effective polling
|
||||
interval. Before the first completed probe, evidence is explicitly partial and
|
||||
unknown with reason `availability_not_observed`; monitoring must never encode
|
||||
that state as a confirmed failure or a healthy observation. The registry owns
|
||||
rebinding the envelope subject to a canonical resource after correlation.
|
||||
binding the source envelope to the check resource and cloning a separately
|
||||
bound envelope for any matched-resource facet projection after correlation.
|
||||
Availability target kind is monitoring-owned runtime metadata, not a frontend
|
||||
guess. Saved targets carry the bounded `targetKind` values `machine`, `service`,
|
||||
and `device`; monitoring must preserve that value in probe status, supplemental
|
||||
|
|
|
|||
|
|
@ -30,6 +30,11 @@ disk behind a client-side page ceiling. Search includes vendor, WWN, transport,
|
|||
instance, controller, and member target. Explicit `0%` life remaining is a
|
||||
known critical value for SSD/NVMe media; absent or negative wearout remains the
|
||||
neutral unknown state.
|
||||
The shared `internal/api/resources.go` registry builder may repair
|
||||
availability-check identity by replaying authoritative supplemental records.
|
||||
That availability composition remains owned by API contracts and unified
|
||||
resources; it does not make availability rows recovery points, storage health,
|
||||
backup evidence, or restore authority.
|
||||
|
||||
## Canonical Files
|
||||
|
||||
|
|
|
|||
|
|
@ -1848,19 +1848,31 @@ normalized IP address or one exact normalized hostname matches exactly one
|
|||
non-availability-owned canonical resource. Zero matches are `standalone`;
|
||||
multiple matches are `ambiguous`; neither may be guessed. A genuinely
|
||||
standalone target keeps its `network-endpoint`. An attached target does not
|
||||
mint a duplicate endpoint.
|
||||
collapse into the matched resource: it keeps the same source-owned
|
||||
`network-endpoint` row and projects an additive facet onto the matched
|
||||
resource.
|
||||
|
||||
The attached resource carries every check in the canonical
|
||||
The source-owned endpoint is the canonical identity for each configured check.
|
||||
It owns probe status, incidents, history, evidence, and the outgoing `checks`
|
||||
relationship. The matched resource carries every correlated check in the canonical
|
||||
`availabilityChecks` facet, keyed by saved target id, while `availability`
|
||||
remains an additive singular compatibility summary selected from that set.
|
||||
Adding a second explicit or unambiguously correlated check must retain both
|
||||
checks on the same resource, emit one `checks` relationship per target, and
|
||||
must not force the later check into duplicate standalone inventory. Each
|
||||
attached check's evidence subject is rebound to the owning canonical resource
|
||||
and includes the exact correlation rule and matched field. Ambiguous and
|
||||
unresolved standalone evidence carries a typed reason instead.
|
||||
source-owned endpoint rows, project both facets onto the same resource, and
|
||||
emit one `checks` relationship per target from the check to that resource.
|
||||
The check evidence subject is bound to the source-owned endpoint; the projected
|
||||
facet carries a cloned envelope rebound to the matched resource. Both include
|
||||
the exact correlation rule and matched field. Ambiguous and unresolved check
|
||||
evidence carries a typed reason instead.
|
||||
Availability projection is deliberately narrower than identity merge: it must
|
||||
not copy the checked service address, name, status, incident, tags, metrics, or
|
||||
last-seen value into the matched machine or service. Rehydration may seed
|
||||
`SourceAvailability` identity only from availability-owned endpoints, never
|
||||
from a host projection, and manual identity links must not erase a configured
|
||||
check row.
|
||||
Frontend resource adapters must preserve that same availability identity on
|
||||
both REST and realtime paths: a thin `network-endpoint` update with
|
||||
both REST and realtime paths, including plural `availabilityChecks` on matched
|
||||
resources: a thin `network-endpoint` update with
|
||||
availability data is still `platformType=availability`, `sourceType=api`, and
|
||||
must not regress to a generic platform badge in infrastructure rows or drawers.
|
||||
Infrastructure row presentation must also consume that availability payload as
|
||||
|
|
@ -1880,8 +1892,9 @@ Frontend primitives owns Machines as the operational presentation for those
|
|||
same agentless checks; unified resources owns the projection contract consumed
|
||||
there. `StandalonePageSurface.tsx` must fetch both `agent` and
|
||||
`network-endpoint` resources, keep standalone machines and availability checks
|
||||
as separate buckets in `standalonePageModel.ts`, exclude every resource whose
|
||||
availability correlation state is `attached`, and let
|
||||
as separate buckets in `standalonePageModel.ts`, include every source-owned
|
||||
`network-endpoint` regardless of correlation state, exclude matched machine or
|
||||
service projections from the check inventory by resource type, and let
|
||||
`AvailabilityChecksTable.tsx` render saved probe method, target, latest result,
|
||||
check age, failure count, and cadence from the canonical availability payload.
|
||||
Recent check timing and fuller failure context may stay in tooltip or drawer
|
||||
|
|
|
|||
|
|
@ -264,6 +264,71 @@ describe('StandalonePageSurface', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('counts every configured check when two attached services share one monitored host', () => {
|
||||
mocks.pathname = '/standalone/availability';
|
||||
mocks.useUnifiedResources.mockReturnValue({
|
||||
resources: () => [
|
||||
resource({
|
||||
id: 'agent-core2026',
|
||||
type: 'agent',
|
||||
platformType: 'agent',
|
||||
sources: ['agent', 'availability'],
|
||||
availability: freshAvailability({
|
||||
targetId: 'stats-pv',
|
||||
correlationState: 'attached',
|
||||
}),
|
||||
}),
|
||||
resource({
|
||||
id: 'availability:stats-pv',
|
||||
type: 'network-endpoint',
|
||||
platformType: 'availability',
|
||||
sources: ['availability'],
|
||||
availability: freshAvailability({
|
||||
targetId: 'stats-pv',
|
||||
correlationState: 'attached',
|
||||
}),
|
||||
}),
|
||||
resource({
|
||||
id: 'availability:grafana',
|
||||
type: 'network-endpoint',
|
||||
platformType: 'availability',
|
||||
sources: ['availability'],
|
||||
availability: freshAvailability({
|
||||
targetId: 'grafana',
|
||||
correlationState: 'attached',
|
||||
}),
|
||||
}),
|
||||
resource({
|
||||
id: 'availability:public-api',
|
||||
type: 'network-endpoint',
|
||||
platformType: 'availability',
|
||||
sources: ['availability'],
|
||||
availability: freshAvailability({ targetId: 'public-api' }),
|
||||
}),
|
||||
resource({
|
||||
id: 'availability:router',
|
||||
type: 'network-endpoint',
|
||||
platformType: 'availability',
|
||||
sources: ['availability'],
|
||||
availability: freshAvailability({ targetId: 'router' }),
|
||||
}),
|
||||
],
|
||||
loading: () => false,
|
||||
error: () => null,
|
||||
refetch: vi.fn(),
|
||||
});
|
||||
|
||||
render(() => <StandalonePageSurface />);
|
||||
|
||||
expect(screen.getByTestId('availability-checks-table')).toHaveAttribute(
|
||||
'data-resource-count',
|
||||
'4',
|
||||
);
|
||||
expect(screen.getByTestId('standalone-posture-summary')).toHaveTextContent(
|
||||
'All 4 checks reporting normally',
|
||||
);
|
||||
});
|
||||
|
||||
it('makes failed availability posture visible before the table', () => {
|
||||
mocks.pathname = '/standalone/availability';
|
||||
mocks.useUnifiedResources.mockReturnValue({
|
||||
|
|
|
|||
|
|
@ -148,7 +148,7 @@ describe('standalonePageModel', () => {
|
|||
expect(model.resources.map((item) => item.id)).toEqual(['mac-mini']);
|
||||
});
|
||||
|
||||
it('does not duplicate an attached resource in the availability-check inventory', () => {
|
||||
it('shows every source-owned check while keeping attached host facets out of the inventory', () => {
|
||||
const model = buildStandalonePageModel([
|
||||
resource({
|
||||
id: 'agent:docker-trust',
|
||||
|
|
@ -162,19 +162,58 @@ describe('standalonePageModel', () => {
|
|||
},
|
||||
}),
|
||||
resource({
|
||||
id: 'availability:orphan',
|
||||
id: 'availability:tower-api',
|
||||
platformType: 'availability',
|
||||
type: 'network-endpoint',
|
||||
sources: ['availability'],
|
||||
availability: {
|
||||
targetId: 'orphan',
|
||||
targetId: 'tower-api',
|
||||
correlationState: 'attached',
|
||||
available: true,
|
||||
},
|
||||
}),
|
||||
resource({
|
||||
id: 'availability:tower-grafana',
|
||||
platformType: 'availability',
|
||||
type: 'network-endpoint',
|
||||
sources: ['availability'],
|
||||
availability: {
|
||||
targetId: 'tower-grafana',
|
||||
correlationState: 'attached',
|
||||
available: true,
|
||||
},
|
||||
}),
|
||||
resource({
|
||||
id: 'availability:public-api',
|
||||
platformType: 'availability',
|
||||
type: 'network-endpoint',
|
||||
sources: ['availability'],
|
||||
availability: {
|
||||
targetId: 'public-api',
|
||||
correlationState: 'standalone',
|
||||
available: true,
|
||||
},
|
||||
}),
|
||||
resource({
|
||||
id: 'availability:router',
|
||||
platformType: 'availability',
|
||||
type: 'network-endpoint',
|
||||
sources: ['availability'],
|
||||
availability: {
|
||||
targetId: 'router',
|
||||
correlationState: 'standalone',
|
||||
available: true,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(model.availabilityChecks.map((item) => item.id)).toEqual(['availability:orphan']);
|
||||
expect(model.availabilityChecks.map((item) => item.id)).toEqual([
|
||||
'availability:tower-api',
|
||||
'availability:tower-grafana',
|
||||
'availability:public-api',
|
||||
'availability:router',
|
||||
]);
|
||||
expect(model.machines.map((item) => item.id)).toEqual(['agent:docker-trust']);
|
||||
});
|
||||
|
||||
it('treats stale or unobserved availability evidence as attention', () => {
|
||||
|
|
|
|||
|
|
@ -23,8 +23,7 @@ export const isStandaloneMachineResource = (resource: Resource): boolean =>
|
|||
isPulseAgentPlatformResource(resource);
|
||||
|
||||
export const isAgentlessAvailabilityResource = (resource: Resource): boolean =>
|
||||
resource.availability?.correlationState !== 'attached' &&
|
||||
(resource.type === 'network-endpoint' || resource.platformType === 'availability');
|
||||
resource.type === 'network-endpoint' || resource.platformType === 'availability';
|
||||
|
||||
const AGENT_REPORT_STALE_AFTER_MS = 5 * 60 * 1000;
|
||||
|
||||
|
|
|
|||
|
|
@ -307,6 +307,111 @@ describe('useUnifiedResources', () => {
|
|||
dispose();
|
||||
});
|
||||
|
||||
it('keeps an attached availability check distinct from its host across REST and websocket updates', async () => {
|
||||
const host = createWsResource({
|
||||
id: 'agent-core2026',
|
||||
name: 'core2026',
|
||||
displayName: 'core2026',
|
||||
availability: {
|
||||
targetId: 'stats-pv',
|
||||
correlationState: 'attached',
|
||||
available: true,
|
||||
},
|
||||
availabilityChecks: [
|
||||
{
|
||||
targetId: 'stats-pv',
|
||||
correlationState: 'attached',
|
||||
available: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
const check = createWsResource({
|
||||
id: 'network-endpoint-stats-pv',
|
||||
type: 'network-endpoint',
|
||||
name: 'stats_pv',
|
||||
displayName: 'stats_pv',
|
||||
platformId: 'stats_pv',
|
||||
platformType: 'availability',
|
||||
sourceType: 'api',
|
||||
sources: ['availability'],
|
||||
availability: {
|
||||
targetId: 'stats-pv',
|
||||
correlationState: 'attached',
|
||||
available: true,
|
||||
},
|
||||
});
|
||||
setWsState('resources', reconcile([host, check]));
|
||||
apiFetchMock.mockResolvedValueOnce(
|
||||
resourceResponse([
|
||||
{
|
||||
...v2Resource,
|
||||
id: host.id,
|
||||
name: host.name,
|
||||
availability: host.availability,
|
||||
availabilityChecks: host.availabilityChecks,
|
||||
},
|
||||
{
|
||||
...v2Resource,
|
||||
id: check.id,
|
||||
type: 'network-endpoint',
|
||||
name: check.name,
|
||||
sources: ['availability'],
|
||||
availability: check.availability,
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
let dispose = () => {};
|
||||
let result: ReturnType<UseUnifiedResourcesModule['useUnifiedResources']> | undefined;
|
||||
createRoot((d) => {
|
||||
dispose = d;
|
||||
result = useUnifiedResources();
|
||||
});
|
||||
|
||||
await waitForResourceCount(() => result!.resources().length, 2);
|
||||
expect(
|
||||
result!
|
||||
.resources()
|
||||
.map((resource) => resource.id)
|
||||
.sort(),
|
||||
).toEqual(['agent-core2026', 'network-endpoint-stats-pv']);
|
||||
|
||||
batch(() => {
|
||||
setWsState(
|
||||
'resources',
|
||||
reconcile([
|
||||
host,
|
||||
{
|
||||
...check,
|
||||
status: 'offline',
|
||||
availability: {
|
||||
...check.availability,
|
||||
available: false,
|
||||
consecutiveFailures: 2,
|
||||
},
|
||||
},
|
||||
]),
|
||||
);
|
||||
setWsState('lastUpdate', 1);
|
||||
});
|
||||
await flushAsync();
|
||||
|
||||
expect(result!.resources()).toHaveLength(2);
|
||||
expect(
|
||||
result!.resources().find((resource) => resource.id === 'network-endpoint-stats-pv'),
|
||||
).toEqual(
|
||||
expect.objectContaining({
|
||||
status: 'offline',
|
||||
availability: expect.objectContaining({ available: false }),
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
result!.resources().find((resource) => resource.id === 'agent-core2026')?.availabilityChecks,
|
||||
).toHaveLength(1);
|
||||
|
||||
dispose();
|
||||
});
|
||||
|
||||
it('keeps comma-separated type filters in the browser-encoded resource query shape', async () => {
|
||||
let dispose = () => {};
|
||||
let result: ReturnType<UseUnifiedResourcesModule['useUnifiedResources']> | undefined;
|
||||
|
|
|
|||
|
|
@ -75,7 +75,7 @@ func TestSyncUnifiedResourceIncidentsCreatesAndClearsAlerts(t *testing.T) {
|
|||
assertAlertMissing(t, m, alertID)
|
||||
}
|
||||
|
||||
func TestSyncUnifiedResourceIncidentsRoutesAttachedAvailabilityEvidenceThroughLifecycle(t *testing.T) {
|
||||
func TestSyncUnifiedResourceIncidentsRoutesAttachedCheckThroughSourceOwnedLifecycle(t *testing.T) {
|
||||
m := newTestManager(t)
|
||||
configureUnifiedEvalManager(t, m, unifiedEvalBaseConfig())
|
||||
|
||||
|
|
@ -84,25 +84,56 @@ func TestSyncUnifiedResourceIncidentsRoutesAttachedAvailabilityEvidenceThroughLi
|
|||
Provider: "availability",
|
||||
Collector: "availability-poller",
|
||||
}
|
||||
subject := operationaltrust.EvidenceSubject{ResourceID: "docker-service:api"}
|
||||
subject := operationaltrust.EvidenceSubject{ResourceID: "availability:energy-meter"}
|
||||
evidenceID, err := operationaltrust.NewEvidenceID(source, subject, observedAt, "energy-meter")
|
||||
if err != nil {
|
||||
t.Fatalf("NewEvidenceID() error = %v", err)
|
||||
}
|
||||
validUntil := observedAt.Add(2 * time.Minute)
|
||||
resource := unifiedresources.Resource{
|
||||
host := unifiedresources.Resource{
|
||||
ID: "docker-service:api",
|
||||
Type: unifiedresources.ResourceTypeDockerService,
|
||||
Name: "API",
|
||||
Sources: []unifiedresources.DataSource{unifiedresources.SourceDocker, unifiedresources.SourceAvailability},
|
||||
Availability: &unifiedresources.AvailabilityData{
|
||||
TargetID: "api-health",
|
||||
Address: "192.0.2.45",
|
||||
Protocol: "http",
|
||||
Port: 8080,
|
||||
Enabled: true,
|
||||
Available: true,
|
||||
CorrelationState: unifiedresources.AvailabilityCorrelationAttached,
|
||||
TargetID: "energy-meter",
|
||||
Address: "192.0.2.44",
|
||||
Protocol: "icmp",
|
||||
Enabled: true,
|
||||
Available: false,
|
||||
ConsecutiveFailures: 2,
|
||||
CorrelationState: unifiedresources.AvailabilityCorrelationAttached,
|
||||
},
|
||||
}
|
||||
check := unifiedresources.Resource{
|
||||
ID: "availability:energy-meter",
|
||||
Type: unifiedresources.ResourceTypeNetworkEndpoint,
|
||||
Name: "Energy meter",
|
||||
Sources: []unifiedresources.DataSource{unifiedresources.SourceAvailability},
|
||||
Availability: &unifiedresources.AvailabilityData{
|
||||
TargetID: "energy-meter",
|
||||
Address: "192.0.2.44",
|
||||
Protocol: "icmp",
|
||||
Enabled: true,
|
||||
Available: false,
|
||||
ConsecutiveFailures: 2,
|
||||
FailureThreshold: 2,
|
||||
CorrelationState: unifiedresources.AvailabilityCorrelationAttached,
|
||||
Evidence: &operationaltrust.EvidenceEnvelope{
|
||||
ID: evidenceID,
|
||||
Source: source,
|
||||
Subject: subject,
|
||||
ObservedAt: observedAt,
|
||||
IngestedAt: observedAt,
|
||||
ValidUntil: &validUntil,
|
||||
Completeness: operationaltrust.EvidenceComplete,
|
||||
Confidence: operationaltrust.EvidenceConfirmed,
|
||||
Permissions: operationaltrust.EvidencePermissionsSufficient,
|
||||
PayloadRef: &operationaltrust.EvidencePayloadRef{
|
||||
Kind: "availability-target",
|
||||
ID: "energy-meter",
|
||||
},
|
||||
},
|
||||
},
|
||||
AvailabilityChecks: []unifiedresources.AvailabilityData{{
|
||||
TargetID: "energy-meter",
|
||||
|
|
@ -139,9 +170,9 @@ func TestSyncUnifiedResourceIncidentsRoutesAttachedAvailabilityEvidenceThroughLi
|
|||
}},
|
||||
}
|
||||
|
||||
m.SyncUnifiedResourceIncidents([]unifiedresources.Resource{resource})
|
||||
m.SyncUnifiedResourceIncidents([]unifiedresources.Resource{host, check})
|
||||
|
||||
alertID := unifiedIncidentAlertID(resource, resource.Incidents[0])
|
||||
alertID := unifiedIncidentAlertID(check, check.Incidents[0])
|
||||
assertAlertPresent(t, m, alertID)
|
||||
|
||||
m.mu.RLock()
|
||||
|
|
@ -151,8 +182,8 @@ func TestSyncUnifiedResourceIncidentsRoutesAttachedAvailabilityEvidenceThroughLi
|
|||
if alert.Type != "resource-incident" {
|
||||
t.Fatalf("alert type = %q, want resource-incident", alert.Type)
|
||||
}
|
||||
if alert.ResourceID != resource.ID {
|
||||
t.Fatalf("resource id = %q, want %q", alert.ResourceID, resource.ID)
|
||||
if alert.ResourceID != check.ID {
|
||||
t.Fatalf("resource id = %q, want source-owned check %q", alert.ResourceID, check.ID)
|
||||
}
|
||||
if alert.Instance != "Availability" {
|
||||
t.Fatalf("instance = %q, want Availability", alert.Instance)
|
||||
|
|
|
|||
|
|
@ -263,17 +263,33 @@ type resourceContractSnapshot struct {
|
|||
Type string
|
||||
}
|
||||
|
||||
func TestContract_UnifiedSeedSourcesIncludesPluralAvailabilityFacets(t *testing.T) {
|
||||
sources := unifiedSeedSources([]unifiedresources.Resource{{
|
||||
ID: "docker-host:ops",
|
||||
Type: unifiedresources.ResourceTypeAgent,
|
||||
func TestContract_UnifiedSeedSourcesUsesSourceOwnedAvailabilityEndpoint(t *testing.T) {
|
||||
hostProjection := unifiedresources.Resource{
|
||||
ID: "docker-host:ops",
|
||||
Type: unifiedresources.ResourceTypeAgent,
|
||||
Sources: []unifiedresources.DataSource{unifiedresources.SourceDocker, unifiedresources.SourceAvailability},
|
||||
SourceStatus: map[unifiedresources.DataSource]unifiedresources.SourceStatus{
|
||||
unifiedresources.SourceAvailability: {},
|
||||
},
|
||||
AvailabilityChecks: []unifiedresources.AvailabilityData{{
|
||||
TargetID: "ops-api",
|
||||
}},
|
||||
}})
|
||||
}
|
||||
sources := unifiedSeedSources([]unifiedresources.Resource{hostProjection})
|
||||
if _, ok := sources[unifiedresources.SourceAvailability]; ok {
|
||||
t.Fatalf("seed sources = %v, host projection must not claim provider ownership", sources)
|
||||
}
|
||||
|
||||
check := unifiedresources.Resource{
|
||||
ID: "network-endpoint:ops-api",
|
||||
Type: unifiedresources.ResourceTypeNetworkEndpoint,
|
||||
AvailabilityChecks: []unifiedresources.AvailabilityData{{
|
||||
TargetID: "ops-api",
|
||||
}},
|
||||
}
|
||||
sources = unifiedSeedSources([]unifiedresources.Resource{hostProjection, check})
|
||||
if _, ok := sources[unifiedresources.SourceAvailability]; !ok {
|
||||
t.Fatalf("seed sources = %v, want availability from plural resource facet", sources)
|
||||
t.Fatalf("seed sources = %v, want availability from source-owned endpoint", sources)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -12944,6 +12960,66 @@ func TestContract_ResourceListAcceptsBrowserEncodedTypeCSV(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestContract_ResourceListCountsEveryConfiguredAvailabilityCheck(t *testing.T) {
|
||||
now := time.Date(2026, 7, 23, 21, 30, 0, 0, time.UTC)
|
||||
h := newActionTestResourceHandlers(t, &config.Config{DataPath: t.TempDir()})
|
||||
resources := []unifiedresources.Resource{{
|
||||
ID: "agent-core2026",
|
||||
Type: unifiedresources.ResourceTypeAgent,
|
||||
Name: "core2026",
|
||||
Status: unifiedresources.StatusOnline,
|
||||
LastSeen: now,
|
||||
Sources: []unifiedresources.DataSource{unifiedresources.SourceAgent, unifiedresources.SourceAvailability},
|
||||
AvailabilityChecks: []unifiedresources.AvailabilityData{
|
||||
{TargetID: "stats-pv", CorrelationState: unifiedresources.AvailabilityCorrelationAttached},
|
||||
{TargetID: "grafana", CorrelationState: unifiedresources.AvailabilityCorrelationAttached},
|
||||
},
|
||||
}}
|
||||
for _, targetID := range []string{"stats-pv", "grafana", "public-api", "router"} {
|
||||
correlation := unifiedresources.AvailabilityCorrelationStandalone
|
||||
if targetID == "stats-pv" || targetID == "grafana" {
|
||||
correlation = unifiedresources.AvailabilityCorrelationAttached
|
||||
}
|
||||
resources = append(resources, unifiedresources.Resource{
|
||||
ID: "network-endpoint:" + targetID,
|
||||
Type: unifiedresources.ResourceTypeNetworkEndpoint,
|
||||
Name: targetID,
|
||||
Status: unifiedresources.StatusOnline,
|
||||
LastSeen: now,
|
||||
Sources: []unifiedresources.DataSource{unifiedresources.SourceAvailability},
|
||||
Availability: &unifiedresources.AvailabilityData{
|
||||
TargetID: targetID,
|
||||
Enabled: true,
|
||||
Available: true,
|
||||
CorrelationState: correlation,
|
||||
},
|
||||
})
|
||||
}
|
||||
h.SetStateProvider(resourceUnifiedSeedProvider{
|
||||
snapshot: models.StateSnapshot{LastUpdate: now},
|
||||
resources: resources,
|
||||
})
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/resources?type=network-endpoint&page=1&limit=100", nil)
|
||||
h.HandleListResources(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var response ResourcesResponse
|
||||
if err := json.NewDecoder(rec.Body).Decode(&response); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if len(response.Data) != 4 || response.Meta.Total != 4 {
|
||||
t.Fatalf("availability response count = data:%d total:%d, want 4", len(response.Data), response.Meta.Total)
|
||||
}
|
||||
for _, resource := range response.Data {
|
||||
if resource.Type != unifiedresources.ResourceTypeNetworkEndpoint {
|
||||
t.Fatalf("availability filter leaked %q resource %q", resource.Type, resource.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestContract_StateAndResourceListShareCanonicalMockResourceContract(t *testing.T) {
|
||||
setMockModeForTest(t, true)
|
||||
|
||||
|
|
|
|||
|
|
@ -1026,11 +1026,19 @@ func (h *ResourceHandlers) buildRegistry(orgID string) (*unified.ResourceRegistr
|
|||
|
||||
registry := unified.NewRegistry(store)
|
||||
ownedSources := supplementalSnapshotOwnedSources(supplementalProviders, orgID)
|
||||
supplementalSources := sortedSupplementalSources(supplementalProviders)
|
||||
if seed.unifiedSource {
|
||||
registry.IngestResources(seed.resources)
|
||||
seedSources := unifiedSeedSources(seed.resources)
|
||||
for source, provider := range supplementalProviders {
|
||||
if provider == nil || !sourceOwnedBySupplementalProvider(source, ownedSources) || unifiedSeedIncludesSource(seedSources, source) {
|
||||
for _, source := range supplementalSources {
|
||||
provider := supplementalProviders[source]
|
||||
// Availability records are safe authoritative replacements and
|
||||
// must replay even when an older unified seed contains standalone
|
||||
// endpoints. Pre-fix seeds could otherwise hide attached targets
|
||||
// while falsely looking source-complete.
|
||||
if provider == nil ||
|
||||
!sourceOwnedBySupplementalProvider(source, ownedSources) ||
|
||||
(source != unified.SourceAvailability && unifiedSeedIncludesSource(seedSources, source)) {
|
||||
continue
|
||||
}
|
||||
records := supplementalRecordsForOrg(provider, orgID)
|
||||
|
|
@ -1042,7 +1050,8 @@ func (h *ResourceHandlers) buildRegistry(orgID string) (*unified.ResourceRegistr
|
|||
} else {
|
||||
registry.IngestSnapshot(unified.SnapshotWithoutSources(seed.snapshot, ownedSources))
|
||||
|
||||
for source, provider := range supplementalProviders {
|
||||
for _, source := range supplementalSources {
|
||||
provider := supplementalProviders[source]
|
||||
if provider == nil {
|
||||
continue
|
||||
}
|
||||
|
|
@ -1061,6 +1070,25 @@ func (h *ResourceHandlers) buildRegistry(orgID string) (*unified.ResourceRegistr
|
|||
return registry, nil
|
||||
}
|
||||
|
||||
func sortedSupplementalSources(
|
||||
providers map[unified.DataSource]SupplementalRecordsProvider,
|
||||
) []unified.DataSource {
|
||||
sources := make([]unified.DataSource, 0, len(providers))
|
||||
for source := range providers {
|
||||
sources = append(sources, source)
|
||||
}
|
||||
sort.Slice(sources, func(i, j int) bool {
|
||||
if sources[i] == unified.SourceAvailability {
|
||||
return false
|
||||
}
|
||||
if sources[j] == unified.SourceAvailability {
|
||||
return true
|
||||
}
|
||||
return string(sources[i]) < string(sources[j])
|
||||
})
|
||||
return sources
|
||||
}
|
||||
|
||||
func (h *ResourceHandlers) registrySeed(orgID string) (registrySeed, error) {
|
||||
seed := registrySeed{}
|
||||
|
||||
|
|
@ -1163,13 +1191,20 @@ func unifiedSeedSources(resources []unified.Resource) map[unified.DataSource]str
|
|||
|
||||
sources := make(map[unified.DataSource]struct{})
|
||||
for _, resource := range resources {
|
||||
availabilityOwned := unified.CanonicalResourceType(resource.Type) == unified.ResourceTypeNetworkEndpoint
|
||||
for _, source := range resource.Sources {
|
||||
if normalized := normalizeDataSourceAlias(source); normalized != "" {
|
||||
if normalized == unified.SourceAvailability && !availabilityOwned {
|
||||
continue
|
||||
}
|
||||
sources[normalized] = struct{}{}
|
||||
}
|
||||
}
|
||||
for source := range resource.SourceStatus {
|
||||
if normalized := normalizeDataSourceAlias(source); normalized != "" {
|
||||
if normalized == unified.SourceAvailability && !availabilityOwned {
|
||||
continue
|
||||
}
|
||||
sources[normalized] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
|
@ -1178,7 +1213,8 @@ func unifiedSeedSources(resources []unified.Resource) map[unified.DataSource]str
|
|||
sources[unified.SourceTrueNAS] = struct{}{}
|
||||
case resource.VMware != nil:
|
||||
sources[unified.SourceVMware] = struct{}{}
|
||||
case len(unified.AvailabilityChecksForResource(resource)) > 0:
|
||||
case availabilityOwned &&
|
||||
len(unified.AvailabilityChecksForResource(resource)) > 0:
|
||||
sources[unified.SourceAvailability] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import (
|
|||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
|
@ -63,6 +64,124 @@ type mockSupplementalRecordsProvider struct {
|
|||
ownedSources []unified.DataSource
|
||||
}
|
||||
|
||||
func TestResourceListRepairsPreFixAvailabilitySnapshotFromConfiguredTargets(t *testing.T) {
|
||||
now := time.Date(2026, 7, 23, 22, 0, 0, 0, time.UTC)
|
||||
hostID := "agent-core2026"
|
||||
host := unified.Resource{
|
||||
ID: hostID,
|
||||
Type: unified.ResourceTypeAgent,
|
||||
Name: "core2026",
|
||||
Status: unified.StatusOnline,
|
||||
LastSeen: now,
|
||||
Sources: []unified.DataSource{unified.SourceAgent, unified.SourceAvailability},
|
||||
Availability: &unified.AvailabilityData{
|
||||
TargetID: "stats-pv",
|
||||
LinkedResourceID: hostID,
|
||||
Address: "192.0.2.70",
|
||||
Protocol: "https",
|
||||
Enabled: true,
|
||||
Available: true,
|
||||
CorrelationState: unified.AvailabilityCorrelationAttached,
|
||||
},
|
||||
}
|
||||
seed := []unified.Resource{host}
|
||||
for _, targetID := range []string{"public-api", "router", "switch"} {
|
||||
seed = append(seed, unified.Resource{
|
||||
ID: "network-endpoint:" + targetID,
|
||||
Type: unified.ResourceTypeNetworkEndpoint,
|
||||
Name: targetID,
|
||||
Status: unified.StatusOnline,
|
||||
LastSeen: now,
|
||||
Sources: []unified.DataSource{unified.SourceAvailability},
|
||||
Availability: &unified.AvailabilityData{
|
||||
TargetID: targetID,
|
||||
Address: targetID + ".example.test",
|
||||
Protocol: "https",
|
||||
Enabled: true,
|
||||
Available: true,
|
||||
CorrelationState: unified.AvailabilityCorrelationStandalone,
|
||||
},
|
||||
})
|
||||
}
|
||||
record := func(targetID, linkedResourceID string) unified.IngestRecord {
|
||||
availability := &unified.AvailabilityData{
|
||||
TargetID: targetID,
|
||||
LinkedResourceID: linkedResourceID,
|
||||
Address: targetID + ".example.test",
|
||||
Protocol: "https",
|
||||
Enabled: true,
|
||||
Available: true,
|
||||
}
|
||||
return unified.IngestRecord{
|
||||
SourceID: targetID,
|
||||
Resource: unified.Resource{
|
||||
Type: unified.ResourceTypeNetworkEndpoint,
|
||||
Name: targetID,
|
||||
Status: unified.StatusOnline,
|
||||
LastSeen: now,
|
||||
Sources: []unified.DataSource{unified.SourceAvailability},
|
||||
Availability: availability,
|
||||
},
|
||||
Identity: unified.ResourceIdentity{Hostnames: []string{availability.Address}},
|
||||
}
|
||||
}
|
||||
|
||||
h := NewResourceHandlers(&config.Config{DataPath: t.TempDir()})
|
||||
h.SetStateProvider(&mutableResourceUnifiedSeedProvider{
|
||||
resources: seed,
|
||||
freshness: now,
|
||||
})
|
||||
h.SetSupplementalRecordsProvider(unified.SourceAvailability, mockSupplementalRecordsProvider{
|
||||
records: []unified.IngestRecord{
|
||||
record("stats-pv", hostID),
|
||||
record("public-api", ""),
|
||||
record("router", ""),
|
||||
record("switch", ""),
|
||||
},
|
||||
ownedSources: []unified.DataSource{unified.SourceAvailability},
|
||||
})
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/resources?type=network-endpoint&page=1&limit=100", nil)
|
||||
h.HandleListResources(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var response ResourcesResponse
|
||||
if err := json.NewDecoder(rec.Body).Decode(&response); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if len(response.Data) != 4 || response.Meta.Total != 4 {
|
||||
t.Fatalf("repaired availability count = data:%d total:%d, want 4", len(response.Data), response.Meta.Total)
|
||||
}
|
||||
foundAttached := false
|
||||
for _, resource := range response.Data {
|
||||
if resource.Availability != nil && resource.Availability.TargetID == "stats-pv" {
|
||||
foundAttached = resource.Availability.CorrelationState == unified.AvailabilityCorrelationAttached
|
||||
}
|
||||
}
|
||||
if !foundAttached {
|
||||
t.Fatal("pre-fix attached target was not restored as a source-owned endpoint")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSortedSupplementalSourcesIngestsAvailabilityLast(t *testing.T) {
|
||||
provider := mockSupplementalRecordsProvider{}
|
||||
sources := sortedSupplementalSources(map[unified.DataSource]SupplementalRecordsProvider{
|
||||
unified.SourceAvailability: provider,
|
||||
unified.SourceTrueNAS: provider,
|
||||
unified.SourceAgent: provider,
|
||||
})
|
||||
want := []unified.DataSource{
|
||||
unified.SourceAgent,
|
||||
unified.SourceTrueNAS,
|
||||
unified.SourceAvailability,
|
||||
}
|
||||
if !reflect.DeepEqual(sources, want) {
|
||||
t.Fatalf("supplemental source order = %v, want %v", sources, want)
|
||||
}
|
||||
}
|
||||
|
||||
func (m mockSupplementalRecordsProvider) GetCurrentRecords() []unified.IngestRecord {
|
||||
out := make([]unified.IngestRecord, len(m.records))
|
||||
copy(out, m.records)
|
||||
|
|
|
|||
|
|
@ -1174,6 +1174,7 @@ type ResourceConvertInput struct {
|
|||
TrueNAS json.RawMessage
|
||||
VMware json.RawMessage
|
||||
Availability json.RawMessage
|
||||
AvailabilityChecks json.RawMessage
|
||||
PlatformData json.RawMessage
|
||||
}
|
||||
|
||||
|
|
@ -1254,6 +1255,7 @@ func ConvertResourceToFrontend(input ResourceConvertInput) ResourceFrontend {
|
|||
TrueNAS: input.TrueNAS,
|
||||
VMware: input.VMware,
|
||||
Availability: input.Availability,
|
||||
AvailabilityChecks: input.AvailabilityChecks,
|
||||
PlatformData: input.PlatformData,
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1040,28 +1040,29 @@ type ResourceFrontend struct {
|
|||
// Identity for deduplication
|
||||
Identity *ResourceIdentityFrontend `json:"identity,omitempty"`
|
||||
|
||||
DiscoveryTarget json.RawMessage `json:"discoveryTarget,omitempty"`
|
||||
MetricsTarget json.RawMessage `json:"metricsTarget,omitempty"`
|
||||
Canonical json.RawMessage `json:"canonicalIdentity,omitempty"`
|
||||
Policy json.RawMessage `json:"policy,omitempty"`
|
||||
AISafeSummary string `json:"aiSafeSummary,omitempty"`
|
||||
Capabilities json.RawMessage `json:"capabilities,omitempty"`
|
||||
Relationships json.RawMessage `json:"relationships,omitempty"`
|
||||
RecentChanges json.RawMessage `json:"recentChanges,omitempty"`
|
||||
FacetCounts json.RawMessage `json:"facetCounts,omitempty"`
|
||||
Incidents json.RawMessage `json:"incidents,omitempty"`
|
||||
Proxmox json.RawMessage `json:"proxmox,omitempty"`
|
||||
Storage json.RawMessage `json:"storage,omitempty"`
|
||||
Agent json.RawMessage `json:"agent,omitempty"`
|
||||
Docker json.RawMessage `json:"docker,omitempty"`
|
||||
PBS json.RawMessage `json:"pbs,omitempty"`
|
||||
PMG json.RawMessage `json:"pmg,omitempty"`
|
||||
Kubernetes json.RawMessage `json:"kubernetes,omitempty"`
|
||||
PhysicalDisk json.RawMessage `json:"physicalDisk,omitempty"`
|
||||
Ceph json.RawMessage `json:"ceph,omitempty"`
|
||||
TrueNAS json.RawMessage `json:"truenas,omitempty"`
|
||||
VMware json.RawMessage `json:"vmware,omitempty"`
|
||||
Availability json.RawMessage `json:"availability,omitempty"`
|
||||
DiscoveryTarget json.RawMessage `json:"discoveryTarget,omitempty"`
|
||||
MetricsTarget json.RawMessage `json:"metricsTarget,omitempty"`
|
||||
Canonical json.RawMessage `json:"canonicalIdentity,omitempty"`
|
||||
Policy json.RawMessage `json:"policy,omitempty"`
|
||||
AISafeSummary string `json:"aiSafeSummary,omitempty"`
|
||||
Capabilities json.RawMessage `json:"capabilities,omitempty"`
|
||||
Relationships json.RawMessage `json:"relationships,omitempty"`
|
||||
RecentChanges json.RawMessage `json:"recentChanges,omitempty"`
|
||||
FacetCounts json.RawMessage `json:"facetCounts,omitempty"`
|
||||
Incidents json.RawMessage `json:"incidents,omitempty"`
|
||||
Proxmox json.RawMessage `json:"proxmox,omitempty"`
|
||||
Storage json.RawMessage `json:"storage,omitempty"`
|
||||
Agent json.RawMessage `json:"agent,omitempty"`
|
||||
Docker json.RawMessage `json:"docker,omitempty"`
|
||||
PBS json.RawMessage `json:"pbs,omitempty"`
|
||||
PMG json.RawMessage `json:"pmg,omitempty"`
|
||||
Kubernetes json.RawMessage `json:"kubernetes,omitempty"`
|
||||
PhysicalDisk json.RawMessage `json:"physicalDisk,omitempty"`
|
||||
Ceph json.RawMessage `json:"ceph,omitempty"`
|
||||
TrueNAS json.RawMessage `json:"truenas,omitempty"`
|
||||
VMware json.RawMessage `json:"vmware,omitempty"`
|
||||
Availability json.RawMessage `json:"availability,omitempty"`
|
||||
AvailabilityChecks json.RawMessage `json:"availabilityChecks,omitempty"`
|
||||
|
||||
// Platform-specific data (JSON blob)
|
||||
PlatformData json.RawMessage `json:"platformData,omitempty"`
|
||||
|
|
|
|||
|
|
@ -171,6 +171,22 @@ func TestAvailabilityPollProviderListsOnlyEnabledTargets(t *testing.T) {
|
|||
if len(got) != 1 || got[0] != "enabled" {
|
||||
t.Fatalf("ListInstances() = %+v, want [enabled]", got)
|
||||
}
|
||||
records := availabilityPollProvider{}.SupplementalRecords(monitor, "org-a")
|
||||
if len(records) != 2 {
|
||||
t.Fatalf("SupplementalRecords() length = %d, want every configured target", len(records))
|
||||
}
|
||||
foundPaused := false
|
||||
for _, record := range records {
|
||||
if record.SourceID == "paused" {
|
||||
foundPaused = true
|
||||
if record.Resource.Availability == nil || record.Resource.Availability.Enabled {
|
||||
t.Fatalf("paused target projection = %+v, want disabled check row", record.Resource.Availability)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !foundPaused {
|
||||
t.Fatal("disabled configured target is missing from supplemental records")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAvailabilityResourceFromTargetOmitsUnsetProbeTimes(t *testing.T) {
|
||||
|
|
|
|||
|
|
@ -5606,6 +5606,7 @@ func monitorResourceToConvertInput(resource unifiedresources.Resource) models.Re
|
|||
TrueNAS: monitorRawJSON(resource.TrueNAS),
|
||||
VMware: monitorRawJSON(resource.VMware),
|
||||
Availability: monitorRawJSON(resource.Availability),
|
||||
AvailabilityChecks: monitorRawJSON(resource.AvailabilityChecks),
|
||||
PlatformData: monitorPlatformData(resource, resourceType, platformID),
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -20,6 +20,33 @@ import (
|
|||
"github.com/rcourtman/pulse-go-rewrite/pkg/metrics"
|
||||
)
|
||||
|
||||
func TestMonitoringBroadcastCarriesEveryAvailabilityProjection(t *testing.T) {
|
||||
input := monitorResourceToConvertInput(unifiedresources.Resource{
|
||||
ID: "agent-core2026",
|
||||
Type: unifiedresources.ResourceTypeAgent,
|
||||
Name: "core2026",
|
||||
Status: unifiedresources.StatusOnline,
|
||||
Sources: []unifiedresources.DataSource{
|
||||
unifiedresources.SourceAgent,
|
||||
unifiedresources.SourceAvailability,
|
||||
},
|
||||
AvailabilityChecks: []unifiedresources.AvailabilityData{
|
||||
{TargetID: "stats-pv", CorrelationState: unifiedresources.AvailabilityCorrelationAttached},
|
||||
{TargetID: "grafana", CorrelationState: unifiedresources.AvailabilityCorrelationAttached},
|
||||
},
|
||||
})
|
||||
|
||||
payload := string(input.AvailabilityChecks)
|
||||
if !strings.Contains(payload, `"targetId":"stats-pv"`) ||
|
||||
!strings.Contains(payload, `"targetId":"grafana"`) {
|
||||
t.Fatalf("AvailabilityChecks = %s, want both projected checks", payload)
|
||||
}
|
||||
frontend := models.ConvertResourceToFrontend(input)
|
||||
if string(frontend.AvailabilityChecks) != payload {
|
||||
t.Fatalf("frontend availabilityChecks = %s, want %s", frontend.AvailabilityChecks, payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyHostReportOperationReceiptProtocolReplacesCapabilityAuthority(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
baseReport := func(version int) agentshost.Report {
|
||||
|
|
|
|||
|
|
@ -82,7 +82,18 @@ func availabilityProbeEvidence(t *testing.T, targetID string, observedAt time.Ti
|
|||
}
|
||||
}
|
||||
|
||||
func TestAvailabilityExplicitLinkAttachesFacetToKnownResource(t *testing.T) {
|
||||
func availabilityEndpointByTarget(t *testing.T, rr *ResourceRegistry, targetID string) Resource {
|
||||
t.Helper()
|
||||
for _, endpoint := range rr.ListByType(ResourceTypeNetworkEndpoint) {
|
||||
if endpoint.Availability != nil && endpoint.Availability.TargetID == targetID {
|
||||
return endpoint
|
||||
}
|
||||
}
|
||||
t.Fatalf("availability endpoint %q missing", targetID)
|
||||
return Resource{}
|
||||
}
|
||||
|
||||
func TestAvailabilityExplicitLinkRetainsCheckAndProjectsFacetToKnownResource(t *testing.T) {
|
||||
rr := NewRegistry(nil)
|
||||
hostID := ingestAgentFixture(t, rr, "host-1", "machine-1")
|
||||
observedAt := time.Now().UTC()
|
||||
|
|
@ -99,9 +110,10 @@ func TestAvailabilityExplicitLinkAttachesFacetToKnownResource(t *testing.T) {
|
|||
}),
|
||||
})
|
||||
|
||||
if got := rr.ListByType(ResourceTypeNetworkEndpoint); len(got) != 0 {
|
||||
t.Fatalf("expected 0 standalone network endpoints, got %d", len(got))
|
||||
if got := rr.ListByType(ResourceTypeNetworkEndpoint); len(got) != 1 {
|
||||
t.Fatalf("expected configured check to retain its endpoint row, got %d", len(got))
|
||||
}
|
||||
check := availabilityEndpointByTarget(t, rr, "probe-1")
|
||||
host, ok := rr.Get(hostID)
|
||||
if !ok || host == nil {
|
||||
t.Fatalf("host %q missing after ingest", hostID)
|
||||
|
|
@ -125,26 +137,41 @@ func TestAvailabilityExplicitLinkAttachesFacetToKnownResource(t *testing.T) {
|
|||
host.Availability.Evidence.Correlation.Rule != "explicit_resource_link" {
|
||||
t.Fatalf("evidence correlation = %+v, want explicit resource link", host.Availability.Evidence.Correlation)
|
||||
}
|
||||
if check.Availability == nil ||
|
||||
check.Availability.Evidence == nil ||
|
||||
check.Availability.Evidence.Subject.ResourceID != check.ID {
|
||||
t.Fatalf("check evidence = %+v, want source-owned subject %q", check.Availability, check.ID)
|
||||
}
|
||||
if host.Status != StatusOnline || len(host.Incidents) != 0 {
|
||||
t.Fatalf("host status/incidents were overwritten by check: status=%q incidents=%+v", host.Status, host.Incidents)
|
||||
}
|
||||
if len(host.Identity.IPAddresses) != 0 {
|
||||
t.Fatalf("host identity was polluted by service address: %+v", host.Identity)
|
||||
}
|
||||
foundChecksRelationship := false
|
||||
for _, relationship := range host.Relationships {
|
||||
for _, relationship := range check.Relationships {
|
||||
if relationship.Type == RelChecks &&
|
||||
relationship.SourceID == check.ID &&
|
||||
relationship.TargetID == hostID &&
|
||||
relationship.Metadata["targetId"] == "probe-1" {
|
||||
if relationship.ID == "" {
|
||||
t.Fatal("availability relationship is missing its stable ID")
|
||||
}
|
||||
if relationship.EvidenceID != host.Availability.Evidence.ID {
|
||||
if relationship.EvidenceID != check.Availability.Evidence.ID {
|
||||
t.Fatalf(
|
||||
"availability relationship evidence = %q, want %q",
|
||||
relationship.EvidenceID,
|
||||
host.Availability.Evidence.ID,
|
||||
check.Availability.Evidence.ID,
|
||||
)
|
||||
}
|
||||
foundChecksRelationship = true
|
||||
}
|
||||
}
|
||||
if !foundChecksRelationship {
|
||||
t.Fatalf("relationships = %+v, want availability checks edge", host.Relationships)
|
||||
t.Fatalf("relationships = %+v, want availability checks edge", check.Relationships)
|
||||
}
|
||||
if len(host.Relationships) != 0 {
|
||||
t.Fatalf("host must not own the check relationship, got %+v", host.Relationships)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -176,8 +203,8 @@ func TestAvailabilityExactIPMatchAttachesToKnownResource(t *testing.T) {
|
|||
}),
|
||||
})
|
||||
|
||||
if got := rr.ListByType(ResourceTypeNetworkEndpoint); len(got) != 0 {
|
||||
t.Fatalf("expected probe to attach (0 endpoints), got %d", len(got))
|
||||
if got := rr.ListByType(ResourceTypeNetworkEndpoint); len(got) != 1 {
|
||||
t.Fatalf("expected attached probe to retain one endpoint, got %d", len(got))
|
||||
}
|
||||
host, ok := rr.Get(hostID)
|
||||
if !ok || host == nil || host.Availability == nil || host.Availability.TargetID != "probe-ip" {
|
||||
|
|
@ -210,8 +237,8 @@ func TestAvailabilityExactFullHostnameMatchAttachesToKnownResource(t *testing.T)
|
|||
record.Identity = ResourceIdentity{Hostnames: []string{"api.example.test"}}
|
||||
rr.IngestRecords(SourceAvailability, []IngestRecord{record})
|
||||
|
||||
if got := rr.ListByType(ResourceTypeNetworkEndpoint); len(got) != 0 {
|
||||
t.Fatalf("expected hostname probe to attach, got %d standalone endpoints", len(got))
|
||||
if got := rr.ListByType(ResourceTypeNetworkEndpoint); len(got) != 1 {
|
||||
t.Fatalf("expected attached hostname probe to retain its endpoint, got %d", len(got))
|
||||
}
|
||||
host, ok := rr.Get(hostID)
|
||||
if !ok || host == nil || host.Availability == nil {
|
||||
|
|
@ -303,7 +330,7 @@ func TestAvailabilityInvalidExplicitLinkFailsClosedBeforeAddressCorrelation(t *t
|
|||
}
|
||||
}
|
||||
|
||||
func TestAvailabilityKeepsMultipleChecksOnOneCanonicalResource(t *testing.T) {
|
||||
func TestAvailabilityKeepsEveryConfiguredCheckWithMultipleServicesOnOneHost(t *testing.T) {
|
||||
rr := NewRegistry(nil)
|
||||
hostID := ingestAgentFixture(t, rr, "host-1", "machine-1")
|
||||
|
||||
|
|
@ -316,9 +343,6 @@ func TestAvailabilityKeepsMultipleChecksOnOneCanonicalResource(t *testing.T) {
|
|||
Available: true,
|
||||
}),
|
||||
})
|
||||
// A second probe explicitly linked to the same host belongs to the same
|
||||
// canonical resource. The singular facet stays as a compatibility summary
|
||||
// while the plural facet and relationships retain both checks.
|
||||
rr.IngestRecords(SourceAvailability, []IngestRecord{
|
||||
availabilityProbeRecord("probe-b", "203.0.113.11", &AvailabilityData{
|
||||
LinkedResourceID: hostID,
|
||||
|
|
@ -327,6 +351,8 @@ func TestAvailabilityKeepsMultipleChecksOnOneCanonicalResource(t *testing.T) {
|
|||
Enabled: true,
|
||||
Available: true,
|
||||
}),
|
||||
availabilityProbeRecord("probe-public-api", "198.51.100.20", nil),
|
||||
availabilityProbeRecord("probe-router", "198.51.100.21", nil),
|
||||
})
|
||||
|
||||
host, ok := rr.Get(hostID)
|
||||
|
|
@ -344,16 +370,209 @@ func TestAvailabilityKeepsMultipleChecksOnOneCanonicalResource(t *testing.T) {
|
|||
if !targets["probe-a"] || !targets["probe-b"] {
|
||||
t.Fatalf("availability targets = %+v, want probe-a and probe-b", targets)
|
||||
}
|
||||
if got := rr.ListByType(ResourceTypeNetworkEndpoint); len(got) != 0 {
|
||||
t.Fatalf("expected both probes to attach (0 endpoints), got %d", len(got))
|
||||
endpoints := rr.ListByType(ResourceTypeNetworkEndpoint)
|
||||
if len(endpoints) != 4 {
|
||||
t.Fatalf("availability endpoint count = %d, want all 4 configured checks", len(endpoints))
|
||||
}
|
||||
endpointTargets := map[string]bool{}
|
||||
for _, endpoint := range endpoints {
|
||||
if endpoint.Availability == nil {
|
||||
t.Fatalf("endpoint %q missing availability facet", endpoint.ID)
|
||||
}
|
||||
endpointTargets[endpoint.Availability.TargetID] = true
|
||||
}
|
||||
for _, targetID := range []string{"probe-a", "probe-b", "probe-public-api", "probe-router"} {
|
||||
if !endpointTargets[targetID] {
|
||||
t.Fatalf("availability endpoint targets = %+v, missing %q", endpointTargets, targetID)
|
||||
}
|
||||
}
|
||||
stats := rr.Stats()
|
||||
if stats.ByType[ResourceTypeNetworkEndpoint] != 4 {
|
||||
t.Fatalf("network endpoint stats = %d, want 4", stats.ByType[ResourceTypeNetworkEndpoint])
|
||||
}
|
||||
checkRelationships := 0
|
||||
for _, relationship := range host.Relationships {
|
||||
if relationship.Type == RelChecks {
|
||||
checkRelationships++
|
||||
for _, endpoint := range endpoints {
|
||||
for _, relationship := range endpoint.Relationships {
|
||||
if relationship.Type == RelChecks {
|
||||
checkRelationships++
|
||||
}
|
||||
}
|
||||
}
|
||||
if checkRelationships != 2 {
|
||||
t.Fatalf("checks relationships = %d, want 2: %+v", checkRelationships, host.Relationships)
|
||||
t.Fatalf("checks relationships = %d, want 2", checkRelationships)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAvailabilityEditReplacesEndpointAndMovesProjection(t *testing.T) {
|
||||
rr := NewRegistry(nil)
|
||||
hostA := ingestAgentFixture(t, rr, "host-a", "machine-a")
|
||||
now := time.Now().UTC()
|
||||
rr.IngestRecords(SourceAgent, []IngestRecord{{
|
||||
SourceID: "host-b",
|
||||
Resource: Resource{
|
||||
Type: ResourceTypeAgent,
|
||||
Name: "host-b",
|
||||
Status: StatusOnline,
|
||||
LastSeen: now,
|
||||
},
|
||||
Identity: ResourceIdentity{MachineID: "machine-b"},
|
||||
}})
|
||||
var hostB string
|
||||
for _, host := range rr.ListByType(ResourceTypeAgent) {
|
||||
if host.ID != hostA {
|
||||
hostB = host.ID
|
||||
}
|
||||
}
|
||||
if hostB == "" {
|
||||
t.Fatal("second host missing")
|
||||
}
|
||||
|
||||
failed := availabilityProbeRecord("probe-edit", "192.0.2.50", &AvailabilityData{
|
||||
LinkedResourceID: hostA,
|
||||
Address: "192.0.2.50",
|
||||
Protocol: "http",
|
||||
Enabled: true,
|
||||
Available: false,
|
||||
})
|
||||
failed.Resource.Status = StatusOffline
|
||||
failed.Resource.Incidents = []ResourceIncident{{
|
||||
Provider: string(SourceAvailability),
|
||||
NativeID: "probe-edit",
|
||||
Code: "availability_unreachable",
|
||||
}}
|
||||
rr.IngestRecords(SourceAvailability, []IngestRecord{failed})
|
||||
|
||||
recovered := availabilityProbeRecord("probe-edit", "192.0.2.51", &AvailabilityData{
|
||||
LinkedResourceID: hostB,
|
||||
Address: "192.0.2.51",
|
||||
Protocol: "https",
|
||||
Enabled: true,
|
||||
Available: true,
|
||||
})
|
||||
rr.IngestRecords(SourceAvailability, []IngestRecord{recovered})
|
||||
|
||||
oldHost, _ := rr.Get(hostA)
|
||||
if len(AvailabilityChecksForResource(*oldHost)) != 0 || hasDataSource(oldHost.Sources, SourceAvailability) {
|
||||
t.Fatalf("old host retained moved projection: %+v", oldHost)
|
||||
}
|
||||
newHost, _ := rr.Get(hostB)
|
||||
if checks := AvailabilityChecksForResource(*newHost); len(checks) != 1 ||
|
||||
checks[0].Address != "192.0.2.51" {
|
||||
t.Fatalf("new host projection = %+v, want edited endpoint", checks)
|
||||
}
|
||||
check := availabilityEndpointByTarget(t, rr, "probe-edit")
|
||||
if check.Status != StatusOnline || len(check.Incidents) != 0 {
|
||||
t.Fatalf("edited check retained failed state: status=%q incidents=%+v", check.Status, check.Incidents)
|
||||
}
|
||||
if check.Availability.Address != "192.0.2.51" || check.Availability.Protocol != "https" {
|
||||
t.Fatalf("edited check = %+v, want replacement endpoint", check.Availability)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAvailabilityRehydrateKeepsCheckIdentitySeparateFromProjection(t *testing.T) {
|
||||
rr := NewRegistry(nil)
|
||||
hostID := ingestAgentFixture(t, rr, "host-1", "machine-1")
|
||||
rr.IngestRecords(SourceAvailability, []IngestRecord{
|
||||
availabilityProbeRecord("probe-restart", "192.0.2.60", &AvailabilityData{
|
||||
LinkedResourceID: hostID,
|
||||
Address: "192.0.2.60",
|
||||
Protocol: "tcp",
|
||||
Enabled: true,
|
||||
Available: true,
|
||||
}),
|
||||
})
|
||||
checkBefore := availabilityEndpointByTarget(t, rr, "probe-restart")
|
||||
|
||||
restarted := NewRegistry(nil)
|
||||
restarted.IngestResources(rr.List())
|
||||
restarted.IngestRecords(SourceAvailability, []IngestRecord{
|
||||
availabilityProbeRecord("probe-restart", "192.0.2.60", &AvailabilityData{
|
||||
LinkedResourceID: hostID,
|
||||
Address: "192.0.2.60",
|
||||
Protocol: "tcp",
|
||||
Enabled: true,
|
||||
Available: true,
|
||||
}),
|
||||
})
|
||||
|
||||
checkAfter := availabilityEndpointByTarget(t, restarted, "probe-restart")
|
||||
if checkAfter.ID != checkBefore.ID {
|
||||
t.Fatalf("check ID changed across rehydrate: %q -> %q", checkBefore.ID, checkAfter.ID)
|
||||
}
|
||||
host, _ := restarted.Get(hostID)
|
||||
if checks := AvailabilityChecksForResource(*host); len(checks) != 1 ||
|
||||
checks[0].TargetID != "probe-restart" {
|
||||
t.Fatalf("host projection after rehydrate = %+v", checks)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAvailabilityManualIdentityLinkCannotEraseConfiguredCheck(t *testing.T) {
|
||||
initial := NewRegistry(nil)
|
||||
hostID := ingestAgentFixture(t, initial, "host-1", "machine-1")
|
||||
initial.IngestRecords(SourceAvailability, []IngestRecord{
|
||||
availabilityProbeRecord("probe-linked", "192.0.2.80", &AvailabilityData{
|
||||
LinkedResourceID: hostID,
|
||||
Address: "192.0.2.80",
|
||||
Protocol: "https",
|
||||
Enabled: true,
|
||||
Available: true,
|
||||
}),
|
||||
})
|
||||
checkID := availabilityEndpointByTarget(t, initial, "probe-linked").ID
|
||||
|
||||
store := NewMemoryStore()
|
||||
if err := store.AddLink(ResourceLink{
|
||||
ResourceA: checkID,
|
||||
ResourceB: hostID,
|
||||
PrimaryID: hostID,
|
||||
}); err != nil {
|
||||
t.Fatalf("AddLink(): %v", err)
|
||||
}
|
||||
rehydrated := NewRegistry(store)
|
||||
rehydrated.IngestResources(initial.List())
|
||||
|
||||
if _, ok := rehydrated.Get(checkID); !ok {
|
||||
t.Fatalf("manual link erased configured check %q", checkID)
|
||||
}
|
||||
if _, ok := rehydrated.Get(hostID); !ok {
|
||||
t.Fatalf("manual link erased monitored host %q", hostID)
|
||||
}
|
||||
if got := len(rehydrated.ListByType(ResourceTypeNetworkEndpoint)); got != 1 {
|
||||
t.Fatalf("availability check count = %d, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAvailabilityIdentityRemainsTenantLocal(t *testing.T) {
|
||||
buildTenant := func(machineID string) (*ResourceRegistry, string) {
|
||||
rr := NewRegistry(nil)
|
||||
hostID := ingestAgentFixture(t, rr, "shared-host", machineID)
|
||||
rr.IngestRecords(SourceAvailability, []IngestRecord{
|
||||
availabilityProbeRecord("shared-check", "192.0.2.90", &AvailabilityData{
|
||||
LinkedResourceID: hostID,
|
||||
Address: "192.0.2.90",
|
||||
Protocol: "tcp",
|
||||
Enabled: true,
|
||||
Available: true,
|
||||
}),
|
||||
})
|
||||
return rr, hostID
|
||||
}
|
||||
|
||||
tenantA, hostA := buildTenant("tenant-a-machine")
|
||||
tenantB, hostB := buildTenant("tenant-b-machine")
|
||||
checkA := availabilityEndpointByTarget(t, tenantA, "shared-check")
|
||||
checkB := availabilityEndpointByTarget(t, tenantB, "shared-check")
|
||||
if checkA.ID != checkB.ID {
|
||||
t.Fatalf("tenant-local source identity changed for same target: %q vs %q", checkA.ID, checkB.ID)
|
||||
}
|
||||
if hostA == hostB {
|
||||
t.Fatalf("tenant fixture hosts unexpectedly share canonical ID %q", hostA)
|
||||
}
|
||||
if checkA.Relationships[0].TargetID != hostA || checkB.Relationships[0].TargetID != hostB {
|
||||
t.Fatalf(
|
||||
"cross-tenant projection: tenant A=%+v tenant B=%+v",
|
||||
checkA.Relationships,
|
||||
checkB.Relationships,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -183,6 +183,10 @@ func resourceChangedFields(before, after Resource) []string {
|
|||
if !reflect.DeepEqual(before.Identity, after.Identity) {
|
||||
changed = append(changed, "identity")
|
||||
}
|
||||
if (isAvailabilityOwnedResource(before) || isAvailabilityOwnedResource(after)) &&
|
||||
availabilityConfigurationChanged(before.Availability, after.Availability) {
|
||||
changed = append(changed, "availability.configuration")
|
||||
}
|
||||
if resourceIncidentChanged(before, after) {
|
||||
changed = append(changed, "incidents")
|
||||
}
|
||||
|
|
@ -205,6 +209,45 @@ func resourceChangedFields(before, after Resource) []string {
|
|||
return changed
|
||||
}
|
||||
|
||||
func availabilityConfigurationChanged(before, after *AvailabilityData) bool {
|
||||
type availabilityConfiguration struct {
|
||||
TargetID string
|
||||
LinkedResourceID string
|
||||
Name string
|
||||
TargetKind string
|
||||
Address string
|
||||
Protocol string
|
||||
UDPMode string
|
||||
Port int
|
||||
Path string
|
||||
Enabled bool
|
||||
FailureThreshold int
|
||||
PollIntervalSeconds int
|
||||
TimeoutMillis int
|
||||
}
|
||||
project := func(data *AvailabilityData) availabilityConfiguration {
|
||||
if data == nil {
|
||||
return availabilityConfiguration{}
|
||||
}
|
||||
return availabilityConfiguration{
|
||||
TargetID: strings.TrimSpace(data.TargetID),
|
||||
LinkedResourceID: strings.TrimSpace(data.LinkedResourceID),
|
||||
Name: strings.TrimSpace(data.Name),
|
||||
TargetKind: strings.TrimSpace(data.TargetKind),
|
||||
Address: strings.TrimSpace(data.Address),
|
||||
Protocol: strings.TrimSpace(data.Protocol),
|
||||
UDPMode: strings.TrimSpace(data.UDPMode),
|
||||
Port: data.Port,
|
||||
Path: strings.TrimSpace(data.Path),
|
||||
Enabled: data.Enabled,
|
||||
FailureThreshold: data.FailureThreshold,
|
||||
PollIntervalSeconds: data.PollIntervalSeconds,
|
||||
TimeoutMillis: data.TimeoutMillis,
|
||||
}
|
||||
}
|
||||
return project(before) != project(after)
|
||||
}
|
||||
|
||||
// relationshipsEquivalent reports whether two relationship sets describe the
|
||||
// same edges. Registry rebuilds reconstruct every relationship with fresh
|
||||
// ObservedAt/LastSeenAt stamps and metadata maps, so comparing with
|
||||
|
|
|
|||
|
|
@ -317,6 +317,51 @@ func TestBuildResourceChange_IgnoresUpdateStatusLastCheckedOnly(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestBuildResourceChange_TracksAvailabilityEditsWithoutRecordingPollChurn(t *testing.T) {
|
||||
checkedAt := time.Date(2026, 7, 23, 20, 0, 0, 0, time.UTC)
|
||||
before := Resource{
|
||||
ID: "availability:stats-pv",
|
||||
Type: ResourceTypeNetworkEndpoint,
|
||||
Name: "stats_pv",
|
||||
Status: StatusOnline,
|
||||
Sources: []DataSource{SourceAvailability},
|
||||
Availability: &AvailabilityData{
|
||||
TargetID: "stats-pv",
|
||||
Address: "192.0.2.70",
|
||||
Protocol: "https",
|
||||
Port: 443,
|
||||
Enabled: true,
|
||||
Available: true,
|
||||
LastChecked: &checkedAt,
|
||||
LatencyMillis: 12,
|
||||
PollIntervalSeconds: 60,
|
||||
},
|
||||
}
|
||||
polled := before
|
||||
polledAvailability := *before.Availability
|
||||
nextCheckedAt := checkedAt.Add(time.Minute)
|
||||
polledAvailability.LastChecked = &nextCheckedAt
|
||||
polledAvailability.LatencyMillis = 18
|
||||
polled.Availability = &polledAvailability
|
||||
if change := buildResourceChange(before, true, polled, true, nextCheckedAt, nil, SourcePulseDiff, ""); change != nil {
|
||||
t.Fatalf("poll-only observation emitted history churn: %+v", change)
|
||||
}
|
||||
|
||||
edited := polled
|
||||
editedAvailability := polledAvailability
|
||||
editedAvailability.Address = "192.0.2.71"
|
||||
editedAvailability.Protocol = "http"
|
||||
editedAvailability.Port = 8080
|
||||
edited.Availability = &editedAvailability
|
||||
change := buildResourceChange(before, true, edited, true, nextCheckedAt, nil, SourcePulseDiff, "")
|
||||
if change == nil || change.Kind != ChangeConfigUpdate {
|
||||
t.Fatalf("availability edit change = %+v, want config update", change)
|
||||
}
|
||||
if !sameStringSet(mustChangedFields(t, change), []string{"availability.configuration"}) {
|
||||
t.Fatalf("changedFields = %+v, want availability.configuration", mustChangedFields(t, change))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildResourceChange_ClassifiesKubernetesRestartChange(t *testing.T) {
|
||||
before := Resource{
|
||||
ID: "pod:1",
|
||||
|
|
|
|||
|
|
@ -629,7 +629,8 @@ func TestAPIResourcesKeepsOwnedSupplementalGapFillAndVMwareAlias(t *testing.T) {
|
|||
|
||||
requiredSnippets := []string{
|
||||
"seedSources := unifiedSeedSources(seed.resources)",
|
||||
"!sourceOwnedBySupplementalProvider(source, ownedSources) || unifiedSeedIncludesSource(seedSources, source)",
|
||||
"!sourceOwnedBySupplementalProvider(source, ownedSources)",
|
||||
"(source != unified.SourceAvailability && unifiedSeedIncludesSource(seedSources, source))",
|
||||
`case "vmware", "vmware-vsphere":`,
|
||||
}
|
||||
for _, snippet := range requiredSnippets {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package unifiedresources
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
|
@ -150,7 +151,21 @@ func (a *MonitorAdapter) replaceRegistry(snapshot models.StateSnapshot, recordsB
|
|||
rebuilt := NewRegistry(registry.store)
|
||||
staleThresholds := a.currentStaleThresholds()
|
||||
rebuilt.IngestSnapshotWithStaleThresholds(snapshot, staleThresholds)
|
||||
for source, records := range recordsBySource {
|
||||
sources := make([]DataSource, 0, len(recordsBySource))
|
||||
for source := range recordsBySource {
|
||||
sources = append(sources, source)
|
||||
}
|
||||
sort.Slice(sources, func(i, j int) bool {
|
||||
if sources[i] == SourceAvailability {
|
||||
return false
|
||||
}
|
||||
if sources[j] == SourceAvailability {
|
||||
return true
|
||||
}
|
||||
return string(sources[i]) < string(sources[j])
|
||||
})
|
||||
for _, source := range sources {
|
||||
records := recordsBySource[source]
|
||||
if len(records) == 0 || strings.TrimSpace(string(source)) == "" {
|
||||
continue
|
||||
}
|
||||
|
|
|
|||
|
|
@ -414,6 +414,117 @@ func TestMonitorAdapterRecordsSupplementalChangeTimeline(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestMonitorAdapterAvailabilityChecksSurviveRebuildAndRecordDeletionByCheckID(t *testing.T) {
|
||||
store := NewMemoryStore()
|
||||
adapter := NewMonitorAdapter(NewRegistry(store))
|
||||
now := time.Date(2026, 7, 23, 20, 0, 0, 0, time.UTC)
|
||||
hostID := MachineIdentityCanonicalID(ResourceTypeAgent, "machine-core2026")
|
||||
snapshot := models.StateSnapshot{
|
||||
Hosts: []models.Host{{
|
||||
ID: "agent-core2026",
|
||||
Hostname: "core2026",
|
||||
MachineID: "machine-core2026",
|
||||
Status: "online",
|
||||
LastSeen: now,
|
||||
}},
|
||||
LastUpdate: now,
|
||||
}
|
||||
records := map[DataSource][]IngestRecord{
|
||||
SourceAvailability: {
|
||||
availabilityProbeRecord("stats-pv", "192.0.2.70", &AvailabilityData{
|
||||
LinkedResourceID: hostID,
|
||||
Address: "192.0.2.70",
|
||||
Protocol: "https",
|
||||
Enabled: true,
|
||||
Available: true,
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
adapter.PopulateSnapshotAndSupplemental(snapshot, records)
|
||||
checks := adapter.currentRegistry().ListByType(ResourceTypeNetworkEndpoint)
|
||||
if len(checks) != 1 {
|
||||
t.Fatalf("check count after rebuild = %d, want 1", len(checks))
|
||||
}
|
||||
checkID := checks[0].ID
|
||||
host, ok := adapter.currentRegistry().Get(hostID)
|
||||
if !ok || len(AvailabilityChecksForResource(*host)) != 1 {
|
||||
t.Fatalf("host projection after rebuild = %+v", host)
|
||||
}
|
||||
|
||||
// A reload/restart rebuild must preserve the canonical check row instead
|
||||
// of seeding the provider mapping from the host projection.
|
||||
snapshot.LastUpdate = now.Add(time.Minute)
|
||||
adapter.PopulateSnapshotAndSupplemental(snapshot, records)
|
||||
checks = adapter.currentRegistry().ListByType(ResourceTypeNetworkEndpoint)
|
||||
if len(checks) != 1 || checks[0].ID != checkID {
|
||||
t.Fatalf("check after restart = %+v, want stable ID %q", checks, checkID)
|
||||
}
|
||||
|
||||
// Deleting the configured check means the next atomic replacement carries
|
||||
// no availability records. Both the row and its host projection disappear.
|
||||
snapshot.LastUpdate = now.Add(2 * time.Minute)
|
||||
adapter.PopulateSnapshotAndSupplemental(snapshot, nil)
|
||||
if got := adapter.currentRegistry().ListByType(ResourceTypeNetworkEndpoint); len(got) != 0 {
|
||||
t.Fatalf("check count after deletion = %d, want 0", len(got))
|
||||
}
|
||||
host, ok = adapter.currentRegistry().Get(hostID)
|
||||
if !ok || len(AvailabilityChecksForResource(*host)) != 0 {
|
||||
t.Fatalf("host retained deleted projection: %+v", host)
|
||||
}
|
||||
|
||||
changes, err := store.GetRecentChanges(checkID, time.Time{}, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("GetRecentChanges(%q): %v", checkID, err)
|
||||
}
|
||||
changeTypes := map[string]bool{}
|
||||
for _, change := range changes {
|
||||
if change.Metadata != nil {
|
||||
if changeType, _ := change.Metadata["changeType"].(string); changeType != "" {
|
||||
changeTypes[changeType] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if !changeTypes["resource_created"] || !changeTypes["resource_removed"] {
|
||||
t.Fatalf("check history change types = %+v, want create and remove", changeTypes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMonitorAdapterIngestsAvailabilityAfterCorrelatableSupplementalSources(t *testing.T) {
|
||||
adapter := NewMonitorAdapter(NewRegistry(nil))
|
||||
now := time.Date(2026, 7, 23, 21, 0, 0, 0, time.UTC)
|
||||
host := models.Host{
|
||||
ID: "agent-supplemental",
|
||||
Hostname: "supplemental-host",
|
||||
MachineID: "supplemental-machine",
|
||||
Status: "online",
|
||||
LastSeen: now,
|
||||
}
|
||||
hostID := MachineIdentityCanonicalID(ResourceTypeAgent, host.MachineID)
|
||||
|
||||
adapter.PopulateSnapshotAndSupplemental(models.StateSnapshot{LastUpdate: now}, map[DataSource][]IngestRecord{
|
||||
SourceAvailability: {
|
||||
availabilityProbeRecord("supplemental-check", "192.0.2.75", &AvailabilityData{
|
||||
LinkedResourceID: hostID,
|
||||
Address: "192.0.2.75",
|
||||
Protocol: "tcp",
|
||||
Enabled: true,
|
||||
Available: true,
|
||||
}),
|
||||
},
|
||||
SourceAgent: {HostIngestRecord(host)},
|
||||
})
|
||||
|
||||
check := availabilityEndpointByTarget(t, adapter.currentRegistry(), "supplemental-check")
|
||||
if len(check.Relationships) != 1 || check.Relationships[0].TargetID != hostID {
|
||||
t.Fatalf("availability was ingested before its supplemental target: %+v", check.Relationships)
|
||||
}
|
||||
projectedHost, ok := adapter.currentRegistry().Get(hostID)
|
||||
if !ok || len(AvailabilityChecksForResource(*projectedHost)) != 1 {
|
||||
t.Fatalf("supplemental host projection = %+v", projectedHost)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMonitorAdapterRecordChangeForwardsToStore(t *testing.T) {
|
||||
store := NewMemoryStore()
|
||||
adapter := NewMonitorAdapter(NewRegistry(store))
|
||||
|
|
|
|||
|
|
@ -705,6 +705,12 @@ func (rr *ResourceRegistry) seedSourceMappingsFromResourceLocked(resource *Resou
|
|||
|
||||
for _, source := range sources {
|
||||
if source == SourceAvailability {
|
||||
// Availability facets projected onto a monitored resource do not
|
||||
// own the provider identity. Only the source-owned endpoint may
|
||||
// restore targetID -> resourceID mappings during rehydration.
|
||||
if !isAvailabilityOwnedResource(*resource) {
|
||||
continue
|
||||
}
|
||||
if _, ok := rr.bySource[source]; !ok {
|
||||
rr.bySource[source] = make(map[string]string)
|
||||
}
|
||||
|
|
@ -2420,6 +2426,13 @@ func (rr *ResourceRegistry) ingest(source DataSource, sourceID string, resource
|
|||
resource.parentBySource = make(map[DataSource]string)
|
||||
rr.setSourceParent(&resource, source, resource.ParentID)
|
||||
|
||||
// An availability target is always a first-class, source-owned resource.
|
||||
// Correlation may additionally project its facet onto a monitored machine
|
||||
// or service, but it must never replace the configured check row.
|
||||
if source == SourceAvailability && resource.Type == ResourceTypeNetworkEndpoint {
|
||||
return rr.ingestAvailabilityCheckLocked(sourceID, resource, identity)
|
||||
}
|
||||
|
||||
// Rehydrated registries seed exact source mappings from the persisted
|
||||
// unified snapshot. Honor that durable mapping before attempting weaker
|
||||
// identity correlation, but fail closed if a colliding source key belongs
|
||||
|
|
@ -2446,33 +2459,6 @@ func (rr *ResourceRegistry) ingest(source DataSource, sourceID string, resource
|
|||
|
||||
candidateID := rr.sourceSpecificID(resource.Type, source, sourceID)
|
||||
|
||||
// Agentless availability probes attach as a facet on the known resource
|
||||
// they monitor instead of minting a parallel network-endpoint. An explicit
|
||||
// linkedResourceId wins; otherwise an exact, unique normalized IP or full
|
||||
// hostname may attach. Ambiguous or invalid correlations remain explicit
|
||||
// on the fallback endpoint and are never guessed.
|
||||
var availabilityResolution availabilityLinkResolution
|
||||
if source == SourceAvailability && resource.Type == ResourceTypeNetworkEndpoint {
|
||||
availabilityResolution = rr.resolveAvailabilityLink(resource)
|
||||
operationaltrust.GetMetrics().ObserveIdentityCorrelation(string(availabilityResolution.State))
|
||||
applyAvailabilityResolution(resource.Availability, availabilityResolution)
|
||||
if linked := availabilityResolution.ResourceID; linked != "" {
|
||||
if existing := rr.resources[linked]; existing != nil {
|
||||
bindAvailabilityEvidence(resource.Availability, existing.ID, availabilityResolution)
|
||||
existing.Relationships = upsertAvailabilityCheckRelationship(
|
||||
existing.Relationships,
|
||||
candidateID,
|
||||
existing.ID,
|
||||
resource.Availability,
|
||||
availabilityResolution,
|
||||
)
|
||||
rr.mergeInto(existing, resource, source)
|
||||
rr.bySource[source][sourceID] = existing.ID
|
||||
return existing.ID
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if resource.Type == ResourceTypeAgent || resource.Type == ResourceTypePhysicalDisk {
|
||||
if match, excluded := rr.findMatch(resource, candidateID); match != nil {
|
||||
existing := rr.resources[match.ResourceB]
|
||||
|
|
@ -2491,10 +2477,6 @@ func (rr *ResourceRegistry) ingest(source DataSource, sourceID string, resource
|
|||
}
|
||||
|
||||
resource.ID = rr.chooseNewID(resource.Type, identity, source, sourceID)
|
||||
if source == SourceAvailability && resource.Availability != nil {
|
||||
bindAvailabilityEvidence(resource.Availability, resource.ID, availabilityResolution)
|
||||
normalizeResourceAvailability(&resource)
|
||||
}
|
||||
normalizeResourceRelationships(&resource)
|
||||
if existing := rr.resources[resource.ID]; existing != nil {
|
||||
rr.mergeInto(existing, resource, source)
|
||||
|
|
@ -2509,6 +2491,149 @@ func (rr *ResourceRegistry) ingest(source DataSource, sourceID string, resource
|
|||
return resource.ID
|
||||
}
|
||||
|
||||
func (rr *ResourceRegistry) ingestAvailabilityCheckLocked(
|
||||
sourceID string,
|
||||
resource Resource,
|
||||
identity ResourceIdentity,
|
||||
) string {
|
||||
checkResourceID := rr.sourceSpecificID(resource.Type, SourceAvailability, sourceID)
|
||||
if mappedID := CanonicalResourceID(rr.bySource[SourceAvailability][sourceID]); mappedID != "" {
|
||||
if existing := rr.resources[mappedID]; existing != nil &&
|
||||
existing.Type == ResourceTypeNetworkEndpoint &&
|
||||
isAvailabilityOwnedResource(*existing) {
|
||||
checkResourceID = mappedID
|
||||
}
|
||||
}
|
||||
|
||||
// Incremental edits can change an explicit link or endpoint identity.
|
||||
// Remove only this check's prior projection before resolving its new
|
||||
// target; other checks attached to the same resource remain intact.
|
||||
rr.removeAvailabilityProjectionLocked(sourceID, checkResourceID)
|
||||
|
||||
resolution := rr.resolveAvailabilityLink(resource)
|
||||
operationaltrust.GetMetrics().ObserveIdentityCorrelation(string(resolution.State))
|
||||
applyAvailabilityResolution(resource.Availability, resolution)
|
||||
|
||||
resource.ID = checkResourceID
|
||||
resource.Identity = identity
|
||||
bindAvailabilityEvidence(resource.Availability, checkResourceID, resolution)
|
||||
normalizeResourceAvailability(&resource)
|
||||
|
||||
if targetResourceID := resolution.ResourceID; targetResourceID != "" {
|
||||
if target := rr.resources[targetResourceID]; target != nil {
|
||||
resource.Relationships = upsertAvailabilityCheckRelationship(
|
||||
resource.Relationships,
|
||||
checkResourceID,
|
||||
targetResourceID,
|
||||
resource.Availability,
|
||||
resolution,
|
||||
)
|
||||
rr.projectAvailabilityCheckLocked(target, resource, resolution)
|
||||
}
|
||||
}
|
||||
normalizeResourceRelationships(&resource)
|
||||
|
||||
// Availability records are authoritative snapshots for one configured
|
||||
// target. Replacing the source-owned row clears recovered incidents and
|
||||
// removes stale endpoint identity after edits.
|
||||
rr.resources[checkResourceID] = &resource
|
||||
rr.bySource[SourceAvailability][sourceID] = checkResourceID
|
||||
rr.matcher.Add(checkResourceID, identity)
|
||||
return checkResourceID
|
||||
}
|
||||
|
||||
func (rr *ResourceRegistry) projectAvailabilityCheckLocked(
|
||||
target *Resource,
|
||||
checkResource Resource,
|
||||
resolution availabilityLinkResolution,
|
||||
) {
|
||||
if target == nil || checkResource.Availability == nil {
|
||||
return
|
||||
}
|
||||
|
||||
projected := cloneAvailabilityData(checkResource.Availability)
|
||||
if projected == nil {
|
||||
return
|
||||
}
|
||||
bindAvailabilityEvidence(projected, target.ID, resolution)
|
||||
target.AvailabilityChecks = mergeAvailabilityChecks(
|
||||
target.AvailabilityChecks,
|
||||
target.Availability,
|
||||
nil,
|
||||
projected,
|
||||
)
|
||||
target.Availability = primaryAvailabilityCheck(target.AvailabilityChecks)
|
||||
target.Sources = addSource(target.Sources, SourceAvailability)
|
||||
if target.SourceStatus == nil {
|
||||
target.SourceStatus = make(map[DataSource]SourceStatus)
|
||||
}
|
||||
target.SourceStatus[SourceAvailability] = SourceStatus{
|
||||
Status: sourceSightingStatus(checkResource.LastSeen),
|
||||
LastSeen: checkResource.LastSeen,
|
||||
}
|
||||
}
|
||||
|
||||
func (rr *ResourceRegistry) removeAvailabilityProjectionLocked(
|
||||
targetID string,
|
||||
checkResourceID string,
|
||||
) {
|
||||
targetID = strings.TrimSpace(targetID)
|
||||
checkResourceID = CanonicalResourceID(checkResourceID)
|
||||
if targetID == "" {
|
||||
return
|
||||
}
|
||||
|
||||
for _, resource := range rr.resources {
|
||||
if resource == nil || isAvailabilityOwnedResource(*resource) {
|
||||
continue
|
||||
}
|
||||
|
||||
checks := AvailabilityChecksForResource(*resource)
|
||||
filteredChecks := make([]AvailabilityData, 0, len(checks))
|
||||
removed := false
|
||||
for _, check := range checks {
|
||||
if strings.TrimSpace(check.TargetID) == targetID {
|
||||
removed = true
|
||||
continue
|
||||
}
|
||||
filteredChecks = append(filteredChecks, check)
|
||||
}
|
||||
if removed {
|
||||
resource.AvailabilityChecks = filteredChecks
|
||||
resource.Availability = primaryAvailabilityCheck(filteredChecks)
|
||||
if len(filteredChecks) == 0 {
|
||||
resource.Sources = removeDataSource(resource.Sources, SourceAvailability)
|
||||
delete(resource.SourceStatus, SourceAvailability)
|
||||
}
|
||||
}
|
||||
|
||||
filteredRelationships := resource.Relationships[:0]
|
||||
for _, relationship := range resource.Relationships {
|
||||
relationshipTargetID, _ := relationship.Metadata["targetId"].(string)
|
||||
if relationship.Type == RelChecks &&
|
||||
(CanonicalResourceID(relationship.SourceID) == checkResourceID ||
|
||||
strings.TrimSpace(relationshipTargetID) == targetID) {
|
||||
continue
|
||||
}
|
||||
filteredRelationships = append(filteredRelationships, relationship)
|
||||
}
|
||||
resource.Relationships = filteredRelationships
|
||||
|
||||
// Builds before the source-owned check contract merged availability
|
||||
// incidents into the correlated host. Remove that legacy copy while
|
||||
// retaining incidents from all other checks and providers.
|
||||
filteredIncidents := resource.Incidents[:0]
|
||||
for _, incident := range resource.Incidents {
|
||||
if strings.EqualFold(strings.TrimSpace(incident.Provider), string(SourceAvailability)) &&
|
||||
strings.TrimSpace(incident.NativeID) == targetID {
|
||||
continue
|
||||
}
|
||||
filteredIncidents = append(filteredIncidents, incident)
|
||||
}
|
||||
resource.Incidents = filteredIncidents
|
||||
}
|
||||
}
|
||||
|
||||
func (rr *ResourceRegistry) mergeLinkedKubernetesNode(
|
||||
sourceID string,
|
||||
resource Resource,
|
||||
|
|
@ -3705,6 +3830,12 @@ func (rr *ResourceRegistry) applyManualLinks(thresholds map[DataSource]time.Dura
|
|||
if other == nil || otherID == primaryID {
|
||||
continue
|
||||
}
|
||||
// Availability checks retain their source-owned identity even when an
|
||||
// operator links them to another resource. Correlation is represented
|
||||
// by RelChecks plus the additive facet projection.
|
||||
if isAvailabilityOwnedResource(*primary) || isAvailabilityOwnedResource(*other) {
|
||||
continue
|
||||
}
|
||||
|
||||
// A manual link records operator intent to unify identities, but its
|
||||
// direction must not change the semantic resource shape. In particular,
|
||||
|
|
@ -4733,6 +4864,16 @@ func addSources(sources []DataSource, more []DataSource) []DataSource {
|
|||
return out
|
||||
}
|
||||
|
||||
func removeDataSource(sources []DataSource, source DataSource) []DataSource {
|
||||
out := make([]DataSource, 0, len(sources))
|
||||
for _, existing := range sources {
|
||||
if existing != source {
|
||||
out = append(out, existing)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func mergeMetrics(
|
||||
target *Resource,
|
||||
existing *ResourceMetrics,
|
||||
|
|
|
|||
|
|
@ -130,8 +130,8 @@ func TestResourceRegistryAvailabilityLinkedResourceResolvesSourceReference(t *te
|
|||
}),
|
||||
})
|
||||
|
||||
if got := rr.ListByType(ResourceTypeNetworkEndpoint); len(got) != 0 {
|
||||
t.Fatalf("expected source-referenced probe to attach (0 endpoints), got %d", len(got))
|
||||
if got := rr.ListByType(ResourceTypeNetworkEndpoint); len(got) != 1 {
|
||||
t.Fatalf("expected source-referenced probe to retain its endpoint row, got %d", len(got))
|
||||
}
|
||||
services := rr.ListByType(ResourceTypeDockerService)
|
||||
if len(services) != 1 {
|
||||
|
|
@ -5200,8 +5200,8 @@ func TestRegistryAvailabilityLinkAttachesFacetToKnownResource(t *testing.T) {
|
|||
Identity: ResourceIdentity{IPAddresses: []string{"192.0.2.10"}},
|
||||
}})
|
||||
|
||||
if got := rr.ListByType(ResourceTypeNetworkEndpoint); len(got) != 0 {
|
||||
t.Fatalf("expected 0 standalone network endpoints, got %d", len(got))
|
||||
if got := rr.ListByType(ResourceTypeNetworkEndpoint); len(got) != 1 {
|
||||
t.Fatalf("expected attached availability check to retain its endpoint row, got %d", len(got))
|
||||
}
|
||||
host, ok := rr.Get(hostID)
|
||||
if !ok || host.Availability == nil || host.Availability.TargetID != "probe-1" {
|
||||
|
|
|
|||
|
|
@ -40,10 +40,12 @@ const DOCKER_HOST_ID = "docker-host:operational-trust";
|
|||
const DOCKER_HOST_NAME = "Operational Trust Docker Host";
|
||||
const ATTACHED_TCP_TARGET_ID = "ops-api";
|
||||
const ATTACHED_HTTPS_TARGET_ID = "ops-web";
|
||||
const ATTACHED_TCP_RESOURCE_ID = "network-endpoint:ops-api";
|
||||
const ATTACHED_HTTPS_RESOURCE_ID = "network-endpoint:ops-web";
|
||||
const CANONICAL_AVAILABILITY_SPEC_ID =
|
||||
"alertspec:provider-incident:22f0f1f19599cd71";
|
||||
const AVAILABILITY_EVIDENCE_ID = "evidence_2825048c9470f82ba5490f8f8496813a";
|
||||
const ATTENTION_ID = `${DOCKER_HOST_ID}::${CANONICAL_AVAILABILITY_SPEC_ID}`;
|
||||
const ATTENTION_ID = `${ATTACHED_TCP_RESOURCE_ID}::${CANONICAL_AVAILABILITY_SPEC_ID}`;
|
||||
|
||||
type RouteResource = Record<string, unknown>;
|
||||
|
||||
|
|
@ -85,6 +87,7 @@ const evidenceEnvelope = ({
|
|||
validUntil,
|
||||
completeness = "complete",
|
||||
confidence = "confirmed",
|
||||
matchedResourceId = resourceId,
|
||||
}: {
|
||||
id: string;
|
||||
resourceId: string;
|
||||
|
|
@ -93,6 +96,7 @@ const evidenceEnvelope = ({
|
|||
validUntil?: string;
|
||||
completeness?: "complete" | "partial" | "unavailable";
|
||||
confidence?: "confirmed" | "inferred" | "unknown";
|
||||
matchedResourceId?: string;
|
||||
}) => ({
|
||||
id,
|
||||
source: {
|
||||
|
|
@ -101,8 +105,6 @@ const evidenceEnvelope = ({
|
|||
},
|
||||
subject: {
|
||||
resourceId,
|
||||
providerRef: targetId,
|
||||
providerScope: "availability-target",
|
||||
},
|
||||
observedAt,
|
||||
ingestedAt: observedAt,
|
||||
|
|
@ -115,9 +117,9 @@ const evidenceEnvelope = ({
|
|||
id: targetId,
|
||||
},
|
||||
correlation: {
|
||||
rule: "explicit-linked-resource",
|
||||
rule: "explicit_resource_link",
|
||||
matchedFields: {
|
||||
linkedResourceId: resourceId,
|
||||
linkedResourceId: matchedResourceId,
|
||||
},
|
||||
candidateCount: 1,
|
||||
},
|
||||
|
|
@ -160,7 +162,7 @@ const availabilityCheck = ({
|
|||
pollIntervalSeconds: 60,
|
||||
timeoutMillis: 5_000,
|
||||
correlationState: "attached",
|
||||
correlationRule: "explicit-linked-resource",
|
||||
correlationRule: "explicit_resource_link",
|
||||
correlationCandidates: 1,
|
||||
evidence: evidenceEnvelope({
|
||||
id: `evidence_${targetId}`,
|
||||
|
|
@ -171,6 +173,34 @@ const availabilityCheck = ({
|
|||
}),
|
||||
});
|
||||
|
||||
const attachedCheckResource = ({
|
||||
id,
|
||||
name,
|
||||
check,
|
||||
}: {
|
||||
id: string;
|
||||
name: string;
|
||||
check: ReturnType<typeof availabilityCheck>;
|
||||
}) => ({
|
||||
id,
|
||||
type: "network-endpoint",
|
||||
name,
|
||||
status: check.available ? "online" : "offline",
|
||||
lastSeen: check.lastChecked,
|
||||
sources: ["availability"],
|
||||
availability: {
|
||||
...check,
|
||||
evidence: evidenceEnvelope({
|
||||
id: `evidence_${check.targetId}_resource`,
|
||||
resourceId: id,
|
||||
targetId: check.targetId,
|
||||
observedAt: check.lastChecked,
|
||||
validUntil: check.evidence.validUntil,
|
||||
matchedResourceId: DOCKER_HOST_ID,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
test.describe("Operational trust availability resource facet", () => {
|
||||
test.setTimeout(180_000);
|
||||
|
||||
|
|
@ -265,7 +295,7 @@ test.describe("Operational trust availability resource facet", () => {
|
|||
await expect(httpsCard).toContainText("Checked");
|
||||
});
|
||||
|
||||
test("keeps attached checks out of standalone inventory and does not infer health from stale or missing observations", async ({
|
||||
test("keeps every attached check in inventory and does not infer health from stale or missing observations", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.skip(
|
||||
|
|
@ -278,6 +308,24 @@ test.describe("Operational trust availability resource facet", () => {
|
|||
const staleValidUntil = new Date(now - 10 * 60_000).toISOString();
|
||||
const freshObservedAt = new Date(now - 30_000).toISOString();
|
||||
const freshValidUntil = new Date(now + 2 * 60_000).toISOString();
|
||||
const attachedTCP = availabilityCheck({
|
||||
targetId: ATTACHED_TCP_TARGET_ID,
|
||||
address: "192.0.2.18",
|
||||
protocol: "tcp",
|
||||
port: 8007,
|
||||
observedAt: freshObservedAt,
|
||||
validUntil: freshValidUntil,
|
||||
latencyMillis: 12,
|
||||
});
|
||||
const attachedHTTPS = availabilityCheck({
|
||||
targetId: ATTACHED_HTTPS_TARGET_ID,
|
||||
address: "ops.example.test",
|
||||
protocol: "https",
|
||||
path: "/health",
|
||||
observedAt: freshObservedAt,
|
||||
validUntil: freshValidUntil,
|
||||
latencyMillis: 23,
|
||||
});
|
||||
|
||||
await routeResources(page, [
|
||||
{
|
||||
|
|
@ -287,27 +335,19 @@ test.describe("Operational trust availability resource facet", () => {
|
|||
status: "online",
|
||||
lastSeen: freshObservedAt,
|
||||
sources: ["docker", "availability"],
|
||||
availability: {
|
||||
targetId: ATTACHED_TCP_TARGET_ID,
|
||||
linkedResourceId: DOCKER_HOST_ID,
|
||||
address: "192.0.2.18",
|
||||
protocol: "tcp",
|
||||
port: 8007,
|
||||
enabled: true,
|
||||
available: true,
|
||||
lastChecked: freshObservedAt,
|
||||
latencyMillis: 12,
|
||||
pollIntervalSeconds: 60,
|
||||
correlationState: "attached",
|
||||
evidence: evidenceEnvelope({
|
||||
id: "evidence_attached_tcp",
|
||||
resourceId: DOCKER_HOST_ID,
|
||||
targetId: ATTACHED_TCP_TARGET_ID,
|
||||
observedAt: freshObservedAt,
|
||||
validUntil: freshValidUntil,
|
||||
}),
|
||||
},
|
||||
availability: attachedTCP,
|
||||
availabilityChecks: [attachedTCP, attachedHTTPS],
|
||||
},
|
||||
attachedCheckResource({
|
||||
id: ATTACHED_TCP_RESOURCE_ID,
|
||||
name: "Operations API",
|
||||
check: attachedTCP,
|
||||
}),
|
||||
attachedCheckResource({
|
||||
id: ATTACHED_HTTPS_RESOURCE_ID,
|
||||
name: "Operations Web",
|
||||
check: attachedHTTPS,
|
||||
}),
|
||||
{
|
||||
id: "network-endpoint:standalone-switch",
|
||||
type: "network-endpoint",
|
||||
|
|
@ -397,6 +437,18 @@ test.describe("Operational trust availability resource facet", () => {
|
|||
const standalonePage = page.getByTestId("standalone-page");
|
||||
await expect(standalonePage).toBeVisible({ timeout: 30_000 });
|
||||
await expect(standalonePage.getByText(DOCKER_HOST_NAME)).toHaveCount(0);
|
||||
await expect(standalonePage.getByText("Operations API")).toBeVisible();
|
||||
await expect(standalonePage.getByText("Operations Web")).toBeVisible();
|
||||
await expect(
|
||||
standalonePage.locator(
|
||||
`[data-availability-check-row="${ATTACHED_TCP_RESOURCE_ID}"]`,
|
||||
),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
standalonePage.locator(
|
||||
`[data-availability-check-row="${ATTACHED_HTTPS_RESOURCE_ID}"]`,
|
||||
),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
standalonePage.getByText("Standalone lab switch"),
|
||||
).toBeVisible();
|
||||
|
|
@ -422,9 +474,9 @@ test.describe("Operational trust availability resource facet", () => {
|
|||
await expect(unobservedRow).not.toContainText("Healthy");
|
||||
|
||||
const posture = standalonePage.getByTestId("standalone-posture-summary");
|
||||
await expect(posture).toContainText("1 healthy");
|
||||
await expect(posture).toContainText("3 healthy");
|
||||
await expect(posture).toContainText("2 need attention");
|
||||
await expect(posture).not.toContainText("All 3 checks reporting normally");
|
||||
await expect(posture).not.toContainText("All 5 checks reporting normally");
|
||||
});
|
||||
|
||||
test("routes an attached availability failure into Patrol with canonical lifecycle evidence", async ({
|
||||
|
|
@ -441,10 +493,10 @@ test.describe("Operational trust availability resource facet", () => {
|
|||
const item = {
|
||||
id: ATTENTION_ID,
|
||||
operationalRecordId: ATTENTION_ID,
|
||||
subjectResourceId: DOCKER_HOST_ID,
|
||||
subjectResourceName: DOCKER_HOST_NAME,
|
||||
subjectResourceType: "docker-host",
|
||||
title: `Availability check failed for ${DOCKER_HOST_NAME}`,
|
||||
subjectResourceId: ATTACHED_TCP_RESOURCE_ID,
|
||||
subjectResourceName: "Operations API",
|
||||
subjectResourceType: "network-endpoint",
|
||||
title: "Availability check failed for Operations API",
|
||||
plainLanguageSummary:
|
||||
"The attached TCP availability check failed twice and reached its alert threshold.",
|
||||
severity: "critical",
|
||||
|
|
@ -479,9 +531,7 @@ test.describe("Operational trust availability resource facet", () => {
|
|||
collector: "availability-poller",
|
||||
},
|
||||
subject: {
|
||||
resourceId: DOCKER_HOST_ID,
|
||||
providerRef: ATTACHED_TCP_TARGET_ID,
|
||||
providerScope: "availability-target",
|
||||
resourceId: ATTACHED_TCP_RESOURCE_ID,
|
||||
},
|
||||
observedAt,
|
||||
ingestedAt,
|
||||
|
|
@ -503,7 +553,7 @@ test.describe("Operational trust availability resource facet", () => {
|
|||
operationalRecord: {
|
||||
id: ATTENTION_ID,
|
||||
canonicalSpecId: CANONICAL_AVAILABILITY_SPEC_ID,
|
||||
subjectResourceId: DOCKER_HOST_ID,
|
||||
subjectResourceId: ATTACHED_TCP_RESOURCE_ID,
|
||||
state: "open",
|
||||
severity: "critical",
|
||||
firstObservedAt: observedAt,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue