Expand Docker and Kubernetes platform projections

This commit is contained in:
rcourtman
2026-05-24 08:58:02 +01:00
parent d3934e19e3
commit 6346929328
63 changed files with 5858 additions and 258 deletions
@@ -82,13 +82,17 @@
"canonical_projections": [
"agent",
"app-container",
"docker-service"
"docker-image",
"docker-volume",
"docker-network",
"docker-service",
"docker-task"
],
"support_floor": {
"setup": "supported",
"visibility": "supported",
"workloads": "supported",
"storage": "n/a",
"storage": "supported",
"recovery": "n/a",
"alerts": "supported",
"assistant_read": "supported",
@@ -118,13 +122,23 @@
"k8s-cluster",
"k8s-node",
"pod",
"k8s-deployment"
"k8s-deployment",
"k8s-statefulset",
"k8s-daemonset",
"k8s-service",
"k8s-job",
"k8s-cronjob",
"k8s-ingress",
"k8s-persistent-volume",
"k8s-persistent-volume-claim",
"k8s-namespace",
"k8s-event"
],
"support_floor": {
"setup": "supported",
"visibility": "supported",
"workloads": "supported",
"storage": "n/a",
"storage": "supported",
"recovery": "supported",
"alerts": "supported",
"assistant_read": "supported",
@@ -1,6 +1,6 @@
# Pulse v6 Platform Support Model
Last updated: 2026-05-20
Last updated: 2026-05-24
Status: ACTIVE
This file is the canonical governed model for platform support in Pulse v6.
@@ -206,9 +206,13 @@ physical host.
3. Proxmox guest workloads project as `vm` and `system-container`.
4. OCI and application workloads project as `app-container`, including
TrueNAS-managed apps.
5. Docker Swarm service topology projects as `docker-service`.
6. Kubernetes projects as `k8s-cluster`, `k8s-node`, `pod`, and
`k8s-deployment`.
5. Docker / Podman runtime inventory projects as `app-container`,
`docker-image`, `docker-volume`, `docker-network`, `docker-service`, and
`docker-task` under the `docker` runtime lens.
6. Kubernetes projects as `k8s-cluster`, `k8s-node`, `pod`,
`k8s-deployment`, `k8s-statefulset`, `k8s-daemonset`, `k8s-service`,
`k8s-job`, `k8s-cronjob`, `k8s-ingress`, `k8s-persistent-volume`,
`k8s-persistent-volume-claim`, `k8s-namespace`, and `k8s-event`.
7. Storage projects through shared `storage`, `ceph`, and `physical-disk`
resources instead of provider-local storage types.
8. Recovery artifacts stay in `internal/recovery` and reference canonical
@@ -317,8 +321,8 @@ Support floor fields are recorded in this order: `setup`, `visibility`,
| Platform | Surface kind | Governance | Readiness | Primary mode | Onboarding | Canonical projections | Support floor |
| --- | --- | --- | --- | --- | --- | --- | --- |
| `agent` | `platform` | `supported` | `supported` | `agent-backed` | `install-workspace` | `agent`, `storage`, `physical-disk` | `setup=supported`; `visibility=supported`; `workloads=n/a`; `storage=supported`; `recovery=n/a`; `alerts=supported`; `assistant_read=supported`; `assistant_control=supported` |
| `docker` | `runtime-lens` | `supported` | `supported` | `agent-backed` | `install-workspace` | `agent`, `app-container`, `docker-service` | `setup=supported`; `visibility=supported`; `workloads=supported`; `storage=n/a`; `recovery=n/a`; `alerts=supported`; `assistant_read=supported`; `assistant_control=supported` |
| `kubernetes` | `platform` | `supported` | `supported` | `agent-backed` | `install-workspace` | `k8s-cluster`, `k8s-node`, `pod`, `k8s-deployment` | `setup=supported`; `visibility=supported`; `workloads=supported`; `storage=n/a`; `recovery=supported`; `alerts=supported`; `assistant_read=supported`; `assistant_control=supported` |
| `docker` | `runtime-lens` | `supported` | `supported` | `agent-backed` | `install-workspace` | `agent`, `app-container`, `docker-image`, `docker-volume`, `docker-network`, `docker-service`, `docker-task` | `setup=supported`; `visibility=supported`; `workloads=supported`; `storage=supported`; `recovery=n/a`; `alerts=supported`; `assistant_read=supported`; `assistant_control=supported` |
| `kubernetes` | `platform` | `supported` | `supported` | `agent-backed` | `install-workspace` | `k8s-cluster`, `k8s-node`, `pod`, `k8s-deployment`, `k8s-statefulset`, `k8s-daemonset`, `k8s-service`, `k8s-job`, `k8s-cronjob`, `k8s-ingress`, `k8s-persistent-volume`, `k8s-persistent-volume-claim`, `k8s-namespace`, `k8s-event` | `setup=supported`; `visibility=supported`; `workloads=supported`; `storage=supported`; `recovery=supported`; `alerts=supported`; `assistant_read=supported`; `assistant_control=supported` |
| `proxmox-pve` | `platform` | `supported` | `supported` | `api-backed` | `platform-connections` | `agent`, `vm`, `system-container`, `storage`, `ceph`, `physical-disk` | `setup=supported`; `visibility=supported`; `workloads=supported`; `storage=supported`; `recovery=supported`; `alerts=supported`; `assistant_read=supported`; `assistant_control=augmentation-only` |
| `proxmox-pbs` | `platform` | `supported` | `supported` | `api-backed` | `platform-connections` | `pbs`, `storage` | `setup=supported`; `visibility=supported`; `workloads=n/a`; `storage=supported`; `recovery=supported`; `alerts=supported`; `assistant_read=supported`; `assistant_control=read-only` |
| `proxmox-pmg` | `platform` | `supported` | `supported` | `api-backed` | `platform-connections` | `pmg` | `setup=supported`; `visibility=supported`; `workloads=n/a`; `storage=n/a`; `recovery=n/a`; `alerts=supported`; `assistant_read=supported`; `assistant_control=read-only` |
@@ -376,8 +380,8 @@ or runtime platform fallbacks that are not declared in this document.
| Platform | Surface kind | Family | Primary mode | Optional augmentation | Canonical projections |
| ---------------- | ------------- | ------------------ | ------------ | ------------------------------------- | --------------------------------------------------------------------- |
| `agent` | platform | Pulse-managed host | agent-backed | none | `agent`, `storage`, `physical-disk` |
| `docker` | runtime-lens | container runtime | agent-backed | none | `agent`, `app-container`, `docker-service` |
| `kubernetes` | platform | cluster runtime | agent-backed | none | `k8s-cluster`, `k8s-node`, `pod`, `k8s-deployment` |
| `docker` | runtime-lens | container runtime | agent-backed | none | `agent`, `app-container`, `docker-image`, `docker-volume`, `docker-network`, `docker-service`, `docker-task` |
| `kubernetes` | platform | cluster runtime | agent-backed | none | `k8s-cluster`, `k8s-node`, `pod`, `k8s-deployment`, `k8s-statefulset`, `k8s-daemonset`, `k8s-service`, `k8s-job`, `k8s-cronjob`, `k8s-ingress`, `k8s-persistent-volume`, `k8s-persistent-volume-claim`, `k8s-namespace`, `k8s-event` |
| `proxmox-pve` | platform | Proxmox | api-backed | host agent may augment into hybrid | `agent`, `vm`, `system-container`, `storage`, `ceph`, `physical-disk` |
| `proxmox-pbs` | platform | Proxmox | api-backed | host agent may augment into hybrid | `pbs`, `storage` |
| `proxmox-pmg` | platform | Proxmox | api-backed | none today | `pmg` |
@@ -386,8 +390,8 @@ or runtime platform fallbacks that are not declared in this document.
| Platform | Setup | Visibility | Workloads | Storage | Recovery | Alerts | Assistant read | Assistant control |
| ---------------- | -------------------------------------- | ---------- | --------- | --------- | --------- | --------- | -------------- | ----------------- |
| `agent` | install workspace | supported | `n/a` | supported | `n/a` | supported | supported | supported |
| `docker` | install workspace / runtime enablement | supported | supported | `n/a` | `n/a` | supported | supported | supported |
| `kubernetes` | install workspace / runtime enablement | supported | supported | `n/a` | supported | supported | supported | supported |
| `docker` | install workspace / runtime enablement | supported | supported | supported | `n/a` | supported | supported | supported |
| `kubernetes` | install workspace / runtime enablement | supported | supported | supported | supported | supported | supported | supported |
| `proxmox-pve` | platform connections | supported | supported | supported | supported | supported | supported | augmentation-only |
| `proxmox-pbs` | platform connections | supported | `n/a` | supported | supported | supported | supported | read-only |
| `proxmox-pmg` | platform connections | supported | `n/a` | `n/a` | `n/a` | supported | supported | read-only |
@@ -180,6 +180,12 @@ impact previews, or cross-organization sharing only through the API and
unified-resource contracts; lifecycle code must not treat that share as an
agent install target, a command-agent authority, or a reason to widen setup
tokens.
Docker / Podman native inventory and Kubernetes native API inventory follow
that same lifecycle boundary. Images, volumes, networks, Swarm tasks,
Kubernetes Services, controllers, PV/PVC records, ingresses, namespaces, and
events may appear as API/unified-resource evidence, but lifecycle surfaces must
not treat those child inventory rows as agent enrollment candidates, install
targets, fleet command authorities, or setup-token scopes.
VMware vSphere `network` resources follow that same lifecycle boundary. A
vCenter connection may project networks into platform tables, resource
pickers, Assistant context, or monitored-system previews through the shared
@@ -116,6 +116,15 @@ transport-only. It may amortize polling and remount cost, but it must not
change normalized response shape, bypass monitor or read-state availability
checks, merge tenants, or become the source of truth for telemetry freshness.
The `/api/resources` type filter is the REST contract boundary for platform
page native inventory. It must accept canonical Docker / Podman runtime tokens
(`docker-image`, `docker-volume`, `docker-network`, `docker-task`) and
canonical Kubernetes API object tokens (`k8s-namespace`, `k8s-service`,
`k8s-statefulset`, `k8s-daemonset`, `k8s-job`, `k8s-cronjob`, `k8s-ingress`,
`k8s-persistent-volume`, `k8s-persistent-volume-claim`, `k8s-event`) whenever
unified resources can publish those records. Unsupported legacy aliases should
continue to fail closed instead of silently widening platform queries.
Discovery read endpoints are a canonical API payload boundary even when Pulse
is running in mock mode. `/api/discovery`, typed discovery detail/progress
routes, type filters, agent filters, and status must expose mock-authored
@@ -165,6 +165,12 @@ work extends shared components instead of creating new local variants.
## Shared Boundaries
Platform page subnavigation is a shared frontend primitive. Docker / Podman
and Kubernetes platform pages may add native API-backed sections, but the tabs
must use `PlatformSectionTabs`, canonical table alignment helpers, and shared
resource type presentation/reporting helpers rather than page-local tab shells,
alignment classes, or ad hoc report-category coercion.
1. `frontend-modern/src/components/Settings/APIAccessPanel.tsx` shared with `security-privacy`: the API Access settings intro is both a security/privacy token-management trust surface and a canonical settings-shell presentation boundary.
The panel may own shell placement and local action layout, but
token-specific Docker / Podman copy must come from
@@ -70,6 +70,14 @@ truth for live infrastructure data.
5. Add or change container startup ownership/bootstrap behavior for hosted or managed Pulse runtime mounts through `docker-entrypoint.sh`
6. Add or change Docker Swarm manager task/service runtime collection through `internal/dockeragent/swarm.go`
7. Add or change Docker or Podman container stats compatibility and runtime metric semantics through `internal/dockeragent/collect.go`
Docker / Podman collection now owns native runtime inventory as well as
container metrics. It may collect image summaries, volume summaries,
network summaries, Swarm tasks, and daemon storage-usage buckets from the
documented runtime API, then publish those records through the Docker agent
report for unified-resource ingestion. Failures in image, volume, network,
or storage-usage collection are best-effort warnings and must not make the
whole host report fail when container/runtime health data is otherwise
usable.
8. Add or change Proxmox Ceph compatibility payload decoding through `pkg/proxmox/ceph.go`
9. Add or change Proxmox ZFS compatibility payload decoding and vdev-role normalization through `pkg/proxmox/zfs.go`
10. Add or change mock chart synthesis, seeded history continuity, or mock-owned
@@ -149,6 +157,14 @@ truth for live infrastructure data.
`platform_event` provenance, then recorded by monitoring's supplemental
resource-change bridge. Monitoring must not create a frontend-only VMware
activity fixture or bypass the unified resource-change store.
18. Add or change Kubernetes native API inventory through
`internal/kubernetesagent/agent.go` and
`internal/monitoring/kubernetes_agents.go`. The Kubernetes agent may read
Namespaces, Services, StatefulSets, DaemonSets, Jobs, CronJobs, Ingresses,
PersistentVolumes, PersistentVolumeClaims, and Events as bounded
best-effort inventory, but monitoring must preserve those objects as native
cluster inventory instead of flattening them into pods, deployments, or
generic storage rows.
## Forbidden Paths
@@ -78,6 +78,12 @@ create, review, and approve cross-organization shares.
compensation around grids.
3. Route organization and RBAC frontend transport changes through `frontend-modern/src/api/orgs.ts` and `frontend-modern/src/api/rbac.ts`
4. Keep backend organization management and lifecycle handlers aligned through `internal/api/org_handlers.go` and `internal/api/org_lifecycle_handlers.go`
Organization resource shares may reference the canonical Docker / Podman
and Kubernetes inventory resource types exposed by `/api/resources`, but the
organization layer only validates the bounded resource-type vocabulary. It
must not reinterpret a Docker image/volume/network/task or Kubernetes
Service/controller/storage/networking/event row as a broader organization
role, agent enrollment authority, or command capability.
5. Keep RBAC role, assignment, and admin recovery transport aligned through `internal/api/access_control_handlers.go` and `internal/api/enterprise_extension_rbac_admin.go`
## Forbidden Paths
@@ -153,6 +153,12 @@ unified-resource/platform truth sourced from the TrueNAS API; storage/recovery
must not reinterpret app containers, VM device inventory, service PIDs, share
paths, volumes, or update posture as storage ownership, restore entitlement, or
a separate Docker-only or TrueNAS-local inventory path.
Docker / Podman volume inventory and Kubernetes PersistentVolume /
PersistentVolumeClaim inventory are also read-only platform context for this
subsystem. Storage and recovery surfaces may use those records to explain
runtime attachment or protected workload context, but restore entitlement,
storage risk, and recovery-point ownership remain on the storage/recovery and
provider contracts rather than on Docker volume rows or Kubernetes PV/PVC rows.
1. Add or change recovery-point persistence, rollups, or series derivation through `internal/recovery/`
2. Add or change recovery page UX through `frontend-modern/src/components/Recovery/` and keep canonical route/query/filter state ownership in `frontend-modern/src/features/recovery/useRecoverySurfaceState.ts`
@@ -225,6 +225,16 @@ reintroduce false Swarm capability surfaces.
`TrueNASData.Services` on the canonical top-level `agent` resource,
preserving service name, boot enablement, runtime state, and process IDs
through clone, merge, transport, and frontend decode paths.
Docker / Podman inventory extends that same canonical type contract beyond
containers and Swarm services. Runtime image, volume, network, task, and
storage-usage evidence must enter through `DockerData` and the typed Docker
resource records (`docker-image`, `docker-volume`, `docker-network`,
`docker-task`) rather than being inferred inside the container page.
Kubernetes inventory likewise projects native API objects as first-class
unified resources: namespaces, Services, StatefulSets, DaemonSets, Jobs,
CronJobs, Ingresses, PersistentVolumes, PersistentVolumeClaims, and Events
must preserve their API identity and source metadata through clone, merge,
REST, websocket, and frontend decode paths.
2. Add typed accessors and views in `internal/unifiedresources/views.go`
3. Add source ingestion/adaptation in the adapter layer only
Frontend resource platform contracts in
@@ -326,6 +336,11 @@ reintroduce false Swarm capability surfaces.
alias while the current Proxmox metadata exposes a renamed instance or
cluster label; current node metadata is the fallback source of truth when
source-key lookup misses.
Platform pages that bucket Docker / Podman or Kubernetes resources must use
these canonical resource types and source facets directly. Page-local
scraping, generic type coercion, or "single table for every native object"
fallbacks are not a substitute for adapter-owned image, volume, network,
service, controller, storage, ingress, and event resource projections.
Resource detail mappers now reuse the shared
`frontend-modern/src/utils/textPresentation.ts` title-case helper for sensor
@@ -0,0 +1,360 @@
import { For, Show, type Component, type JSX } from 'solid-js';
import { StatusDot } from '@/components/shared/StatusDot';
import { TableCard } from '@/components/shared/TableCard';
import { TableCardHeader } from '@/components/shared/TableCardHeader';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/shared/Table';
import { formatBytes } from '@/utils/format';
import { getSimpleStatusIndicator } from '@/utils/status';
import { asTrimmedString } from '@/utils/stringUtils';
import {
PLATFORM_HEALTH_FILTER_OPTIONS,
PLATFORM_TABLE_BODY_CLASS,
PLATFORM_TABLE_CARD_CLASS,
PLATFORM_TABLE_HEADER_ROW_CLASS,
PlatformTableEmptyState,
PlatformTableToolbar,
createPlatformTableFilterState,
filterPlatformResources,
getPlatformTableCellClassForKind,
getPlatformTableHeadClassForKind,
type PlatformResourceStatusFilter,
} from '@/features/platformPage/sharedPlatformPage';
import type { Resource } from '@/types/resource';
type DockerInventoryVariant = 'images' | 'volumes' | 'networks' | 'tasks';
const textValue = (value: string | undefined): string => asTrimmedString(value) || '—';
const numberValue = (value: number | undefined): JSX.Element => (
<span class="tabular-nums">{value ?? 0}</span>
);
const byteValue = (value: number | undefined): string =>
typeof value === 'number' && value > 0 ? formatBytes(value) : '—';
const joinValues = (values: readonly (string | undefined)[] | undefined, empty = '—'): string => {
const joined = (values ?? [])
.map((value) => asTrimmedString(value))
.filter((value): value is string => typeof value === 'string' && value.length > 0)
.join(', ');
return joined || empty;
};
const dockerTableTitle = (variant: DockerInventoryVariant, explicit?: string): string => {
if (explicit) return explicit;
switch (variant) {
case 'images':
return 'Images';
case 'volumes':
return 'Volumes';
case 'networks':
return 'Networks';
case 'tasks':
return 'Swarm Tasks';
}
};
const searchPlaceholder = (variant: DockerInventoryVariant): string => {
switch (variant) {
case 'images':
return 'Search images';
case 'volumes':
return 'Search volumes';
case 'networks':
return 'Search networks';
case 'tasks':
return 'Search Swarm tasks';
}
};
export const DockerInventoryTable: Component<{
resources: Resource[];
variant: DockerInventoryVariant;
emptyIcon: JSX.Element;
emptyTitle: string;
emptyDescription: string;
title?: string;
showToolbar?: boolean;
}> = (props) => {
const tableState = createPlatformTableFilterState({
resources: () => props.resources,
initialStatus: 'all' as PlatformResourceStatusFilter,
filter: filterPlatformResources,
});
return (
<Show
when={props.resources.length > 0}
fallback={
<PlatformTableEmptyState
icon={props.emptyIcon}
title={props.emptyTitle}
description={props.emptyDescription}
/>
}
>
<div class="space-y-3">
<Show when={props.showToolbar !== false}>
<PlatformTableToolbar
search={tableState.search}
onSearchChange={tableState.setSearch}
searchPlaceholder={searchPlaceholder(props.variant)}
status={tableState.status()}
onStatusChange={tableState.setStatus}
statusOptions={PLATFORM_HEALTH_FILTER_OPTIONS}
visible={tableState.visible()}
total={tableState.total()}
rowNoun={props.variant}
/>
</Show>
<Show
when={tableState.filtered().length > 0}
fallback={
<PlatformTableEmptyState
icon={props.emptyIcon}
title={`No ${props.variant} match current filters`}
description="Adjust the search or status filter to see more rows."
/>
}
>
<TableCard class={PLATFORM_TABLE_CARD_CLASS}>
<TableCardHeader title={dockerTableTitle(props.variant, props.title)} />
<Table class="min-w-full table-fixed text-xs md:min-w-[1080px]">
<TableHeader>
<DockerInventoryHeader variant={props.variant} />
</TableHeader>
<TableBody class={PLATFORM_TABLE_BODY_CLASS}>
<For each={tableState.filtered()}>
{(resource) => <DockerInventoryRow resource={resource} variant={props.variant} />}
</For>
</TableBody>
</Table>
</TableCard>
</Show>
</div>
</Show>
);
};
const DockerInventoryHeader: Component<{ variant: DockerInventoryVariant }> = (props) => {
if (props.variant === 'images') {
return (
<TableRow class={PLATFORM_TABLE_HEADER_ROW_CLASS}>
<TableHead class={`${getPlatformTableHeadClassForKind('name')} md:w-[31%]`}>
Image
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('text')} hidden md:table-cell md:w-[29%]`}
>
Digests
</TableHead>
<TableHead class={`${getPlatformTableHeadClassForKind('numeric-value')} md:w-[12%]`}>
Size
</TableHead>
<TableHead class={`${getPlatformTableHeadClassForKind('numeric-value')} md:w-[10%]`}>
In Use
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('text')} hidden md:table-cell md:w-[18%]`}
>
Host
</TableHead>
</TableRow>
);
}
if (props.variant === 'volumes') {
return (
<TableRow class={PLATFORM_TABLE_HEADER_ROW_CLASS}>
<TableHead class={`${getPlatformTableHeadClassForKind('name')} md:w-[24%]`}>
Volume
</TableHead>
<TableHead class={`${getPlatformTableHeadClassForKind('text')} md:w-[12%]`}>
Driver
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('text')} hidden md:table-cell md:w-[10%]`}
>
Scope
</TableHead>
<TableHead class={`${getPlatformTableHeadClassForKind('numeric-value')} md:w-[10%]`}>
Size
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('numeric-value')} hidden md:table-cell md:w-[8%]`}
>
Refs
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('text')} hidden md:table-cell md:w-[36%]`}
>
Mountpoint
</TableHead>
</TableRow>
);
}
if (props.variant === 'networks') {
return (
<TableRow class={PLATFORM_TABLE_HEADER_ROW_CLASS}>
<TableHead class={`${getPlatformTableHeadClassForKind('name')} md:w-[25%]`}>
Network
</TableHead>
<TableHead class={`${getPlatformTableHeadClassForKind('text')} md:w-[14%]`}>
Driver
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('text')} hidden md:table-cell md:w-[12%]`}
>
Scope
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('text')} hidden md:table-cell md:w-[31%]`}
>
Subnets
</TableHead>
<TableHead class={`${getPlatformTableHeadClassForKind('text')} md:w-[18%]`}>Host</TableHead>
</TableRow>
);
}
return (
<TableRow class={PLATFORM_TABLE_HEADER_ROW_CLASS}>
<TableHead class={`${getPlatformTableHeadClassForKind('name')} md:w-[22%]`}>Task</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('text')} hidden md:table-cell md:w-[22%]`}
>
Service
</TableHead>
<TableHead class={`${getPlatformTableHeadClassForKind('text')} md:w-[14%]`}>
Desired
</TableHead>
<TableHead class={`${getPlatformTableHeadClassForKind('text')} md:w-[18%]`}>
Current
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('text')} hidden md:table-cell md:w-[24%]`}
>
Node
</TableHead>
</TableRow>
);
};
const DockerInventoryRow: Component<{ resource: Resource; variant: DockerInventoryVariant }> = (
props,
) => {
const indicator = () => getSimpleStatusIndicator(props.resource.status);
const name = () => asTrimmedString(props.resource.name) || props.resource.id;
const host = () => textValue(props.resource.docker?.hostname);
return (
<TableRow class="text-[11px] sm:text-xs">
<TableCell class={getPlatformTableCellClassForKind('name')}>
<div class="flex min-w-0 items-center gap-2">
<StatusDot
size="sm"
variant={indicator().variant}
title={props.resource.status || 'unknown'}
ariaHidden
/>
<span class="truncate font-semibold text-base-content" title={name()}>
{name()}
</span>
</div>
</TableCell>
<Show when={props.variant === 'images'}>
<TableCell
class={`${getPlatformTableCellClassForKind('text')} hidden text-base-content md:table-cell`}
>
<span
class="inline-block max-w-[24rem] truncate"
title={joinValues(props.resource.docker?.repoDigests)}
>
{joinValues(props.resource.docker?.repoDigests)}
</span>
</TableCell>
<TableCell class={`${getPlatformTableCellClassForKind('numeric-value')} text-base-content`}>
{byteValue(props.resource.docker?.sizeBytes)}
</TableCell>
<TableCell class={`${getPlatformTableCellClassForKind('numeric-value')} text-base-content`}>
{numberValue(props.resource.docker?.imageContainers)}
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('text')} hidden text-base-content md:table-cell`}
>
{host()}
</TableCell>
</Show>
<Show when={props.variant === 'volumes'}>
<TableCell class={`${getPlatformTableCellClassForKind('text')} text-base-content`}>
{textValue(props.resource.docker?.driver)}
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('text')} hidden text-base-content md:table-cell`}
>
{textValue(props.resource.docker?.scope)}
</TableCell>
<TableCell class={`${getPlatformTableCellClassForKind('numeric-value')} text-base-content`}>
{byteValue(props.resource.docker?.sizeBytes)}
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('numeric-value')} hidden text-base-content md:table-cell`}
>
{numberValue(props.resource.docker?.refCount)}
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('text')} hidden text-base-content md:table-cell`}
>
<span
class="inline-block max-w-[28rem] truncate"
title={textValue(props.resource.docker?.mountpoint)}
>
{textValue(props.resource.docker?.mountpoint)}
</span>
</TableCell>
</Show>
<Show when={props.variant === 'networks'}>
<TableCell class={`${getPlatformTableCellClassForKind('text')} text-base-content`}>
{textValue(props.resource.docker?.driver)}
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('text')} hidden text-base-content md:table-cell`}
>
{textValue(props.resource.docker?.scope)}
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('text')} hidden text-base-content md:table-cell`}
>
{joinValues(props.resource.docker?.subnets?.map((subnet) => subnet.subnet))}
</TableCell>
<TableCell class={`${getPlatformTableCellClassForKind('text')} text-base-content`}>
{host()}
</TableCell>
</Show>
<Show when={props.variant === 'tasks'}>
<TableCell
class={`${getPlatformTableCellClassForKind('text')} hidden text-base-content md:table-cell`}
>
{textValue(props.resource.docker?.serviceName)}
</TableCell>
<TableCell class={`${getPlatformTableCellClassForKind('text')} text-base-content`}>
{textValue(props.resource.docker?.desiredState)}
</TableCell>
<TableCell class={`${getPlatformTableCellClassForKind('text')} text-base-content`}>
{textValue(props.resource.docker?.currentState)}
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('text')} hidden text-base-content md:table-cell`}
>
{textValue(props.resource.docker?.nodeName || props.resource.docker?.nodeId)}
</TableCell>
</Show>
</TableRow>
);
};
export default DockerInventoryTable;
@@ -1,3 +1,4 @@
import { useLocation } from '@solidjs/router';
import ContainerIcon from 'lucide-solid/icons/container';
import { Show, createMemo } from 'solid-js';
import { WorkloadsFilter } from '@/components/Workloads/WorkloadsFilter';
@@ -13,16 +14,20 @@ import {
} from '@/features/platformPage/sharedPlatformPage';
import { APP_CONTAINER_COLUMN_LABEL_OVERRIDES } from '@/features/platformPage/appContainerColumns';
import { DockerHostsTable } from './DockerHostsTable';
import { DockerInventoryTable } from './DockerInventoryTable';
import { DockerServicesTable } from './DockerServicesTable';
import {
DOCKER_TAB_SPECS,
buildDockerPageModel,
buildDockerContainerDefaultHiddenColumnIds,
buildDockerWorkloadGroupLabelBadges,
filterDockerHosts,
filterDockerServices,
type DockerPageTabId,
} from './dockerPageModel';
const DOCKER_RESOURCE_QUERY = 'type=agent,docker-host,app-container,docker-service';
const DOCKER_RESOURCE_QUERY =
'type=agent,docker-host,app-container,docker-service,docker-image,docker-volume,docker-network,docker-task';
const DOCKER_PLATFORM_FILTER = 'docker';
const DOCKER_WORKLOAD_FORCED_VIEW_MODE = 'app-container';
const DOCKER_WORKLOAD_DEFAULT_SORT_KEY = 'name';
@@ -37,16 +42,21 @@ const DOCKER_WORKLOAD_STATUS_OPTIONS: readonly WorkloadsStatusOption[] = [
{ value: 'degraded', label: 'Attention' },
{ value: 'stopped', label: 'Stopped' },
];
const DOCKER_TAB_SPECS = [{ id: 'overview', label: 'Overview', path: '/docker/overview' }] as const;
const VALID_TABS = new Set<DockerPageTabId>(DOCKER_TAB_SPECS.map((tab) => tab.id));
const dockerIcon = () => <ContainerIcon class="h-6 w-6 text-slate-400" />;
export function DockerPageSurface() {
const location = useLocation();
const { resources, loading, error, refetch } = useUnifiedResources({
query: DOCKER_RESOURCE_QUERY,
cacheKey: 'docker-workspace',
initialHydration: 'prefer-ws-then-rest',
});
const activeTab = createMemo<DockerPageTabId>(() => {
const segment = location.pathname.split('/').filter(Boolean)[1] as DockerPageTabId | undefined;
return segment && VALID_TABS.has(segment) ? segment : 'overview';
});
const model = createMemo(() => buildDockerPageModel(resources()));
const dockerWorkloadDefaultHiddenColumns = createMemo(() =>
buildDockerContainerDefaultHiddenColumnIds(model().containers),
@@ -96,7 +106,7 @@ export function DockerPageSurface() {
<div data-testid="docker-page" class="space-y-3">
<PlatformSectionTabs
tabs={DOCKER_TAB_SPECS}
active="overview"
active={activeTab()}
ariaLabel="Container runtime sections"
/>
@@ -129,80 +139,65 @@ export function DockerPageSurface() {
/>
}
>
<div class="space-y-4">
<Show when={showSharedFilterToolbar()}>
<div data-summary-clear-ignore>
<WorkloadsFilter
search={workloadsState.search}
setSearch={workloadsState.setSearch}
viewMode={workloadsState.viewMode}
setViewMode={workloadsState.setViewMode}
statusMode={workloadsState.statusMode}
setStatusMode={workloadsState.setStatusMode}
groupingMode={workloadsState.groupingMode}
setGroupingMode={workloadsState.setGroupingMode}
defaultSortKey={DOCKER_WORKLOAD_DEFAULT_SORT_KEY}
setSortKey={workloadsState.setSortKey}
setSortDirection={workloadsState.setSortDirection}
onBeforeAutoFocus={workloadsState.handleBeforeAutoFocus}
ariaLabel="Container runtime workload filters"
searchPlaceholder="Search containers by name, image, host, or runtime"
searchEmptyMessage="Recent Docker container searches appear here."
statusOptions={DOCKER_WORKLOAD_STATUS_OPTIONS}
columnVisibility={workloadsState.workloadsFilterColumnVisibility()}
containerRuntimeFilter={workloadsState.containerRuntimeFilterConfig()}
hostFilter={undefined}
platformFilter={undefined}
namespaceFilter={undefined}
suppressTypeFilter
metricDisplayMode={workloadsState.workloadMetricDisplayMode}
setMetricDisplayMode={workloadsState.setWorkloadMetricDisplayMode}
metricHistoryRange={workloadsState.workloadMetricHistoryRange}
setMetricHistoryRange={workloadsState.setWorkloadMetricHistoryRange}
forcedPlatform={DOCKER_PLATFORM_FILTER}
pinnedSelectionActive={() =>
Boolean(
workloadsState.selectedGuestId() ||
workloadsState.focusedSummaryWorkloadGroupId(),
)
}
onClearPinnedSelection={workloadsState.clearPinnedSummaryScope}
/>
</div>
</Show>
<DockerHostsTable
resources={filteredHosts()}
sourceCount={model().hosts.length}
<Show when={activeTab() === 'overview'}>
<DockerOverview
showSharedFilterToolbar={showSharedFilterToolbar()}
workloadsState={workloadsState}
hosts={filteredHosts()}
hostSourceCount={model().hosts.length}
filteredServices={filteredServices()}
serviceSourceCount={model().services.length}
showServicesSection={showServicesSection()}
/>
</Show>
<Show when={activeTab() === 'containers'}>
<DockerContainers workloadsState={workloadsState} />
</Show>
<Show when={activeTab() === 'images'}>
<DockerInventoryTable
resources={model().images}
variant="images"
emptyIcon={dockerIcon()}
emptyTitle="No Docker or Podman hosts"
emptyDescription="Container hosts appear here once a Pulse agent registers them."
showToolbar={false}
emptyTitle="No images"
emptyDescription="Images appear here when a Docker or Podman host reports local image inventory."
/>
<WorkloadsSurface
state={workloadsState}
vms={[]}
containers={[]}
nodes={[]}
useWorkloads
embedded
tableOnly
forcedPlatform={DOCKER_PLATFORM_FILTER}
forcedViewMode={DOCKER_WORKLOAD_FORCED_VIEW_MODE}
groupNodeDrawerMode="disabled"
emptyStateTitle="No Docker or Podman containers"
emptyStateDescription="Containers appear here when a Docker or Podman host reports workload inventory."
</Show>
<Show when={activeTab() === 'volumes'}>
<DockerInventoryTable
resources={model().volumes}
variant="volumes"
emptyIcon={dockerIcon()}
emptyTitle="No volumes"
emptyDescription="Volumes appear here when the container runtime reports volume inventory."
/>
<Show when={showServicesSection()}>
<DockerServicesTable
resources={filteredServices()}
sourceCount={model().services.length}
emptyIcon={dockerIcon()}
emptyTitle="No Swarm services"
emptyDescription="Docker Swarm services appear here when a Swarm manager reports them."
showToolbar={false}
/>
</Show>
</div>
</Show>
<Show when={activeTab() === 'networks'}>
<DockerInventoryTable
resources={model().networks}
variant="networks"
emptyIcon={dockerIcon()}
emptyTitle="No networks"
emptyDescription="Networks appear here when the container runtime reports network inventory."
/>
</Show>
<Show when={activeTab() === 'services'}>
<DockerServicesTable
resources={model().services}
sourceCount={model().services.length}
emptyIcon={dockerIcon()}
emptyTitle="No Swarm services"
emptyDescription="Docker Swarm services appear here when a Swarm manager reports them."
/>
</Show>
<Show when={activeTab() === 'tasks'}>
<DockerInventoryTable
resources={model().tasks}
variant="tasks"
emptyIcon={dockerIcon()}
emptyTitle="No Swarm tasks"
emptyDescription="Swarm tasks appear here when a Swarm manager reports replica task inventory."
/>
</Show>
</Show>
</Show>
</Show>
@@ -211,3 +206,120 @@ export function DockerPageSurface() {
}
export default DockerPageSurface;
type DockerWorkloadsState = ReturnType<typeof useWorkloadsState>;
function DockerWorkloadsFilter(props: { workloadsState: DockerWorkloadsState }) {
return (
<div data-summary-clear-ignore>
<WorkloadsFilter
search={props.workloadsState.search}
setSearch={props.workloadsState.setSearch}
viewMode={props.workloadsState.viewMode}
setViewMode={props.workloadsState.setViewMode}
statusMode={props.workloadsState.statusMode}
setStatusMode={props.workloadsState.setStatusMode}
groupingMode={props.workloadsState.groupingMode}
setGroupingMode={props.workloadsState.setGroupingMode}
defaultSortKey={DOCKER_WORKLOAD_DEFAULT_SORT_KEY}
setSortKey={props.workloadsState.setSortKey}
setSortDirection={props.workloadsState.setSortDirection}
onBeforeAutoFocus={props.workloadsState.handleBeforeAutoFocus}
ariaLabel="Container runtime workload filters"
searchPlaceholder="Search containers by name, image, host, or runtime"
searchEmptyMessage="Recent Docker container searches appear here."
statusOptions={DOCKER_WORKLOAD_STATUS_OPTIONS}
columnVisibility={props.workloadsState.workloadsFilterColumnVisibility()}
containerRuntimeFilter={props.workloadsState.containerRuntimeFilterConfig()}
hostFilter={undefined}
platformFilter={undefined}
namespaceFilter={undefined}
suppressTypeFilter
metricDisplayMode={props.workloadsState.workloadMetricDisplayMode}
setMetricDisplayMode={props.workloadsState.setWorkloadMetricDisplayMode}
metricHistoryRange={props.workloadsState.workloadMetricHistoryRange}
setMetricHistoryRange={props.workloadsState.setWorkloadMetricHistoryRange}
forcedPlatform={DOCKER_PLATFORM_FILTER}
pinnedSelectionActive={() =>
Boolean(
props.workloadsState.selectedGuestId() ||
props.workloadsState.focusedSummaryWorkloadGroupId(),
)
}
onClearPinnedSelection={props.workloadsState.clearPinnedSummaryScope}
/>
</div>
);
}
function DockerContainers(props: { workloadsState: DockerWorkloadsState }) {
return (
<div class="space-y-4">
<DockerWorkloadsFilter workloadsState={props.workloadsState} />
<WorkloadsSurface
state={props.workloadsState}
vms={[]}
containers={[]}
nodes={[]}
useWorkloads
embedded
tableOnly
forcedPlatform={DOCKER_PLATFORM_FILTER}
forcedViewMode={DOCKER_WORKLOAD_FORCED_VIEW_MODE}
groupNodeDrawerMode="disabled"
emptyStateTitle="No Docker or Podman containers"
emptyStateDescription="Containers appear here when a Docker or Podman host reports workload inventory."
/>
</div>
);
}
function DockerOverview(props: {
showSharedFilterToolbar: boolean;
workloadsState: DockerWorkloadsState;
hosts: ReturnType<typeof buildDockerPageModel>['hosts'];
hostSourceCount: number;
filteredServices: ReturnType<typeof buildDockerPageModel>['services'];
serviceSourceCount: number;
showServicesSection: boolean;
}) {
return (
<div class="space-y-4">
<Show when={props.showSharedFilterToolbar}>
<DockerWorkloadsFilter workloadsState={props.workloadsState} />
</Show>
<DockerHostsTable
resources={props.hosts}
sourceCount={props.hostSourceCount}
emptyIcon={dockerIcon()}
emptyTitle="No Docker or Podman hosts"
emptyDescription="Container hosts appear here once a Pulse agent registers them."
showToolbar={false}
/>
<WorkloadsSurface
state={props.workloadsState}
vms={[]}
containers={[]}
nodes={[]}
useWorkloads
embedded
tableOnly
forcedPlatform={DOCKER_PLATFORM_FILTER}
forcedViewMode={DOCKER_WORKLOAD_FORCED_VIEW_MODE}
groupNodeDrawerMode="disabled"
emptyStateTitle="No Docker or Podman containers"
emptyStateDescription="Containers appear here when a Docker or Podman host reports workload inventory."
/>
<Show when={props.showServicesSection}>
<DockerServicesTable
resources={props.filteredServices}
sourceCount={props.serviceSourceCount}
emptyIcon={dockerIcon()}
emptyTitle="No Swarm services"
emptyDescription="Docker Swarm services appear here when a Swarm manager reports them."
showToolbar={false}
/>
</Show>
</div>
);
}
@@ -62,7 +62,7 @@ const makeDockerService = (overrides: Partial<Resource> = {}): Resource => ({
});
describe('dockerPageModel', () => {
it('buckets Docker hosts, containers, and Swarm services from canonical resources', () => {
it('buckets Docker hosts, containers, images, volumes, networks, tasks, and Swarm services from canonical resources', () => {
const model = buildDockerPageModel([
makeResource({ id: 'docker-host-1', type: 'agent' }),
makeResource({
@@ -71,6 +71,10 @@ describe('dockerPageModel', () => {
platformType: 'docker',
}),
makeResource({ id: 'svc-1', type: 'docker-service' }),
makeResource({ id: 'image-1', type: 'docker-image' }),
makeResource({ id: 'volume-1', type: 'docker-volume' }),
makeResource({ id: 'network-1', type: 'docker-network' }),
makeResource({ id: 'task-1', type: 'docker-task' }),
makeResource({
id: 'pve-node-1',
type: 'agent',
@@ -81,8 +85,12 @@ describe('dockerPageModel', () => {
expect(model.hosts.map((resource) => resource.id)).toEqual(['docker-host-1']);
expect(model.containers.map((resource) => resource.id)).toEqual(['ctr-1']);
expect(model.services.map((resource) => resource.id)).toEqual(['svc-1']);
expect(model.images.map((resource) => resource.id)).toEqual(['image-1']);
expect(model.volumes.map((resource) => resource.id)).toEqual(['volume-1']);
expect(model.networks.map((resource) => resource.id)).toEqual(['network-1']);
expect(model.tasks.map((resource) => resource.id)).toEqual(['task-1']);
expect(model.resources.map((resource) => resource.id).sort()).toEqual(
['ctr-1', 'docker-host-1', 'svc-1'].sort(),
['ctr-1', 'docker-host-1', 'image-1', 'network-1', 'svc-1', 'task-1', 'volume-1'].sort(),
);
});
@@ -11,6 +11,33 @@ import { buildAppContainerDefaultHiddenColumnIds } from '@/features/platformPage
const DOCKER_HOST_TYPES = new Set<ResourceType>(['agent', 'docker-host']);
const DOCKER_CONTAINER_TYPES = new Set<ResourceType>(['app-container']);
const DOCKER_SERVICE_TYPES = new Set<ResourceType>(['docker-service']);
const DOCKER_IMAGE_TYPES = new Set<ResourceType>(['docker-image']);
const DOCKER_VOLUME_TYPES = new Set<ResourceType>(['docker-volume']);
const DOCKER_NETWORK_TYPES = new Set<ResourceType>(['docker-network']);
const DOCKER_TASK_TYPES = new Set<ResourceType>(['docker-task']);
export type DockerPageTabId =
| 'overview'
| 'containers'
| 'images'
| 'volumes'
| 'networks'
| 'services'
| 'tasks';
export const DOCKER_TAB_SPECS: readonly {
id: DockerPageTabId;
label: string;
path: string;
}[] = [
{ id: 'overview', label: 'Overview', path: '/docker/overview' },
{ id: 'containers', label: 'Containers', path: '/docker/containers' },
{ id: 'images', label: 'Images', path: '/docker/images' },
{ id: 'volumes', label: 'Volumes', path: '/docker/volumes' },
{ id: 'networks', label: 'Networks', path: '/docker/networks' },
{ id: 'services', label: 'Services', path: '/docker/services' },
{ id: 'tasks', label: 'Tasks', path: '/docker/tasks' },
] as const;
const asTrimmedString = (value: unknown): string => (typeof value === 'string' ? value.trim() : '');
@@ -46,6 +73,10 @@ export type DockerPageModel = {
hosts: Resource[];
containers: Resource[];
services: Resource[];
images: Resource[];
volumes: Resource[];
networks: Resource[];
tasks: Resource[];
};
export interface DockerPageFilters {
@@ -112,7 +143,11 @@ export function buildDockerPageModel(resources: Resource[]): DockerPageModel {
(resource) =>
isDockerPlatform(resource) ||
DOCKER_CONTAINER_TYPES.has(resource.type) ||
DOCKER_SERVICE_TYPES.has(resource.type),
DOCKER_SERVICE_TYPES.has(resource.type) ||
DOCKER_IMAGE_TYPES.has(resource.type) ||
DOCKER_VOLUME_TYPES.has(resource.type) ||
DOCKER_NETWORK_TYPES.has(resource.type) ||
DOCKER_TASK_TYPES.has(resource.type),
);
const hosts = dockerResources.filter(
@@ -122,12 +157,20 @@ export function buildDockerPageModel(resources: Resource[]): DockerPageModel {
(resource) => DOCKER_CONTAINER_TYPES.has(resource.type) && isDockerPlatform(resource),
);
const services = dockerResources.filter((resource) => DOCKER_SERVICE_TYPES.has(resource.type));
const images = dockerResources.filter((resource) => DOCKER_IMAGE_TYPES.has(resource.type));
const volumes = dockerResources.filter((resource) => DOCKER_VOLUME_TYPES.has(resource.type));
const networks = dockerResources.filter((resource) => DOCKER_NETWORK_TYPES.has(resource.type));
const tasks = dockerResources.filter((resource) => DOCKER_TASK_TYPES.has(resource.type));
return {
resources: dockerResources,
hosts,
containers,
services,
images,
volumes,
networks,
tasks,
};
}
@@ -146,6 +189,18 @@ const resourceSearchCandidates = (resource: Resource): Array<string | undefined>
resource.docker?.architecture,
resource.docker?.swarm?.clusterName,
resource.docker?.swarm?.nodeRole,
resource.docker?.imageId,
resource.docker?.volumeName,
resource.docker?.networkId,
resource.docker?.driver,
resource.docker?.mountpoint,
resource.docker?.scope,
resource.docker?.taskId,
resource.docker?.serviceName,
resource.docker?.currentState,
...(resource.docker?.repoTags ?? []),
...(resource.docker?.repoDigests ?? []),
...(resource.docker?.subnets?.map((subnet) => subnet.subnet) ?? []),
resource.identity?.hostname,
resource.canonicalIdentity?.displayName,
resource.canonicalIdentity?.hostname,
@@ -258,3 +313,16 @@ export function filterDockerServices(
matchesHostScope(resource, filters.selectedHostScope),
);
}
export function filterDockerInventoryResources(
resources: readonly Resource[],
filters: DockerPageFilters,
): Resource[] {
return resources.filter(
(resource) =>
matchesSearch(resource, filters.searchTerm || '') &&
matchesStatusMode(resource, filters.statusMode) &&
matchesContainerRuntime(resource, filters.containerRuntime) &&
matchesHostScope(resource, filters.selectedHostScope),
);
}
@@ -0,0 +1,378 @@
import { For, Show, type Component, type JSX } from 'solid-js';
import { StatusDot } from '@/components/shared/StatusDot';
import { TableCard } from '@/components/shared/TableCard';
import { TableCardHeader } from '@/components/shared/TableCardHeader';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/shared/Table';
import { formatBytes } from '@/utils/format';
import { getResourceTypeLabel } from '@/utils/resourceTypePresentation';
import { getSimpleStatusIndicator } from '@/utils/status';
import { asTrimmedString } from '@/utils/stringUtils';
import {
PLATFORM_HEALTH_FILTER_OPTIONS,
PLATFORM_TABLE_BODY_CLASS,
PLATFORM_TABLE_CARD_CLASS,
PLATFORM_TABLE_HEADER_ROW_CLASS,
PlatformTableEmptyState,
PlatformTableToolbar,
createPlatformTableFilterState,
filterPlatformResources,
getPlatformTableCellClassForKind,
getPlatformTableHeadClassForKind,
type PlatformResourceStatusFilter,
} from '@/features/platformPage/sharedPlatformPage';
import type { Resource } from '@/types/resource';
type KubernetesInventoryVariant =
| 'controllers'
| 'services'
| 'storage'
| 'networking'
| 'config'
| 'events';
const textValue = (value: string | undefined): string => asTrimmedString(value) || '—';
const numberValue = (value: number | undefined): JSX.Element => (
<span class="tabular-nums">{value ?? 0}</span>
);
const byteValue = (value: number | undefined): string =>
typeof value === 'number' && value > 0 ? formatBytes(value) : '—';
const joinValues = (values: readonly (string | undefined)[] | undefined): string =>
(values ?? [])
.map((value) => asTrimmedString(value))
.filter((value): value is string => typeof value === 'string' && value.length > 0)
.join(', ') || '—';
const k8sKind = (resource: Resource): string =>
resource.kubernetes?.resourceKind || getResourceTypeLabel(resource.type) || resource.type;
const tableTitle = (variant: KubernetesInventoryVariant, explicit?: string): string => {
if (explicit) return explicit;
switch (variant) {
case 'controllers':
return 'Controllers';
case 'services':
return 'Services';
case 'storage':
return 'Storage';
case 'networking':
return 'Networking';
case 'config':
return 'Config';
case 'events':
return 'Events';
}
};
export const KubernetesInventoryTable: Component<{
resources: Resource[];
variant: KubernetesInventoryVariant;
emptyIcon: JSX.Element;
emptyTitle: string;
emptyDescription: string;
title?: string;
showToolbar?: boolean;
}> = (props) => {
const tableState = createPlatformTableFilterState({
resources: () => props.resources,
initialStatus: 'all' as PlatformResourceStatusFilter,
filter: filterPlatformResources,
});
return (
<Show
when={props.resources.length > 0}
fallback={
<PlatformTableEmptyState
icon={props.emptyIcon}
title={props.emptyTitle}
description={props.emptyDescription}
/>
}
>
<div class="space-y-3">
<Show when={props.showToolbar !== false}>
<PlatformTableToolbar
search={tableState.search}
onSearchChange={tableState.setSearch}
searchPlaceholder={`Search ${tableTitle(props.variant).toLowerCase()}`}
status={tableState.status()}
onStatusChange={tableState.setStatus}
statusOptions={PLATFORM_HEALTH_FILTER_OPTIONS}
visible={tableState.visible()}
total={tableState.total()}
rowNoun={tableTitle(props.variant).toLowerCase()}
/>
</Show>
<Show
when={tableState.filtered().length > 0}
fallback={
<PlatformTableEmptyState
icon={props.emptyIcon}
title={`No ${tableTitle(props.variant).toLowerCase()} match current filters`}
description="Adjust the search or status filter to see more rows."
/>
}
>
<TableCard class={PLATFORM_TABLE_CARD_CLASS}>
<TableCardHeader title={tableTitle(props.variant, props.title)} />
<Table class="min-w-full table-fixed text-xs md:min-w-[1080px]">
<TableHeader>
<KubernetesInventoryHeader variant={props.variant} />
</TableHeader>
<TableBody class={PLATFORM_TABLE_BODY_CLASS}>
<For each={tableState.filtered()}>
{(resource) => (
<KubernetesInventoryRow resource={resource} variant={props.variant} />
)}
</For>
</TableBody>
</Table>
</TableCard>
</Show>
</div>
</Show>
);
};
const KubernetesInventoryHeader: Component<{ variant: KubernetesInventoryVariant }> = (props) => (
<TableRow class={PLATFORM_TABLE_HEADER_ROW_CLASS}>
<TableHead class={`${getPlatformTableHeadClassForKind('name')} md:w-[25%]`}>Name</TableHead>
<TableHead class={`${getPlatformTableHeadClassForKind('text')} md:w-[14%]`}>Kind</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('text')} hidden md:table-cell md:w-[16%]`}
>
Namespace
</TableHead>
<Show when={props.variant === 'storage'}>
<TableHead class={`${getPlatformTableHeadClassForKind('text')} md:w-[12%]`}>Phase</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('text')} hidden md:table-cell md:w-[14%]`}
>
Class
</TableHead>
<TableHead class={`${getPlatformTableHeadClassForKind('numeric-value')} md:w-[12%]`}>
Size
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('text')} hidden md:table-cell md:w-[21%]`}
>
Bound To
</TableHead>
</Show>
<Show when={props.variant === 'services' || props.variant === 'networking'}>
<TableHead class={`${getPlatformTableHeadClassForKind('text')} md:w-[14%]`}>Type</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('text')} hidden md:table-cell md:w-[20%]`}
>
Address
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('text')} hidden md:table-cell md:w-[25%]`}
>
Ports / Hosts
</TableHead>
</Show>
<Show when={props.variant === 'controllers'}>
<TableHead class={`${getPlatformTableHeadClassForKind('numeric-value')} md:w-[10%]`}>
Desired
</TableHead>
<TableHead class={`${getPlatformTableHeadClassForKind('numeric-value')} md:w-[10%]`}>
Ready
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('text')} hidden md:table-cell md:w-[25%]`}
>
Detail
</TableHead>
</Show>
<Show when={props.variant === 'config'}>
<TableHead class={`${getPlatformTableHeadClassForKind('text')} md:w-[14%]`}>Phase</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('text')} hidden md:table-cell md:w-[31%]`}
>
Cluster
</TableHead>
</Show>
<Show when={props.variant === 'events'}>
<TableHead class={`${getPlatformTableHeadClassForKind('text')} md:w-[13%]`}>Reason</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('text')} hidden md:table-cell md:w-[18%]`}
>
Object
</TableHead>
<TableHead class={`${getPlatformTableHeadClassForKind('numeric-value')} md:w-[8%]`}>
Count
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('text')} hidden md:table-cell md:w-[30%]`}
>
Message
</TableHead>
</Show>
</TableRow>
);
const KubernetesInventoryRow: Component<{
resource: Resource;
variant: KubernetesInventoryVariant;
}> = (props) => {
const indicator = () => getSimpleStatusIndicator(props.resource.status);
const name = () => asTrimmedString(props.resource.name) || props.resource.id;
const namespace = () => textValue(props.resource.kubernetes?.namespace);
const kind = () => k8sKind(props.resource);
const address = () =>
textValue(
props.resource.kubernetes?.clusterIp ||
props.resource.kubernetes?.addresses?.[0] ||
props.resource.kubernetes?.externalIps?.[0],
);
const ports = () =>
joinValues(
props.resource.kubernetes?.servicePorts?.map((port) => {
const protocol = port.protocol ? `/${port.protocol.toLowerCase()}` : '';
const target = port.targetPort ? `:${port.targetPort}` : '';
return port.port ? `${port.port}${target}${protocol}` : undefined;
}) ?? props.resource.kubernetes?.hosts,
);
const desired = () =>
props.resource.kubernetes?.desiredReplicas ??
props.resource.kubernetes?.desiredNumberScheduled ??
props.resource.kubernetes?.active;
const ready = () =>
props.resource.kubernetes?.readyReplicas ??
props.resource.kubernetes?.numberReady ??
props.resource.kubernetes?.succeeded;
const detail = () =>
textValue(
props.resource.kubernetes?.schedule ||
props.resource.kubernetes?.serviceName ||
props.resource.kubernetes?.reason,
);
return (
<TableRow class="text-[11px] sm:text-xs">
<TableCell class={getPlatformTableCellClassForKind('name')}>
<div class="flex min-w-0 items-center gap-2">
<StatusDot
size="sm"
variant={indicator().variant}
title={props.resource.status || 'unknown'}
ariaHidden
/>
<span class="truncate font-semibold text-base-content" title={name()}>
{name()}
</span>
</div>
</TableCell>
<TableCell class={`${getPlatformTableCellClassForKind('text')} text-base-content`}>
{kind()}
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('text')} hidden text-base-content md:table-cell`}
>
{namespace()}
</TableCell>
<Show when={props.variant === 'storage'}>
<TableCell class={`${getPlatformTableCellClassForKind('text')} text-base-content`}>
{textValue(props.resource.kubernetes?.phase)}
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('text')} hidden text-base-content md:table-cell`}
>
{textValue(props.resource.kubernetes?.storageClass)}
</TableCell>
<TableCell class={`${getPlatformTableCellClassForKind('numeric-value')} text-base-content`}>
{byteValue(
props.resource.kubernetes?.capacityBytes || props.resource.kubernetes?.requestedBytes,
)}
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('text')} hidden text-base-content md:table-cell`}
>
{textValue(
props.resource.kubernetes?.volumeName ||
[props.resource.kubernetes?.claimNamespace, props.resource.kubernetes?.claimName]
.filter(Boolean)
.join('/'),
)}
</TableCell>
</Show>
<Show when={props.variant === 'services' || props.variant === 'networking'}>
<TableCell class={`${getPlatformTableCellClassForKind('text')} text-base-content`}>
{textValue(
props.resource.kubernetes?.serviceType || props.resource.kubernetes?.className,
)}
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('text')} hidden text-base-content md:table-cell`}
>
{address()}
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('text')} hidden text-base-content md:table-cell`}
>
{ports()}
</TableCell>
</Show>
<Show when={props.variant === 'controllers'}>
<TableCell class={`${getPlatformTableCellClassForKind('numeric-value')} text-base-content`}>
{numberValue(desired())}
</TableCell>
<TableCell class={`${getPlatformTableCellClassForKind('numeric-value')} text-base-content`}>
{numberValue(ready())}
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('text')} hidden text-base-content md:table-cell`}
>
{detail()}
</TableCell>
</Show>
<Show when={props.variant === 'config'}>
<TableCell class={`${getPlatformTableCellClassForKind('text')} text-base-content`}>
{textValue(props.resource.kubernetes?.phase)}
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('text')} hidden text-base-content md:table-cell`}
>
{textValue(props.resource.kubernetes?.clusterName)}
</TableCell>
</Show>
<Show when={props.variant === 'events'}>
<TableCell class={`${getPlatformTableCellClassForKind('text')} text-base-content`}>
{textValue(props.resource.kubernetes?.reason)}
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('text')} hidden text-base-content md:table-cell`}
>
{textValue(
[props.resource.kubernetes?.involvedKind, props.resource.kubernetes?.involvedName]
.filter(Boolean)
.join('/'),
)}
</TableCell>
<TableCell class={`${getPlatformTableCellClassForKind('numeric-value')} text-base-content`}>
{numberValue(props.resource.kubernetes?.count)}
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('text')} hidden text-base-content md:table-cell`}
>
<span
class="inline-block max-w-[28rem] truncate"
title={textValue(props.resource.kubernetes?.message)}
>
{textValue(props.resource.kubernetes?.message)}
</span>
</TableCell>
</Show>
</TableRow>
);
};
export default KubernetesInventoryTable;
@@ -1,3 +1,4 @@
import { useLocation } from '@solidjs/router';
import ShipWheelIcon from 'lucide-solid/icons/ship-wheel';
import { Show, createMemo, type Accessor } from 'solid-js';
import { WorkloadsFilter } from '@/components/Workloads/WorkloadsFilter';
@@ -14,18 +15,21 @@ import {
} from '@/features/platformPage/sharedPlatformPage';
import { KubernetesClustersTable } from './KubernetesClustersTable';
import { KubernetesDeploymentsTable } from './KubernetesDeploymentsTable';
import { KubernetesInventoryTable } from './KubernetesInventoryTable';
import { KubernetesNodesTable } from './KubernetesNodesTable';
import {
KUBERNETES_TAB_SPECS,
buildKubernetesPageModel,
type KubernetesPageModel,
type KubernetesPageTabId,
} from './kubernetesPageModel';
// Include `agent` rows so K8s nodes that the backend registry merged onto
// the linked agent host (sources=['agent','kubernetes']) still appear in the
// Nodes section of the Overview stack; the page model filters them down to
// those tagged kubernetes.
const KUBERNETES_RESOURCE_QUERY = 'type=k8s-cluster,k8s-node,pod,k8s-deployment,agent';
const KUBERNETES_RESOURCE_QUERY =
'type=k8s-cluster,k8s-node,pod,k8s-deployment,k8s-namespace,k8s-service,k8s-statefulset,k8s-daemonset,k8s-job,k8s-cronjob,k8s-ingress,k8s-persistent-volume,k8s-persistent-volume-claim,k8s-event,agent';
const KUBERNETES_PLATFORM_FILTER = 'kubernetes';
const KUBERNETES_WORKLOAD_FORCED_VIEW_MODE = 'pod';
const KUBERNETES_WORKLOAD_COLUMN_SCOPE = 'kubernetes-pods';
@@ -35,22 +39,30 @@ const KUBERNETES_POD_STATUS_OPTIONS: readonly WorkloadsStatusOption[] = [
{ value: 'degraded', label: 'Needs attention' },
{ value: 'stopped', label: 'Not running' },
];
const VALID_TABS = new Set<KubernetesPageTabId>(KUBERNETES_TAB_SPECS.map((tab) => tab.id));
const k8sIcon = () => <ShipWheelIcon class="h-6 w-6 text-slate-400" />;
export function KubernetesPageSurface() {
const location = useLocation();
const { resources, loading, error, refetch } = useUnifiedResources({
query: KUBERNETES_RESOURCE_QUERY,
cacheKey: 'kubernetes-workspace',
initialHydration: 'prefer-ws-then-rest',
});
const activeTab = createMemo<KubernetesPageTabId>(() => {
const segment = location.pathname.split('/').filter(Boolean)[1] as
| KubernetesPageTabId
| undefined;
return segment && VALID_TABS.has(segment) ? segment : 'overview';
});
const model = createMemo(() => buildKubernetesPageModel(resources()));
return (
<div data-testid="kubernetes-page" class="space-y-3">
<PlatformSectionTabs
tabs={KUBERNETES_TAB_SPECS}
active="overview"
active={activeTab()}
ariaLabel="Kubernetes sections"
/>
@@ -83,7 +95,57 @@ export function KubernetesPageSurface() {
/>
}
>
<KubernetesOverview model={model} />
<Show when={activeTab() === 'overview'}>
<KubernetesOverview model={model} />
</Show>
<Show when={activeTab() === 'workloads'}>
<KubernetesWorkloads model={model} />
</Show>
<Show when={activeTab() === 'services'}>
<KubernetesInventoryTable
resources={model().services}
variant="services"
emptyIcon={k8sIcon()}
emptyTitle="No services reported"
emptyDescription="Services appear here once the agent can read Service resources."
/>
</Show>
<Show when={activeTab() === 'storage'}>
<KubernetesInventoryTable
resources={model().storage}
variant="storage"
emptyIcon={k8sIcon()}
emptyTitle="No PV or PVC resources reported"
emptyDescription="Persistent volumes and claims appear here once the agent can read volume inventory."
/>
</Show>
<Show when={activeTab() === 'networking'}>
<KubernetesInventoryTable
resources={model().networking}
variant="networking"
emptyIcon={k8sIcon()}
emptyTitle="No networking resources reported"
emptyDescription="Services and ingresses appear here once the agent can read networking inventory."
/>
</Show>
<Show when={activeTab() === 'config'}>
<KubernetesInventoryTable
resources={model().config}
variant="config"
emptyIcon={k8sIcon()}
emptyTitle="No config resources reported"
emptyDescription="Namespaces appear here once the agent can read cluster configuration inventory."
/>
</Show>
<Show when={activeTab() === 'events'}>
<KubernetesInventoryTable
resources={model().events}
variant="events"
emptyIcon={k8sIcon()}
emptyTitle="No events reported"
emptyDescription="Events appear here when the agent can read the Kubernetes Events API."
/>
</Show>
</Show>
</Show>
</Show>
@@ -96,6 +158,16 @@ interface KubernetesOverviewProps {
}
function KubernetesOverview(props: KubernetesOverviewProps) {
return <KubernetesWorkloadStack model={props.model} mode="overview" />;
}
function KubernetesWorkloads(props: KubernetesOverviewProps) {
return <KubernetesWorkloadStack model={props.model} mode="workloads" />;
}
function KubernetesWorkloadStack(
props: KubernetesOverviewProps & { mode: 'overview' | 'workloads' },
) {
const workloadsState = useWorkloadsState({
vms: [],
containers: [],
@@ -137,6 +209,16 @@ function KubernetesOverview(props: KubernetesOverviewProps) {
.model()
.deployments.filter((deployment) => resourceMatchesSearch(deployment, searchTerm()));
});
const filteredControllers = createMemo(() => {
const controllers = [
...props.model().statefulSets,
...props.model().daemonSets,
...props.model().jobs,
...props.model().cronJobs,
];
if (!searchTerm()) return controllers;
return controllers.filter((controller) => resourceMatchesSearch(controller, searchTerm()));
});
return (
<div class="space-y-4">
@@ -178,21 +260,23 @@ function KubernetesOverview(props: KubernetesOverviewProps) {
/>
</div>
</Show>
<KubernetesClustersTable
clusters={filteredClusters()}
scope={props.model().resources}
emptyIcon={k8sIcon()}
emptyTitle="No clusters reported"
emptyDescription="Kubernetes clusters appear here once at least one agent reports cluster context."
showToolbar={false}
/>
<KubernetesNodesTable
resources={filteredNodes()}
emptyIcon={k8sIcon()}
emptyTitle="No nodes reported"
emptyDescription="Kubernetes nodes appear here as soon as the agent enumerates them."
showToolbar={false}
/>
<Show when={props.mode === 'overview'}>
<KubernetesClustersTable
clusters={filteredClusters()}
scope={props.model().resources}
emptyIcon={k8sIcon()}
emptyTitle="No clusters reported"
emptyDescription="Kubernetes clusters appear here once at least one agent reports cluster context."
showToolbar={false}
/>
<KubernetesNodesTable
resources={filteredNodes()}
emptyIcon={k8sIcon()}
emptyTitle="No nodes reported"
emptyDescription="Kubernetes nodes appear here as soon as the agent enumerates them."
showToolbar={false}
/>
</Show>
<Show when={props.model().deployments.length > 0}>
<KubernetesDeploymentsTable
resources={filteredDeployments()}
@@ -202,6 +286,16 @@ function KubernetesOverview(props: KubernetesOverviewProps) {
showToolbar={false}
/>
</Show>
<Show when={filteredControllers().length > 0}>
<KubernetesInventoryTable
resources={filteredControllers()}
variant="controllers"
emptyIcon={k8sIcon()}
emptyTitle="No controllers reported"
emptyDescription="StatefulSets, DaemonSets, Jobs, and CronJobs appear here when the agent reports them."
showToolbar={false}
/>
</Show>
<WorkloadsSurface
state={workloadsState}
vms={[]}
@@ -15,16 +15,34 @@ const makeResource = (resource: Partial<Resource> & Pick<Resource, 'id' | 'type'
});
describe('kubernetesPageModel', () => {
it('declares the Kubernetes section set as a single Overview tab', () => {
expect(KUBERNETES_TAB_SPECS.map((tab) => tab.id)).toEqual(['overview']);
it('declares API-native Kubernetes sections', () => {
expect(KUBERNETES_TAB_SPECS.map((tab) => tab.id)).toEqual([
'overview',
'workloads',
'services',
'storage',
'networking',
'config',
'events',
]);
});
it('buckets clusters, nodes, pods, and deployments', () => {
it('buckets clusters, nodes, workloads, services, storage, networking, config, and events', () => {
const model = buildKubernetesPageModel([
makeResource({ id: 'cluster-1', type: 'k8s-cluster' }),
makeResource({ id: 'node-1', type: 'k8s-node' }),
makeResource({ id: 'pod-1', type: 'pod' }),
makeResource({ id: 'dep-1', type: 'k8s-deployment' }),
makeResource({ id: 'svc-1', type: 'k8s-service' }),
makeResource({ id: 'sts-1', type: 'k8s-statefulset' }),
makeResource({ id: 'ds-1', type: 'k8s-daemonset' }),
makeResource({ id: 'job-1', type: 'k8s-job' }),
makeResource({ id: 'cron-1', type: 'k8s-cronjob' }),
makeResource({ id: 'ing-1', type: 'k8s-ingress' }),
makeResource({ id: 'pv-1', type: 'k8s-persistent-volume' }),
makeResource({ id: 'pvc-1', type: 'k8s-persistent-volume-claim' }),
makeResource({ id: 'ns-1', type: 'k8s-namespace' }),
makeResource({ id: 'event-1', type: 'k8s-event' }),
makeResource({ id: 'proxmox-vm', type: 'vm', platformType: 'proxmox-pve' }),
]);
@@ -32,7 +50,23 @@ describe('kubernetesPageModel', () => {
expect(model.nodes.map((r) => r.id)).toEqual(['node-1']);
expect(model.pods.map((r) => r.id)).toEqual(['pod-1']);
expect(model.deployments.map((r) => r.id)).toEqual(['dep-1']);
expect(model.resources).toHaveLength(4);
expect(model.services.map((r) => r.id)).toEqual(['svc-1']);
expect(model.statefulSets.map((r) => r.id)).toEqual(['sts-1']);
expect(model.daemonSets.map((r) => r.id)).toEqual(['ds-1']);
expect(model.jobs.map((r) => r.id)).toEqual(['job-1']);
expect(model.cronJobs.map((r) => r.id)).toEqual(['cron-1']);
expect(model.ingresses.map((r) => r.id)).toEqual(['ing-1']);
expect(model.persistentVolumes.map((r) => r.id)).toEqual(['pv-1']);
expect(model.persistentVolumeClaims.map((r) => r.id)).toEqual(['pvc-1']);
expect(model.namespaces.map((r) => r.id)).toEqual(['ns-1']);
expect(model.events.map((r) => r.id)).toEqual(['event-1']);
expect(model.workloads.map((r) => r.id).sort()).toEqual(
['cron-1', 'dep-1', 'ds-1', 'job-1', 'pod-1', 'sts-1'].sort(),
);
expect(model.storage.map((r) => r.id).sort()).toEqual(['pv-1', 'pvc-1']);
expect(model.networking.map((r) => r.id).sort()).toEqual(['ing-1', 'svc-1']);
expect(model.config.map((r) => r.id)).toEqual(['ns-1']);
expect(model.resources).toHaveLength(14);
});
it('treats agent rows that report a kubernetes source as Kubernetes nodes', () => {
@@ -1,20 +1,14 @@
import { resolveResourcePlatformType } from '@/utils/sourcePlatforms';
import type { Resource, ResourceType } from '@/types/resource';
import { resolveResourcePlatformType } from '@/utils/sourcePlatforms';
// The Overview tab stacks the K8s object graph from logical aggregate down
// to runtime detail: clusters → nodes → deployments → pods. Deployments
// (desired state) sit above pods (the actual runtime result) so a reader
// scanning for "did my rollout converge?" hits Ready/Available before
// scrolling into the per-pod list. The deployments table is gated on the
// cluster reporting any; when it's empty the pods section follows nodes
// directly. The standalone Nodes / Pods / Deployments tabs that used to
// live here were pure duplicates of the Overview stack, so they're
// intentionally absent — the Workloads filter inside Overview owns
// search/grouping for pods. Services are not surfaced by the canonical
// unified resource model today (no ResourceTypeK8sService projection on
// the backend), so a Services tab is similarly absent until that gap is
// closed in the canonical adapter.
export type KubernetesPageTabId = 'overview';
export type KubernetesPageTabId =
| 'overview'
| 'workloads'
| 'services'
| 'storage'
| 'networking'
| 'config'
| 'events';
export type KubernetesTabSpec = {
id: KubernetesPageTabId;
@@ -24,6 +18,12 @@ export type KubernetesTabSpec = {
export const KUBERNETES_TAB_SPECS: readonly KubernetesTabSpec[] = [
{ id: 'overview', label: 'Overview', path: '/kubernetes/overview' },
{ id: 'workloads', label: 'Workloads', path: '/kubernetes/workloads' },
{ id: 'services', label: 'Services', path: '/kubernetes/services' },
{ id: 'storage', label: 'Storage', path: '/kubernetes/storage' },
{ id: 'networking', label: 'Networking', path: '/kubernetes/networking' },
{ id: 'config', label: 'Config', path: '/kubernetes/config' },
{ id: 'events', label: 'Events', path: '/kubernetes/events' },
] as const;
const KUBERNETES_RESOURCE_TYPES = new Set<ResourceType>([
@@ -31,6 +31,16 @@ const KUBERNETES_RESOURCE_TYPES = new Set<ResourceType>([
'k8s-node',
'pod',
'k8s-deployment',
'k8s-namespace',
'k8s-service',
'k8s-statefulset',
'k8s-daemonset',
'k8s-job',
'k8s-cronjob',
'k8s-ingress',
'k8s-persistent-volume',
'k8s-persistent-volume-claim',
'k8s-event',
]);
const isKubernetesPlatform = (resource: Resource): boolean => {
@@ -54,6 +64,20 @@ export type KubernetesPageModel = {
nodes: Resource[];
pods: Resource[];
deployments: Resource[];
namespaces: Resource[];
services: Resource[];
statefulSets: Resource[];
daemonSets: Resource[];
jobs: Resource[];
cronJobs: Resource[];
ingresses: Resource[];
persistentVolumes: Resource[];
persistentVolumeClaims: Resource[];
events: Resource[];
workloads: Resource[];
storage: Resource[];
networking: Resource[];
config: Resource[];
};
export function buildKubernetesPageModel(resources: Resource[]): KubernetesPageModel {
@@ -62,6 +86,24 @@ export function buildKubernetesPageModel(resources: Resource[]): KubernetesPageM
const nodes = k8sResources.filter(isKubernetesNodeRow);
const pods = k8sResources.filter((resource) => resource.type === 'pod');
const deployments = k8sResources.filter((resource) => resource.type === 'k8s-deployment');
const namespaces = k8sResources.filter((resource) => resource.type === 'k8s-namespace');
const services = k8sResources.filter((resource) => resource.type === 'k8s-service');
const statefulSets = k8sResources.filter((resource) => resource.type === 'k8s-statefulset');
const daemonSets = k8sResources.filter((resource) => resource.type === 'k8s-daemonset');
const jobs = k8sResources.filter((resource) => resource.type === 'k8s-job');
const cronJobs = k8sResources.filter((resource) => resource.type === 'k8s-cronjob');
const ingresses = k8sResources.filter((resource) => resource.type === 'k8s-ingress');
const persistentVolumes = k8sResources.filter(
(resource) => resource.type === 'k8s-persistent-volume',
);
const persistentVolumeClaims = k8sResources.filter(
(resource) => resource.type === 'k8s-persistent-volume-claim',
);
const events = k8sResources.filter((resource) => resource.type === 'k8s-event');
const workloads = [...deployments, ...statefulSets, ...daemonSets, ...jobs, ...cronJobs, ...pods];
const storage = [...persistentVolumes, ...persistentVolumeClaims];
const networking = [...services, ...ingresses];
const config = [...namespaces];
return {
resources: k8sResources,
@@ -69,5 +111,19 @@ export function buildKubernetesPageModel(resources: Resource[]): KubernetesPageM
nodes,
pods,
deployments,
namespaces,
services,
statefulSets,
daemonSets,
jobs,
cronJobs,
ingresses,
persistentVolumes,
persistentVolumeClaims,
events,
workloads,
storage,
networking,
config,
};
}
@@ -204,6 +204,13 @@ const matchesPlatformSearch = (resource: Resource, search: string): boolean => {
resource.docker?.runtimeVersion,
resource.docker?.dockerVersion,
resource.docker?.image,
resource.docker?.imageId,
resource.docker?.volumeName,
resource.docker?.networkId,
resource.docker?.driver,
resource.docker?.mountpoint,
resource.docker?.serviceName,
resource.docker?.taskId,
resource.docker?.mode,
resource.docker?.swarm?.clusterName,
resource.docker?.swarm?.nodeRole,
@@ -213,6 +220,14 @@ const matchesPlatformSearch = (resource: Resource, search: string): boolean => {
resource.kubernetes?.namespace,
resource.kubernetes?.podName,
resource.kubernetes?.nodeName,
resource.kubernetes?.resourceKind,
resource.kubernetes?.serviceType,
resource.kubernetes?.clusterIp,
resource.kubernetes?.storageClass,
resource.kubernetes?.phase,
resource.kubernetes?.reason,
resource.kubernetes?.involvedName,
resource.kubernetes?.volumeName,
resource.kubernetes?.version,
resource.kubernetes?.kubeletVersion,
resource.kubernetes?.containerRuntimeVersion,
@@ -227,6 +242,12 @@ const matchesPlatformSearch = (resource: Resource, search: string): boolean => {
resource.vmware?.networkType,
resource.vmware?.networkHostNames?.join(' '),
resource.vmware?.networkVmNames?.join(' '),
...(resource.docker?.repoTags ?? []),
...(resource.docker?.repoDigests ?? []),
...(resource.kubernetes?.externalIps ?? []),
...(resource.kubernetes?.hosts ?? []),
...(resource.kubernetes?.addresses ?? []),
...(resource.kubernetes?.accessModes ?? []),
...(resource.tags ?? []),
]
.filter((value): value is string => typeof value === 'string')
@@ -521,10 +521,23 @@ const resolveType = (value?: string): ResourceType => {
case 'agent':
case 'storage':
case 'docker-host':
case 'docker-image':
case 'docker-volume':
case 'docker-network':
case 'docker-task':
case 'k8s-cluster':
case 'k8s-node':
case 'k8s-deployment':
case 'k8s-service':
case 'k8s-namespace':
case 'k8s-statefulset':
case 'k8s-daemonset':
case 'k8s-job':
case 'k8s-cronjob':
case 'k8s-ingress':
case 'k8s-persistent-volume':
case 'k8s-persistent-volume-claim':
case 'k8s-event':
case 'vm':
case 'system-container':
case 'oci-container':
@@ -548,10 +561,36 @@ const resolveType = (value?: string): ResourceType => {
return 'jail';
case 'docker-service':
return 'docker-service';
case 'docker-image':
return 'docker-image';
case 'docker-volume':
return 'docker-volume';
case 'docker-network':
return 'docker-network';
case 'docker-task':
return 'docker-task';
case 'k8s-deployment':
return 'k8s-deployment';
case 'k8s-service':
return 'k8s-service';
case 'k8s-namespace':
return 'k8s-namespace';
case 'k8s-statefulset':
return 'k8s-statefulset';
case 'k8s-daemonset':
return 'k8s-daemonset';
case 'k8s-job':
return 'k8s-job';
case 'k8s-cronjob':
return 'k8s-cronjob';
case 'k8s-ingress':
return 'k8s-ingress';
case 'k8s-persistent-volume':
return 'k8s-persistent-volume';
case 'k8s-persistent-volume-claim':
return 'k8s-persistent-volume-claim';
case 'k8s-event':
return 'k8s-event';
case 'storage':
return 'storage';
case 'datastore':
@@ -554,8 +554,21 @@ describe('Resource Interface', () => {
'pod',
'jail',
'docker-service',
'docker-image',
'docker-volume',
'docker-network',
'docker-task',
'k8s-deployment',
'k8s-service',
'k8s-namespace',
'k8s-statefulset',
'k8s-daemonset',
'k8s-job',
'k8s-cronjob',
'k8s-ingress',
'k8s-persistent-volume',
'k8s-persistent-volume-claim',
'k8s-event',
'storage',
'datastore',
'pool',
+124
View File
@@ -38,8 +38,21 @@ export type ResourceType =
| 'pod' // Kubernetes pod
| 'jail' // BSD jail
| 'docker-service' // Docker Swarm service
| 'docker-image' // Docker/Podman image
| 'docker-volume' // Docker/Podman volume
| 'docker-network' // Docker/Podman network
| 'docker-task' // Docker Swarm task
| 'k8s-deployment' // Kubernetes deployment
| 'k8s-service' // Kubernetes service
| 'k8s-namespace' // Kubernetes namespace
| 'k8s-statefulset' // Kubernetes stateful set
| 'k8s-daemonset' // Kubernetes daemon set
| 'k8s-job' // Kubernetes job
| 'k8s-cronjob' // Kubernetes cron job
| 'k8s-ingress' // Kubernetes ingress
| 'k8s-persistent-volume' // Kubernetes persistent volume
| 'k8s-persistent-volume-claim' // Kubernetes persistent volume claim
| 'k8s-event' // Kubernetes event
| 'storage' // Storage resource
| 'network' // Virtual/network topology resource
| 'datastore' // PBS datastore
@@ -545,6 +558,7 @@ export interface ResourceProxmoxMeta {
// Swarm services table.
export interface ResourceDockerMeta {
serviceId?: string;
serviceName?: string;
hostSourceId?: string;
containerId?: string;
hostname?: string;
@@ -561,10 +575,23 @@ export interface ResourceDockerMeta {
agentVersion?: string;
uptimeSeconds?: number;
containerCount?: number;
imageCount?: number;
volumeCount?: number;
networkCount?: number;
updatesAvailableCount?: number;
updatesLastCheckedAt?: string;
imagesUsage?: DockerStorageUsageMeta;
containersUsage?: DockerStorageUsageMeta;
volumesUsage?: DockerStorageUsageMeta;
buildCacheUsage?: DockerStorageUsageMeta;
command?: Record<string, unknown>;
image?: string;
imageId?: string;
repoTags?: string[];
repoDigests?: string[];
sizeBytes?: number;
sharedSizeBytes?: number;
imageContainers?: number;
containerState?: string;
ports?: Array<{
privatePort?: number;
@@ -587,6 +614,7 @@ export interface ResourceDockerMeta {
mode?: string;
desiredTasks?: number;
runningTasks?: number;
completedTasks?: number;
endpointPorts?: Array<{
protocol?: string;
targetPort?: number;
@@ -594,6 +622,34 @@ export interface ResourceDockerMeta {
publishMode?: string;
}>;
labels?: Record<string, string>;
volumeName?: string;
driver?: string;
mountpoint?: string;
scope?: string;
createdAt?: string;
refCount?: number;
options?: Record<string, string>;
networkId?: string;
enableIpv4?: boolean;
enableIpv6?: boolean;
internal?: boolean;
attachable?: boolean;
ingress?: boolean;
configOnly?: boolean;
subnets?: Array<{
subnet?: string;
gateway?: string;
}>;
taskId?: string;
nodeId?: string;
nodeName?: string;
slot?: number;
desiredState?: string;
currentState?: string;
error?: string;
message?: string;
startedAt?: string;
completedAt?: string;
swarm?: {
clusterId?: string;
clusterName?: string;
@@ -606,6 +662,13 @@ export interface ResourceDockerMeta {
};
}
export interface DockerStorageUsageMeta {
totalCount?: number;
activeCount?: number;
totalSizeBytes?: number;
reclaimableBytes?: number;
}
export interface ResourceTrueNASAppHostPort {
hostPort?: number;
hostIp?: string;
@@ -760,6 +823,8 @@ export interface ResourceKubernetesMeta {
clusterId?: string;
agentId?: string;
clusterName?: string;
resourceUid?: string;
resourceKind?: string;
context?: string;
nodeName?: string;
namespace?: string;
@@ -775,10 +840,69 @@ export interface ResourceKubernetesMeta {
// the generic infrastructure table are not the meaningful operator
// columns; replica counts are.
deploymentUid?: string;
statefulSetUid?: string;
daemonSetUid?: string;
serviceUid?: string;
jobUid?: string;
cronJobUid?: string;
ingressUid?: string;
persistentVolumeUid?: string;
persistentVolumeClaimUid?: string;
eventUid?: string;
namespaceUid?: string;
desiredReplicas?: number;
updatedReplicas?: number;
readyReplicas?: number;
availableReplicas?: number;
currentReplicas?: number;
desiredNumberScheduled?: number;
currentNumberScheduled?: number;
numberReady?: number;
numberAvailable?: number;
numberUnavailable?: number;
numberMisscheduled?: number;
serviceName?: string;
serviceType?: string;
clusterIp?: string;
externalIps?: string[];
servicePorts?: Array<{
name?: string;
protocol?: string;
port?: number;
targetPort?: string;
nodePort?: number;
}>;
selector?: Record<string, string>;
succeeded?: number;
failed?: number;
active?: number;
schedule?: string;
suspend?: boolean;
lastScheduleTime?: string;
lastSuccessfulTime?: string;
startTime?: string;
completionTime?: string;
className?: string;
hosts?: string[];
addresses?: string[];
phase?: string;
storageClass?: string;
capacityBytes?: number;
requestedBytes?: number;
accessModes?: string[];
reclaimPolicy?: string;
claimNamespace?: string;
claimName?: string;
volumeName?: string;
eventType?: string;
reason?: string;
message?: string;
involvedKind?: string;
involvedName?: string;
count?: number;
firstSeen?: string;
eventTime?: string;
createdAt?: string;
// Cluster-only fields surfaced on the Kubernetes platform-page
// Clusters table for at-a-glance fleet posture.
version?: string;
@@ -12,6 +12,20 @@ describe('canonicalResourceTypes', () => {
expect(CANONICAL_RESOURCE_TYPES).toContain('physical_disk');
expect(CANONICAL_RESOURCE_TYPES).toContain('ceph');
expect(CANONICAL_RESOURCE_TYPES).toContain('network-endpoint');
expect(CANONICAL_RESOURCE_TYPES).toContain('docker-image');
expect(CANONICAL_RESOURCE_TYPES).toContain('docker-volume');
expect(CANONICAL_RESOURCE_TYPES).toContain('docker-network');
expect(CANONICAL_RESOURCE_TYPES).toContain('docker-task');
expect(CANONICAL_RESOURCE_TYPES).toContain('k8s-namespace');
expect(CANONICAL_RESOURCE_TYPES).toContain('k8s-service');
expect(CANONICAL_RESOURCE_TYPES).toContain('k8s-statefulset');
expect(CANONICAL_RESOURCE_TYPES).toContain('k8s-daemonset');
expect(CANONICAL_RESOURCE_TYPES).toContain('k8s-job');
expect(CANONICAL_RESOURCE_TYPES).toContain('k8s-cronjob');
expect(CANONICAL_RESOURCE_TYPES).toContain('k8s-ingress');
expect(CANONICAL_RESOURCE_TYPES).toContain('k8s-persistent-volume');
expect(CANONICAL_RESOURCE_TYPES).toContain('k8s-persistent-volume-claim');
expect(CANONICAL_RESOURCE_TYPES).toContain('k8s-event');
});
it('normalizes manual input consistently', () => {
@@ -23,6 +37,8 @@ describe('canonicalResourceTypes', () => {
expect(isCanonicalResourceType('vm')).toBe(true);
expect(isCanonicalResourceType('physical_disk')).toBe(true);
expect(isCanonicalResourceType('network-endpoint')).toBe(true);
expect(isCanonicalResourceType('docker-image')).toBe(true);
expect(isCanonicalResourceType('k8s-event')).toBe(true);
expect(isCanonicalResourceType('host')).toBe(false);
expect(isCanonicalResourceType('lxc')).toBe(false);
});
@@ -17,6 +17,29 @@ describe('reportableResourceTypes', () => {
expect(REPORTABLE_RESOURCE_TYPES.has('truenas' as any)).toBe(false);
});
it('keeps native platform inventory rows out of the metric reporting picker', () => {
const inventoryOnlyTypes = [
'docker-image',
'docker-volume',
'docker-network',
'docker-task',
'k8s-namespace',
'k8s-service',
'k8s-statefulset',
'k8s-daemonset',
'k8s-job',
'k8s-cronjob',
'k8s-ingress',
'k8s-persistent-volume',
'k8s-persistent-volume-claim',
'k8s-event',
] as const;
for (const type of inventoryOnlyTypes) {
expect(REPORTABLE_RESOURCE_TYPES.has(type)).toBe(false);
}
});
it('normalizes resource types for picker ordering', () => {
expect(normalizeReportableResourceType('k8s-cluster')).toBe('k8s');
expect(normalizeReportableResourceType('oci-container')).toBe('system-container');
@@ -8,6 +8,11 @@ describe('toReportingResourceType', () => {
expect(toReportingResourceType('system-container')).toBe('system-container');
expect(toReportingResourceType('app-container')).toBe('app-container');
expect(toReportingResourceType('docker-host')).toBe('docker-host');
expect(toReportingResourceType('docker-service')).toBe('app-container');
expect(toReportingResourceType('docker-image')).toBe('app-container');
expect(toReportingResourceType('docker-volume')).toBe('storage');
expect(toReportingResourceType('docker-network')).toBe('network');
expect(toReportingResourceType('docker-task')).toBe('app-container');
expect(toReportingResourceType('network-endpoint')).toBe('network-endpoint');
expect(toReportingResourceType('network-share')).toBe('network-share');
expect(toReportingResourceType('storage')).toBe('storage');
@@ -17,5 +22,15 @@ describe('toReportingResourceType', () => {
expect(toReportingResourceType('k8s-cluster')).toBe('k8s');
expect(toReportingResourceType('k8s-node')).toBe('k8s');
expect(toReportingResourceType('pod')).toBe('k8s');
expect(toReportingResourceType('k8s-namespace')).toBe('k8s');
expect(toReportingResourceType('k8s-service')).toBe('k8s');
expect(toReportingResourceType('k8s-statefulset')).toBe('k8s');
expect(toReportingResourceType('k8s-daemonset')).toBe('k8s');
expect(toReportingResourceType('k8s-job')).toBe('k8s');
expect(toReportingResourceType('k8s-cronjob')).toBe('k8s');
expect(toReportingResourceType('k8s-ingress')).toBe('k8s');
expect(toReportingResourceType('k8s-persistent-volume')).toBe('k8s');
expect(toReportingResourceType('k8s-persistent-volume-claim')).toBe('k8s');
expect(toReportingResourceType('k8s-event')).toBe('k8s');
});
});
@@ -16,6 +16,24 @@ describe('resourceTypeCompat', () => {
expect(canonicalizeFrontendResourceType('network_endpoint')).toBe('network-endpoint');
expect(canonicalizeFrontendResourceType('network-share')).toBe('network-share');
expect(canonicalizeFrontendResourceType('network')).toBe('network');
expect(canonicalizeFrontendResourceType('docker-image')).toBe('docker-image');
expect(canonicalizeFrontendResourceType('docker-volume')).toBe('docker-volume');
expect(canonicalizeFrontendResourceType('docker-network')).toBe('docker-network');
expect(canonicalizeFrontendResourceType('docker-task')).toBe('docker-task');
expect(canonicalizeFrontendResourceType('k8s-namespace')).toBe('k8s-namespace');
expect(canonicalizeFrontendResourceType('k8s-service')).toBe('k8s-service');
expect(canonicalizeFrontendResourceType('k8s-statefulset')).toBe('k8s-statefulset');
expect(canonicalizeFrontendResourceType('k8s-daemonset')).toBe('k8s-daemonset');
expect(canonicalizeFrontendResourceType('k8s-job')).toBe('k8s-job');
expect(canonicalizeFrontendResourceType('k8s-cronjob')).toBe('k8s-cronjob');
expect(canonicalizeFrontendResourceType('k8s-ingress')).toBe('k8s-ingress');
expect(canonicalizeFrontendResourceType('k8s-persistent-volume')).toBe(
'k8s-persistent-volume',
);
expect(canonicalizeFrontendResourceType('k8s-persistent-volume-claim')).toBe(
'k8s-persistent-volume-claim',
);
expect(canonicalizeFrontendResourceType('k8s-event')).toBe('k8s-event');
});
it('does not silently canonicalize removed non-canonical workload aliases', () => {
@@ -11,6 +11,15 @@ describe('resourceTypePresentation', () => {
expect(getResourceTypeLabel('network-endpoint')).toBe('Network Endpoint');
expect(getResourceTypeLabel('network-share')).toBe('Network Share');
expect(getResourceTypeLabel('truenas')).toBe('Agent');
expect(getResourceTypeLabel('docker-image')).toBe('Image');
expect(getResourceTypeLabel('docker-volume')).toBe('Volume');
expect(getResourceTypeLabel('docker-network')).toBe('Network');
expect(getResourceTypeLabel('docker-task')).toBe('Swarm Task');
expect(getResourceTypeLabel('k8s-service')).toBe('K8s Service');
expect(getResourceTypeLabel('k8s-ingress')).toBe('Ingress');
expect(getResourceTypeLabel('k8s-persistent-volume')).toBe('PV');
expect(getResourceTypeLabel('k8s-persistent-volume-claim')).toBe('PVC');
expect(getResourceTypeLabel('k8s-event')).toBe('Event');
});
it('returns shared presentations for external recovery subject aliases', () => {
@@ -12,8 +12,21 @@ export const CANONICAL_RESOURCE_TYPES = [
'pod',
'jail',
'docker-service',
'docker-image',
'docker-volume',
'docker-network',
'docker-task',
'k8s-deployment',
'k8s-service',
'k8s-namespace',
'k8s-statefulset',
'k8s-daemonset',
'k8s-job',
'k8s-cronjob',
'k8s-ingress',
'k8s-persistent-volume',
'k8s-persistent-volume-claim',
'k8s-event',
'storage',
'datastore',
'pool',
@@ -1,11 +1,11 @@
// This file is generated by scripts/release_control/generate_platform_support_frontend_module.py.
// Do not edit by hand.
// Source: docs/release-control/v6/internal/PLATFORM_SUPPORT_MANIFEST.json
// Source SHA256: 8d290e9e9f71ae0f62de8881b21c6f3350da976229538dd5da87213674ba673a
// Source SHA256: 62c00f727e911db33797de5e5f4d248f12f3dfb7b07a2087c7108679197823e5
export const PLATFORM_SUPPORT_MANIFEST_SOURCE = {
path: 'docs/release-control/v6/internal/PLATFORM_SUPPORT_MANIFEST.json',
sha256: '8d290e9e9f71ae0f62de8881b21c6f3350da976229538dd5da87213674ba673a',
sha256: '62c00f727e911db33797de5e5f4d248f12f3dfb7b07a2087c7108679197823e5',
} as const;
export const PLATFORM_SUPPORT_MANIFEST = {
schemaVersion: 2,
@@ -74,12 +74,20 @@ export const PLATFORM_SUPPORT_MANIFEST = {
readinessStage: 'supported',
primaryMode: 'agent-backed',
onboardingPaths: ['install-workspace'],
canonicalProjections: ['agent', 'app-container', 'docker-service'],
canonicalProjections: [
'agent',
'app-container',
'docker-image',
'docker-volume',
'docker-network',
'docker-service',
'docker-task',
],
supportFloor: {
setup: 'supported',
visibility: 'supported',
workloads: 'supported',
storage: 'n/a',
storage: 'supported',
recovery: 'n/a',
alerts: 'supported',
assistantRead: 'supported',
@@ -99,12 +107,27 @@ export const PLATFORM_SUPPORT_MANIFEST = {
readinessStage: 'supported',
primaryMode: 'agent-backed',
onboardingPaths: ['install-workspace'],
canonicalProjections: ['k8s-cluster', 'k8s-node', 'pod', 'k8s-deployment'],
canonicalProjections: [
'k8s-cluster',
'k8s-node',
'pod',
'k8s-deployment',
'k8s-statefulset',
'k8s-daemonset',
'k8s-service',
'k8s-job',
'k8s-cronjob',
'k8s-ingress',
'k8s-persistent-volume',
'k8s-persistent-volume-claim',
'k8s-namespace',
'k8s-event',
],
supportFloor: {
setup: 'supported',
visibility: 'supported',
workloads: 'supported',
storage: 'n/a',
storage: 'supported',
recovery: 'supported',
alerts: 'supported',
assistantRead: 'supported',
@@ -625,8 +648,31 @@ export const SOURCE_PLATFORM_PRIMARY_MODE = {
} as const;
export const SOURCE_PLATFORM_CANONICAL_PROJECTIONS = {
agent: ['agent', 'storage', 'physical-disk'],
docker: ['agent', 'app-container', 'docker-service'],
kubernetes: ['k8s-cluster', 'k8s-node', 'pod', 'k8s-deployment'],
docker: [
'agent',
'app-container',
'docker-image',
'docker-volume',
'docker-network',
'docker-service',
'docker-task',
],
kubernetes: [
'k8s-cluster',
'k8s-node',
'pod',
'k8s-deployment',
'k8s-statefulset',
'k8s-daemonset',
'k8s-service',
'k8s-job',
'k8s-cronjob',
'k8s-ingress',
'k8s-persistent-volume',
'k8s-persistent-volume-claim',
'k8s-namespace',
'k8s-event',
],
'proxmox-pve': ['agent', 'vm', 'system-container', 'storage', 'ceph', 'physical-disk'],
'proxmox-pbs': ['pbs', 'storage'],
'proxmox-pmg': ['pmg'],
@@ -654,7 +700,7 @@ export const SOURCE_PLATFORM_SUPPORT_FLOOR = {
setup: 'supported',
visibility: 'supported',
workloads: 'supported',
storage: 'n/a',
storage: 'supported',
recovery: 'n/a',
alerts: 'supported',
assistantRead: 'supported',
@@ -664,7 +710,7 @@ export const SOURCE_PLATFORM_SUPPORT_FLOOR = {
setup: 'supported',
visibility: 'supported',
workloads: 'supported',
storage: 'n/a',
storage: 'supported',
recovery: 'supported',
alerts: 'supported',
assistantRead: 'supported',
@@ -25,12 +25,27 @@ export function toReportingResourceType(resourceType: ResourceType): ReportingRe
case 'jail':
return 'system-container';
case 'docker-service':
case 'docker-image':
case 'docker-task':
return 'app-container';
case 'docker-volume':
return 'storage';
case 'docker-network':
return 'network';
case 'k8s-cluster':
case 'k8s-node':
case 'pod':
case 'k8s-deployment':
case 'k8s-service':
case 'k8s-namespace':
case 'k8s-statefulset':
case 'k8s-daemonset':
case 'k8s-job':
case 'k8s-cronjob':
case 'k8s-ingress':
case 'k8s-persistent-volume':
case 'k8s-persistent-volume-claim':
case 'k8s-event':
return 'k8s';
case 'physical_disk':
return 'disk';
@@ -12,15 +12,36 @@ const collectSearchCandidates = (resource: Resource): string[] => {
resource.canonicalIdentity?.hostname,
resource.canonicalIdentity?.primaryId,
resource.docker?.hostname,
resource.docker?.image,
resource.docker?.imageId,
resource.docker?.volumeName,
resource.docker?.networkId,
resource.docker?.driver,
resource.docker?.serviceName,
resource.docker?.taskId,
resource.kubernetes?.clusterName,
resource.kubernetes?.nodeName,
resource.kubernetes?.namespace,
resource.kubernetes?.resourceKind,
resource.kubernetes?.serviceType,
resource.kubernetes?.clusterIp,
resource.kubernetes?.storageClass,
resource.kubernetes?.phase,
resource.kubernetes?.reason,
resource.kubernetes?.involvedName,
resource.kubernetes?.volumeName,
resource.vmware?.runtimeHostName,
resource.vmware?.clusterName,
resource.vmware?.datacenterName,
resource.proxmox?.node,
resource.proxmox?.nodeName,
resource.proxmox?.clusterName,
...(resource.docker?.repoTags ?? []),
...(resource.docker?.repoDigests ?? []),
...(resource.kubernetes?.externalIps ?? []),
...(resource.kubernetes?.hosts ?? []),
...(resource.kubernetes?.addresses ?? []),
...(resource.kubernetes?.accessModes ?? []),
...(resource.canonicalIdentity?.aliases ?? []),
...(resource.tags ?? []),
];
@@ -30,7 +51,10 @@ const collectSearchCandidates = (resource: Resource): string[] => {
.filter((value) => value.length > 0);
};
export function resourceMatchesSearch(resource: Resource, term: string | null | undefined): boolean {
export function resourceMatchesSearch(
resource: Resource,
term: string | null | undefined,
): boolean {
const needle = (term ?? '').trim().toLowerCase();
if (!needle) return true;
return collectSearchCandidates(resource).some((value) => value.includes(needle));
@@ -9,6 +9,10 @@ export type CanonicalFrontendResourceType =
| 'storage'
| 'disk'
| 'docker-host'
| 'docker-image'
| 'docker-volume'
| 'docker-network'
| 'docker-task'
| 'network'
| 'network-share'
| 'pbs'
@@ -17,6 +21,15 @@ export type CanonicalFrontendResourceType =
| 'k8s-cluster'
| 'k8s-deployment'
| 'k8s-service'
| 'k8s-namespace'
| 'k8s-statefulset'
| 'k8s-daemonset'
| 'k8s-job'
| 'k8s-cronjob'
| 'k8s-ingress'
| 'k8s-persistent-volume'
| 'k8s-persistent-volume-claim'
| 'k8s-event'
| 'network-endpoint';
const asNormalizedString = (value: unknown): string | undefined => {
@@ -68,6 +81,10 @@ export const canonicalizeFrontendResourceType = (
case 'storage':
case 'disk':
case 'docker-host':
case 'docker-image':
case 'docker-volume':
case 'docker-network':
case 'docker-task':
case 'network':
case 'network-share':
case 'pbs':
@@ -76,6 +93,15 @@ export const canonicalizeFrontendResourceType = (
case 'k8s-cluster':
case 'k8s-deployment':
case 'k8s-service':
case 'k8s-namespace':
case 'k8s-statefulset':
case 'k8s-daemonset':
case 'k8s-job':
case 'k8s-cronjob':
case 'k8s-ingress':
case 'k8s-persistent-volume':
case 'k8s-persistent-volume-claim':
case 'k8s-event':
case 'network-endpoint':
return normalized;
default:
@@ -54,6 +54,22 @@ const RESOURCE_TYPE_PRESENTATION: Partial<Record<ResourceType | string, Resource
label: 'Docker Service',
badgeClasses: DEFAULT_BADGE_CLASSES,
},
'docker-image': {
label: 'Image',
badgeClasses: DEFAULT_BADGE_CLASSES,
},
'docker-volume': {
label: 'Volume',
badgeClasses: DEFAULT_BADGE_CLASSES,
},
'docker-network': {
label: 'Network',
badgeClasses: DEFAULT_BADGE_CLASSES,
},
'docker-task': {
label: 'Swarm Task',
badgeClasses: DEFAULT_BADGE_CLASSES,
},
'k8s-deployment': {
label: 'K8s Deployment',
badgeClasses: DEFAULT_BADGE_CLASSES,
@@ -62,6 +78,42 @@ const RESOURCE_TYPE_PRESENTATION: Partial<Record<ResourceType | string, Resource
label: 'K8s Service',
badgeClasses: DEFAULT_BADGE_CLASSES,
},
'k8s-namespace': {
label: 'Namespace',
badgeClasses: DEFAULT_BADGE_CLASSES,
},
'k8s-statefulset': {
label: 'StatefulSet',
badgeClasses: DEFAULT_BADGE_CLASSES,
},
'k8s-daemonset': {
label: 'DaemonSet',
badgeClasses: DEFAULT_BADGE_CLASSES,
},
'k8s-job': {
label: 'Job',
badgeClasses: DEFAULT_BADGE_CLASSES,
},
'k8s-cronjob': {
label: 'CronJob',
badgeClasses: DEFAULT_BADGE_CLASSES,
},
'k8s-ingress': {
label: 'Ingress',
badgeClasses: DEFAULT_BADGE_CLASSES,
},
'k8s-persistent-volume': {
label: 'PV',
badgeClasses: DEFAULT_BADGE_CLASSES,
},
'k8s-persistent-volume-claim': {
label: 'PVC',
badgeClasses: DEFAULT_BADGE_CLASSES,
},
'k8s-event': {
label: 'Event',
badgeClasses: DEFAULT_BADGE_CLASSES,
},
storage: {
label: 'Storage',
badgeClasses: DEFAULT_BADGE_CLASSES,
+45
View File
@@ -11069,6 +11069,51 @@ func TestContract_AlertResourceTypeConsistency(t *testing.T) {
}
}
func TestContractResourceFiltersAcceptNativeDockerAndKubernetesInventory(t *testing.T) {
raw := strings.Join([]string{
"docker-image",
"docker-volume",
"docker-network",
"docker-task",
"k8s-namespace",
"k8s-service",
"k8s-statefulset",
"k8s-daemonset",
"k8s-job",
"k8s-cronjob",
"k8s-ingress",
"k8s-persistent-volume",
"k8s-persistent-volume-claim",
"k8s-event",
}, ",")
if unsupported := unsupportedResourceTypeFilterTokens(raw); len(unsupported) != 0 {
t.Fatalf("native platform inventory filters should be supported, got unsupported=%v", unsupported)
}
got := parseResourceTypes(raw)
for _, resourceType := range []unifiedresources.ResourceType{
unifiedresources.ResourceTypeDockerImage,
unifiedresources.ResourceTypeDockerVolume,
unifiedresources.ResourceTypeDockerNetwork,
unifiedresources.ResourceTypeDockerTask,
unifiedresources.ResourceTypeK8sNamespace,
unifiedresources.ResourceTypeK8sService,
unifiedresources.ResourceTypeK8sStatefulSet,
unifiedresources.ResourceTypeK8sDaemonSet,
unifiedresources.ResourceTypeK8sJob,
unifiedresources.ResourceTypeK8sCronJob,
unifiedresources.ResourceTypeK8sIngress,
unifiedresources.ResourceTypeK8sPV,
unifiedresources.ResourceTypeK8sPVC,
unifiedresources.ResourceTypeK8sEvent,
} {
if _, ok := got[resourceType]; !ok {
t.Fatalf("parseResourceTypes(%q) missing %q in %#v", raw, resourceType, got)
}
}
}
func TestContract_TenantResourcesDoNotFallbackToRawSnapshotSeeding(t *testing.T) {
now := time.Date(2026, 3, 17, 9, 0, 0, 0, time.UTC)
h := NewResourceHandlers(&config.Config{DataPath: t.TempDir()})
+38 -25
View File
@@ -29,31 +29,44 @@ const (
var organizationIDPattern = regexp.MustCompile(`^[A-Za-z0-9._-]{1,64}$`)
var supportedOrganizationShareResourceTypes = map[string]struct{}{
"agent": {},
"node": {},
"docker-host": {},
"k8s-cluster": {},
"k8s-node": {},
"truenas": {},
"vm": {},
"system-container": {},
"app-container": {},
"oci-container": {},
"pod": {},
"jail": {},
"docker-service": {},
"k8s-deployment": {},
"k8s-service": {},
"storage": {},
"datastore": {},
"pool": {},
"dataset": {},
"pbs": {},
"pmg": {},
"physical_disk": {},
"network-share": {},
"ceph": {},
"view": {},
"agent": {},
"node": {},
"docker-host": {},
"k8s-cluster": {},
"k8s-node": {},
"truenas": {},
"vm": {},
"system-container": {},
"app-container": {},
"oci-container": {},
"pod": {},
"jail": {},
"docker-service": {},
"docker-image": {},
"docker-volume": {},
"docker-network": {},
"docker-task": {},
"k8s-deployment": {},
"k8s-service": {},
"k8s-namespace": {},
"k8s-statefulset": {},
"k8s-daemonset": {},
"k8s-job": {},
"k8s-cronjob": {},
"k8s-ingress": {},
"k8s-persistent-volume": {},
"k8s-persistent-volume-claim": {},
"k8s-event": {},
"storage": {},
"datastore": {},
"pool": {},
"dataset": {},
"pbs": {},
"pmg": {},
"physical_disk": {},
"network-share": {},
"ceph": {},
"view": {},
}
type OrgHandlers struct {
+14
View File
@@ -1236,6 +1236,20 @@ func TestIsUnsupportedOrganizationShareResourceType(t *testing.T) {
{name: "mixed case host unsupported", in: " HoSt ", want: true},
{name: "agent supported", in: "agent", want: false},
{name: "docker host supported", in: "docker-host", want: false},
{name: "docker image supported", in: "docker-image", want: false},
{name: "docker volume supported", in: "docker-volume", want: false},
{name: "docker network supported", in: "docker-network", want: false},
{name: "docker task supported", in: "docker-task", want: false},
{name: "kubernetes namespace supported", in: "k8s-namespace", want: false},
{name: "kubernetes service supported", in: "k8s-service", want: false},
{name: "kubernetes statefulset supported", in: "k8s-statefulset", want: false},
{name: "kubernetes daemonset supported", in: "k8s-daemonset", want: false},
{name: "kubernetes job supported", in: "k8s-job", want: false},
{name: "kubernetes cronjob supported", in: "k8s-cronjob", want: false},
{name: "kubernetes ingress supported", in: "k8s-ingress", want: false},
{name: "kubernetes persistent volume supported", in: "k8s-persistent-volume", want: false},
{name: "kubernetes persistent volume claim supported", in: "k8s-persistent-volume-claim", want: false},
{name: "kubernetes event supported", in: "k8s-event", want: false},
{name: "network share supported", in: "network-share", want: false},
}
+28
View File
@@ -1857,6 +1857,14 @@ func resourceTypeFilterAdapter(token string) []unified.ResourceType {
return []unified.ResourceType{unified.ResourceTypeAgent}
case "docker-host":
return []unified.ResourceType{"docker-host"}
case "docker-image", "docker-images":
return []unified.ResourceType{unified.ResourceTypeDockerImage}
case "docker-volume", "docker-volumes":
return []unified.ResourceType{unified.ResourceTypeDockerVolume}
case "docker-network", "docker-networks":
return []unified.ResourceType{unified.ResourceTypeDockerNetwork}
case "docker-task", "docker-tasks":
return []unified.ResourceType{unified.ResourceTypeDockerTask}
case "vm", "vms":
return []unified.ResourceType{unified.ResourceTypeVM}
case "system-container", "system-containers", "oci-container":
@@ -1873,6 +1881,26 @@ func resourceTypeFilterAdapter(token string) []unified.ResourceType {
return []unified.ResourceType{unified.ResourceTypeK8sNode}
case "k8s-deployment", "k8s-deployments":
return []unified.ResourceType{unified.ResourceTypeK8sDeployment}
case "k8s-namespace", "k8s-namespaces":
return []unified.ResourceType{unified.ResourceTypeK8sNamespace}
case "k8s-service", "k8s-services":
return []unified.ResourceType{unified.ResourceTypeK8sService}
case "k8s-statefulset", "k8s-statefulsets":
return []unified.ResourceType{unified.ResourceTypeK8sStatefulSet}
case "k8s-daemonset", "k8s-daemonsets":
return []unified.ResourceType{unified.ResourceTypeK8sDaemonSet}
case "k8s-job", "k8s-jobs":
return []unified.ResourceType{unified.ResourceTypeK8sJob}
case "k8s-cronjob", "k8s-cronjobs":
return []unified.ResourceType{unified.ResourceTypeK8sCronJob}
case "k8s-ingress", "k8s-ingresses":
return []unified.ResourceType{unified.ResourceTypeK8sIngress}
case "k8s-persistent-volume", "k8s-persistent-volumes":
return []unified.ResourceType{unified.ResourceTypeK8sPV}
case "k8s-persistent-volume-claim", "k8s-persistent-volume-claims":
return []unified.ResourceType{unified.ResourceTypeK8sPVC}
case "k8s-event", "k8s-events":
return []unified.ResourceType{unified.ResourceTypeK8sEvent}
case "storage":
return []unified.ResourceType{unified.ResourceTypeStorage}
case "network", "networks":
@@ -186,6 +186,20 @@ func TestParseResourceTypesNodeAlias(t *testing.T) {
{name: "k8s cluster", input: "k8s-cluster", want: map[unified.ResourceType]struct{}{unified.ResourceTypeK8sCluster: {}}},
{name: "k8s node", input: "k8s-node", want: map[unified.ResourceType]struct{}{unified.ResourceTypeK8sNode: {}}},
{name: "k8s deployment", input: "k8s-deployment", want: map[unified.ResourceType]struct{}{unified.ResourceTypeK8sDeployment: {}}},
{name: "docker image", input: "docker-image", want: map[unified.ResourceType]struct{}{unified.ResourceTypeDockerImage: {}}},
{name: "docker volume", input: "docker-volume", want: map[unified.ResourceType]struct{}{unified.ResourceTypeDockerVolume: {}}},
{name: "docker network", input: "docker-network", want: map[unified.ResourceType]struct{}{unified.ResourceTypeDockerNetwork: {}}},
{name: "docker task", input: "docker-task", want: map[unified.ResourceType]struct{}{unified.ResourceTypeDockerTask: {}}},
{name: "k8s namespace", input: "k8s-namespace", want: map[unified.ResourceType]struct{}{unified.ResourceTypeK8sNamespace: {}}},
{name: "k8s service", input: "k8s-service", want: map[unified.ResourceType]struct{}{unified.ResourceTypeK8sService: {}}},
{name: "k8s statefulset", input: "k8s-statefulset", want: map[unified.ResourceType]struct{}{unified.ResourceTypeK8sStatefulSet: {}}},
{name: "k8s daemonset", input: "k8s-daemonset", want: map[unified.ResourceType]struct{}{unified.ResourceTypeK8sDaemonSet: {}}},
{name: "k8s job", input: "k8s-job", want: map[unified.ResourceType]struct{}{unified.ResourceTypeK8sJob: {}}},
{name: "k8s cronjob", input: "k8s-cronjob", want: map[unified.ResourceType]struct{}{unified.ResourceTypeK8sCronJob: {}}},
{name: "k8s ingress", input: "k8s-ingress", want: map[unified.ResourceType]struct{}{unified.ResourceTypeK8sIngress: {}}},
{name: "k8s persistent volume", input: "k8s-persistent-volume", want: map[unified.ResourceType]struct{}{unified.ResourceTypeK8sPV: {}}},
{name: "k8s persistent volume claim", input: "k8s-persistent-volume-claim", want: map[unified.ResourceType]struct{}{unified.ResourceTypeK8sPVC: {}}},
{name: "k8s event", input: "k8s-event", want: map[unified.ResourceType]struct{}{unified.ResourceTypeK8sEvent: {}}},
{name: "unsupported k8s umbrella ignored by parser", input: "k8s", want: map[unified.ResourceType]struct{}{}},
{name: "unsupported kubernetes umbrella ignored by parser", input: "kubernetes", want: map[unified.ResourceType]struct{}{}},
{name: "unsupported k8s-pod ignored by parser", input: "k8s-pod", want: map[unified.ResourceType]struct{}{}},
+104
View File
@@ -11,7 +11,11 @@ import (
"time"
containertypes "github.com/moby/moby/api/types/container"
imagetypes "github.com/moby/moby/api/types/image"
networktypes "github.com/moby/moby/api/types/network"
systemtypes "github.com/moby/moby/api/types/system"
volumetypes "github.com/moby/moby/api/types/volume"
dockerclient "github.com/moby/moby/client"
"github.com/rs/zerolog"
)
@@ -30,6 +34,106 @@ func TestBuildHostSecurityInfoAuthorizationPlugins(t *testing.T) {
}
}
func TestCollectDockerNativeInventory(t *testing.T) {
createdAt := time.Date(2026, 5, 24, 8, 0, 0, 0, time.UTC)
agent := &Agent{
logger: zerolog.Nop(),
docker: &fakeDockerClient{
imageListFn: func(context.Context, dockerImageListOptions) ([]imagetypes.Summary, error) {
return []imagetypes.Summary{{
ID: " sha256:image1 ",
RepoTags: []string{"repo/app:latest", " "},
RepoDigests: []string{"repo/app@sha256:abc"},
Size: 1024,
SharedSize: 256,
Containers: 2,
Created: createdAt.Unix(),
Labels: map[string]string{"tier": "web"},
}}, nil
},
volumeListFn: func(context.Context, dockerVolumeListOptions) ([]volumetypes.Volume, error) {
return []volumetypes.Volume{{
Name: " app-data ",
Driver: " local ",
Mountpoint: "/var/lib/docker/volumes/app-data",
Scope: " local ",
Labels: map[string]string{"backup": "true"},
}}, nil
},
networkListFn: func(context.Context, dockerNetworkListOptions) ([]networktypes.Summary, error) {
return []networktypes.Summary{{
Network: networktypes.Network{
ID: " net1 ",
Name: " app-net ",
Driver: " bridge ",
Scope: " local ",
EnableIPv4: true,
Attachable: true,
IPAM: networktypes.IPAM{Config: []networktypes.IPAMConfig{{
Subnet: netip.MustParsePrefix("10.88.0.0/24"),
Gateway: netip.MustParseAddr("10.88.0.1"),
}}},
Labels: map[string]string{"env": "prod"},
Options: map[string]string{"mtu": "1500"},
},
}}, nil
},
diskUsageFn: func(context.Context, dockerDiskUsageOptions) (dockerclient.DiskUsageResult, error) {
return dockerclient.DiskUsageResult{
Images: dockerclient.ImagesDiskUsage{
TotalCount: 3, ActiveCount: 2, TotalSize: 4096, Reclaimable: 512,
},
Volumes: dockerclient.VolumesDiskUsage{
TotalCount: 1,
Items: []volumetypes.Volume{{
Name: "app-data",
UsageData: &volumetypes.UsageData{Size: 2048, RefCount: 4},
}},
},
}, nil
},
},
}
images, err := agent.collectImages(context.Background())
if err != nil {
t.Fatalf("collectImages: %v", err)
}
if len(images) != 1 || images[0].ID != "sha256:image1" || images[0].CreatedAt != createdAt {
t.Fatalf("unexpected images: %+v", images)
}
if len(images[0].RepoTags) != 1 || images[0].RepoTags[0] != "repo/app:latest" {
t.Fatalf("expected normalized repo tags, got %#v", images[0].RepoTags)
}
usageResult, storageUsage, err := agent.collectStorageUsage(context.Background())
if err != nil {
t.Fatalf("collectStorageUsage: %v", err)
}
if storageUsage.Images.TotalCount != 3 || storageUsage.Images.ReclaimableBytes != 512 {
t.Fatalf("unexpected storage usage: %+v", storageUsage)
}
volumes, err := agent.collectVolumes(context.Background(), usageResult.Volumes.Items)
if err != nil {
t.Fatalf("collectVolumes: %v", err)
}
if len(volumes) != 1 || volumes[0].Name != "app-data" || volumes[0].SizeBytes != 2048 || volumes[0].RefCount != 4 {
t.Fatalf("unexpected volumes: %+v", volumes)
}
networks, err := agent.collectNetworks(context.Background())
if err != nil {
t.Fatalf("collectNetworks: %v", err)
}
if len(networks) != 1 || networks[0].Name != "app-net" || !networks[0].EnableIPv4 || !networks[0].Attachable {
t.Fatalf("unexpected networks: %+v", networks)
}
if len(networks[0].Subnets) != 1 || networks[0].Subnets[0].Subnet != "10.88.0.0/24" || networks[0].Subnets[0].Gateway != "10.88.0.1" {
t.Fatalf("unexpected network subnets: %+v", networks[0].Subnets)
}
}
func TestCollectContainer(t *testing.T) {
logger := zerolog.Nop()
+204
View File
@@ -14,7 +14,11 @@ import (
"time"
containertypes "github.com/moby/moby/api/types/container"
"github.com/moby/moby/api/types/image"
networktypes "github.com/moby/moby/api/types/network"
systemtypes "github.com/moby/moby/api/types/system"
"github.com/moby/moby/api/types/volume"
"github.com/moby/moby/client"
agentsdocker "github.com/rcourtman/pulse-go-rewrite/pkg/agents/docker"
)
@@ -116,6 +120,22 @@ func (a *Agent) buildReport(ctx context.Context) (agentsdocker.Report, error) {
}
services, tasks, swarmInfo := a.collectSwarmData(ctx, info, containers)
diskUsageResult, storageUsage, err := a.collectStorageUsage(ctx)
if err != nil {
a.logger.Warn().Err(err).Msg("failed to collect Docker storage usage")
}
images, err := a.collectImages(ctx)
if err != nil {
a.logger.Warn().Err(err).Msg("failed to collect Docker images")
}
volumes, err := a.collectVolumes(ctx, diskUsageResult.Volumes.Items)
if err != nil {
a.logger.Warn().Err(err).Msg("failed to collect Docker volumes")
}
networks, err := a.collectNetworks(ctx)
if err != nil {
a.logger.Warn().Err(err).Msg("failed to collect Docker networks")
}
// Use Docker's MemTotal, but fall back to gopsutil's reading if Docker returns 0.
// This can happen in Docker-in-LXC setups where Docker daemon can't read host memory.
@@ -161,12 +181,24 @@ func (a *Agent) buildReport(ctx context.Context) (agentsdocker.Report, error) {
if a.cfg.IncludeContainers {
report.Containers = containers
}
if len(images) > 0 {
report.Images = images
}
if len(volumes) > 0 {
report.Volumes = volumes
}
if len(networks) > 0 {
report.Networks = networks
}
if a.cfg.IncludeServices && len(services) > 0 {
report.Services = services
}
if a.cfg.IncludeTasks && len(tasks) > 0 {
report.Tasks = tasks
}
if storageUsage != nil {
report.StorageUsage = storageUsage
}
if report.Agent.IntervalSeconds <= 0 {
report.Agent.IntervalSeconds = int(30 * time.Second / time.Second)
@@ -209,6 +241,17 @@ func normalizedNonEmptyStrings(values []string) []string {
return normalized
}
func cloneStringMap(src map[string]string) map[string]string {
if len(src) == 0 {
return nil
}
out := make(map[string]string, len(src))
for k, v := range src {
out[k] = v
}
return out
}
func (a *Agent) collectContainers(ctx context.Context) ([]agentsdocker.Container, error) {
options := dockerContainerListOptions{All: true}
if len(a.stateFilters) > 0 {
@@ -253,6 +296,167 @@ func (a *Agent) collectContainers(ctx context.Context) ([]agentsdocker.Container
return containers, nil
}
func (a *Agent) collectImages(ctx context.Context) ([]agentsdocker.Image, error) {
list, err := dockerCallWithRetry(ctx, dockerInventoryCallTimeout, func(callCtx context.Context) ([]image.Summary, error) {
return a.docker.ImageList(callCtx, dockerImageListOptions{All: true, SharedSize: true})
})
if err != nil {
return nil, annotateDockerConnectionError(err)
}
images := make([]agentsdocker.Image, 0, len(list))
for _, summary := range list {
images = append(images, agentsdocker.Image{
ID: strings.TrimSpace(summary.ID),
RepoTags: normalizedNonEmptyStrings(summary.RepoTags),
RepoDigests: normalizedNonEmptyStrings(summary.RepoDigests),
SizeBytes: summary.Size,
SharedSizeBytes: summary.SharedSize,
Containers: summary.Containers,
CreatedAt: dockerUnixTimestamp(summary.Created),
Labels: cloneStringMap(summary.Labels),
})
}
return images, nil
}
func (a *Agent) collectVolumes(ctx context.Context, usageVolumes []volume.Volume) ([]agentsdocker.Volume, error) {
usageByName := make(map[string]volume.UsageData, len(usageVolumes))
for _, volume := range usageVolumes {
name := strings.TrimSpace(volume.Name)
if name == "" || volume.UsageData == nil {
continue
}
usageByName[name] = *volume.UsageData
}
list, err := dockerCallWithRetry(ctx, dockerInventoryCallTimeout, func(callCtx context.Context) ([]volume.Volume, error) {
return a.docker.VolumeList(callCtx, dockerVolumeListOptions{})
})
if err != nil {
return nil, annotateDockerConnectionError(err)
}
volumes := make([]agentsdocker.Volume, 0, len(list))
for _, v := range list {
sizeBytes := int64(0)
refCount := int64(0)
if v.UsageData != nil {
sizeBytes = v.UsageData.Size
refCount = v.UsageData.RefCount
} else if usage, ok := usageByName[strings.TrimSpace(v.Name)]; ok {
sizeBytes = usage.Size
refCount = usage.RefCount
}
volumes = append(volumes, agentsdocker.Volume{
Name: strings.TrimSpace(v.Name),
Driver: strings.TrimSpace(v.Driver),
Mountpoint: strings.TrimSpace(v.Mountpoint),
Scope: strings.TrimSpace(v.Scope),
CreatedAt: strings.TrimSpace(v.CreatedAt),
SizeBytes: sizeBytes,
RefCount: refCount,
Labels: cloneStringMap(v.Labels),
Options: cloneStringMap(v.Options),
})
}
return volumes, nil
}
func (a *Agent) collectNetworks(ctx context.Context) ([]agentsdocker.Network, error) {
list, err := dockerCallWithRetry(ctx, dockerInventoryCallTimeout, func(callCtx context.Context) ([]networktypes.Summary, error) {
return a.docker.NetworkList(callCtx, dockerNetworkListOptions{})
})
if err != nil {
return nil, annotateDockerConnectionError(err)
}
networks := make([]agentsdocker.Network, 0, len(list))
for _, n := range list {
subnets := make([]agentsdocker.NetworkSubnet, 0, len(n.IPAM.Config))
for _, config := range n.IPAM.Config {
subnet := ""
if config.Subnet.IsValid() {
subnet = config.Subnet.String()
}
gateway := ""
if config.Gateway.IsValid() {
gateway = config.Gateway.String()
}
subnets = append(subnets, agentsdocker.NetworkSubnet{
Subnet: subnet,
Gateway: gateway,
})
}
networks = append(networks, agentsdocker.Network{
ID: strings.TrimSpace(n.ID),
Name: strings.TrimSpace(n.Name),
Driver: strings.TrimSpace(n.Driver),
Scope: strings.TrimSpace(n.Scope),
CreatedAt: n.Created,
EnableIPv4: n.EnableIPv4,
EnableIPv6: n.EnableIPv6,
Internal: n.Internal,
Attachable: n.Attachable,
Ingress: n.Ingress,
ConfigOnly: n.ConfigOnly,
Subnets: subnets,
Labels: cloneStringMap(n.Labels),
Options: cloneStringMap(n.Options),
})
}
return networks, nil
}
func (a *Agent) collectStorageUsage(ctx context.Context) (client.DiskUsageResult, *agentsdocker.StorageUsage, error) {
result, err := dockerCallWithRetry(ctx, dockerInventoryCallTimeout, func(callCtx context.Context) (client.DiskUsageResult, error) {
return a.docker.DiskUsage(callCtx, dockerDiskUsageOptions{
Containers: true,
Images: true,
Volumes: true,
BuildCache: true,
Verbose: true,
})
})
if err != nil {
return client.DiskUsageResult{}, nil, annotateDockerConnectionError(err)
}
return result, &agentsdocker.StorageUsage{
Images: agentsdocker.StorageUsageBucket{
TotalCount: result.Images.TotalCount,
ActiveCount: result.Images.ActiveCount,
TotalSizeBytes: result.Images.TotalSize,
ReclaimableBytes: result.Images.Reclaimable,
},
Containers: agentsdocker.StorageUsageBucket{
TotalCount: result.Containers.TotalCount,
ActiveCount: result.Containers.ActiveCount,
TotalSizeBytes: result.Containers.TotalSize,
ReclaimableBytes: result.Containers.Reclaimable,
},
Volumes: agentsdocker.StorageUsageBucket{
TotalCount: result.Volumes.TotalCount,
ActiveCount: result.Volumes.ActiveCount,
TotalSizeBytes: result.Volumes.TotalSize,
ReclaimableBytes: result.Volumes.Reclaimable,
},
BuildCache: agentsdocker.StorageUsageBucket{
TotalCount: result.BuildCache.TotalCount,
ActiveCount: result.BuildCache.ActiveCount,
TotalSizeBytes: result.BuildCache.TotalSize,
ReclaimableBytes: result.BuildCache.Reclaimable,
},
}, nil
}
func dockerUnixTimestamp(seconds int64) time.Time {
if seconds <= 0 {
return time.Time{}
}
return time.Unix(seconds, 0).UTC()
}
func (a *Agent) pruneStaleCPUSamples(active map[string]struct{}) {
a.cpuMu.Lock()
defer a.cpuMu.Unlock()
+65
View File
@@ -9,6 +9,7 @@ import (
"github.com/moby/moby/api/types/network"
swarmtypes "github.com/moby/moby/api/types/swarm"
systemtypes "github.com/moby/moby/api/types/system"
"github.com/moby/moby/api/types/volume"
"github.com/moby/moby/client"
v1 "github.com/opencontainers/image-spec/specs-go/v1"
)
@@ -91,6 +92,28 @@ type dockerServiceListOptions struct {
Status bool
}
type dockerImageListOptions struct {
All bool
SharedSize bool
Filters dockerFilters
}
type dockerVolumeListOptions struct {
Filters dockerFilters
}
type dockerNetworkListOptions struct {
Filters dockerFilters
}
type dockerDiskUsageOptions struct {
Containers bool
Images bool
BuildCache bool
Volumes bool
Verbose bool
}
type dockerTaskListOptions struct {
Filters dockerFilters
}
@@ -109,6 +132,10 @@ type dockerClient interface {
NetworkConnect(ctx context.Context, networkID, containerID string, config *network.EndpointSettings) error
ContainerStart(ctx context.Context, containerID string, options dockerContainerStartOptions) error
ContainerRemove(ctx context.Context, containerID string, options dockerContainerRemoveOptions) error
ImageList(ctx context.Context, options dockerImageListOptions) ([]image.Summary, error)
VolumeList(ctx context.Context, options dockerVolumeListOptions) ([]volume.Volume, error)
NetworkList(ctx context.Context, options dockerNetworkListOptions) ([]network.Summary, error)
DiskUsage(ctx context.Context, options dockerDiskUsageOptions) (client.DiskUsageResult, error)
ServiceList(ctx context.Context, options dockerServiceListOptions) ([]swarmtypes.Service, error)
TaskList(ctx context.Context, options dockerTaskListOptions) ([]swarmtypes.Task, error)
ImageInspectWithRaw(ctx context.Context, imageID string) (image.InspectResponse, []byte, error)
@@ -229,6 +256,44 @@ func (m *mobyDockerClient) ContainerRemove(ctx context.Context, containerID stri
return err
}
func (m *mobyDockerClient) ImageList(ctx context.Context, options dockerImageListOptions) ([]image.Summary, error) {
result, err := m.Client.ImageList(ctx, client.ImageListOptions{
All: options.All,
SharedSize: options.SharedSize,
Filters: options.Filters.toClientFilters(),
})
if err != nil {
return nil, err
}
return result.Items, nil
}
func (m *mobyDockerClient) VolumeList(ctx context.Context, options dockerVolumeListOptions) ([]volume.Volume, error) {
result, err := m.Client.VolumeList(ctx, client.VolumeListOptions{Filters: options.Filters.toClientFilters()})
if err != nil {
return nil, err
}
return result.Items, nil
}
func (m *mobyDockerClient) NetworkList(ctx context.Context, options dockerNetworkListOptions) ([]network.Summary, error) {
result, err := m.Client.NetworkList(ctx, client.NetworkListOptions{Filters: options.Filters.toClientFilters()})
if err != nil {
return nil, err
}
return result.Items, nil
}
func (m *mobyDockerClient) DiskUsage(ctx context.Context, options dockerDiskUsageOptions) (client.DiskUsageResult, error) {
return m.Client.DiskUsage(ctx, client.DiskUsageOptions{
Containers: options.Containers,
Images: options.Images,
BuildCache: options.BuildCache,
Volumes: options.Volumes,
Verbose: options.Verbose,
})
}
func (m *mobyDockerClient) ServiceList(ctx context.Context, options dockerServiceListOptions) ([]swarmtypes.Service, error) {
result, err := m.Client.ServiceList(ctx, client.ServiceListOptions{Status: options.Status})
if err != nil {
+1
View File
@@ -13,6 +13,7 @@ import (
const (
dockerInfoCallTimeout = 8 * time.Second
dockerContainerListCallTimeout = 20 * time.Second
dockerInventoryCallTimeout = 20 * time.Second
dockerSwarmListCallTimeout = 20 * time.Second
dockerCleanupCallTimeout = 15 * time.Second
dockerUpdateCallTimeout = 2 * time.Minute
+34
View File
@@ -13,6 +13,8 @@ import (
"github.com/moby/moby/api/types/network"
swarmtypes "github.com/moby/moby/api/types/swarm"
systemtypes "github.com/moby/moby/api/types/system"
"github.com/moby/moby/api/types/volume"
"github.com/moby/moby/client"
v1 "github.com/opencontainers/image-spec/specs-go/v1"
)
@@ -30,6 +32,10 @@ type fakeDockerClient struct {
networkConnectFn func(ctx context.Context, netName, containerID string, endpoint *network.EndpointSettings) error
containerStartFn func(ctx context.Context, id string, opts dockerContainerStartOptions) error
containerRemoveFn func(ctx context.Context, id string, opts dockerContainerRemoveOptions) error
imageListFn func(ctx context.Context, opts dockerImageListOptions) ([]image.Summary, error)
volumeListFn func(ctx context.Context, opts dockerVolumeListOptions) ([]volume.Volume, error)
networkListFn func(ctx context.Context, opts dockerNetworkListOptions) ([]network.Summary, error)
diskUsageFn func(ctx context.Context, opts dockerDiskUsageOptions) (client.DiskUsageResult, error)
serviceListFn func(ctx context.Context, opts dockerServiceListOptions) ([]swarmtypes.Service, error)
taskListFn func(ctx context.Context, opts dockerTaskListOptions) ([]swarmtypes.Task, error)
imageInspectWithRawFn func(ctx context.Context, imageID string) (image.InspectResponse, []byte, error)
@@ -124,6 +130,34 @@ func (f *fakeDockerClient) ContainerRemove(ctx context.Context, id string, opts
return f.containerRemoveFn(ctx, id, opts)
}
func (f *fakeDockerClient) ImageList(ctx context.Context, opts dockerImageListOptions) ([]image.Summary, error) {
if f.imageListFn == nil {
return nil, nil
}
return f.imageListFn(ctx, opts)
}
func (f *fakeDockerClient) VolumeList(ctx context.Context, opts dockerVolumeListOptions) ([]volume.Volume, error) {
if f.volumeListFn == nil {
return nil, nil
}
return f.volumeListFn(ctx, opts)
}
func (f *fakeDockerClient) NetworkList(ctx context.Context, opts dockerNetworkListOptions) ([]network.Summary, error) {
if f.networkListFn == nil {
return nil, nil
}
return f.networkListFn(ctx, opts)
}
func (f *fakeDockerClient) DiskUsage(ctx context.Context, opts dockerDiskUsageOptions) (client.DiskUsageResult, error) {
if f.diskUsageFn == nil {
return client.DiskUsageResult{}, nil
}
return f.diskUsageFn(ctx, opts)
}
func (f *fakeDockerClient) ServiceList(ctx context.Context, opts dockerServiceListOptions) ([]swarmtypes.Service, error) {
if f.serviceListFn == nil {
return nil, errors.New("unexpected ServiceList call")
+659 -5
View File
@@ -25,7 +25,9 @@ import (
agentsk8s "github.com/rcourtman/pulse-go-rewrite/pkg/agents/kubernetes"
"github.com/rs/zerolog"
appsv1 "k8s.io/api/apps/v1"
batchv1 "k8s.io/api/batch/v1"
corev1 "k8s.io/api/core/v1"
networkingv1 "k8s.io/api/networking/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
k8sresource "k8s.io/apimachinery/pkg/api/resource"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
@@ -101,6 +103,8 @@ const (
initialRetryBackoff = 300 * time.Millisecond
maxRetryBackoff = 3 * time.Second
maxSummaryMetricNodes = 200
maxInventoryItems = 1000
maxEventItems = 200
summaryMetricsWorkers = 8
reportUserAgent = "pulse-kubernetes-agent/"
maxMetricsResponseBodyBytes int64 = 32 * 1024 * 1024 // 32 MB
@@ -452,6 +456,47 @@ func (a *Agent) collectReport(ctx context.Context) (agentsk8s.Report, error) {
return agentsk8s.Report{}, fmt.Errorf("collect deployments: %w", err)
}
namespaces, err := a.collectNamespaces(ctx)
if err != nil {
a.logger.Debug().Err(err).Str("cluster_id", a.clusterID).Msg("kubernetes namespaces unavailable, continuing without namespace inventory")
}
services, err := a.collectServices(ctx)
if err != nil {
a.logger.Debug().Err(err).Str("cluster_id", a.clusterID).Msg("kubernetes services unavailable, continuing without service inventory")
}
statefulSets, err := a.collectStatefulSets(ctx)
if err != nil {
a.logger.Debug().Err(err).Str("cluster_id", a.clusterID).Msg("kubernetes statefulsets unavailable, continuing without statefulset inventory")
}
daemonSets, err := a.collectDaemonSets(ctx)
if err != nil {
a.logger.Debug().Err(err).Str("cluster_id", a.clusterID).Msg("kubernetes daemonsets unavailable, continuing without daemonset inventory")
}
jobs, err := a.collectJobs(ctx)
if err != nil {
a.logger.Debug().Err(err).Str("cluster_id", a.clusterID).Msg("kubernetes jobs unavailable, continuing without job inventory")
}
cronJobs, err := a.collectCronJobs(ctx)
if err != nil {
a.logger.Debug().Err(err).Str("cluster_id", a.clusterID).Msg("kubernetes cronjobs unavailable, continuing without cronjob inventory")
}
ingresses, err := a.collectIngresses(ctx)
if err != nil {
a.logger.Debug().Err(err).Str("cluster_id", a.clusterID).Msg("kubernetes ingresses unavailable, continuing without ingress inventory")
}
persistentVolumes, err := a.collectPersistentVolumes(ctx)
if err != nil {
a.logger.Debug().Err(err).Str("cluster_id", a.clusterID).Msg("kubernetes persistent volumes unavailable, continuing without persistent volume inventory")
}
persistentVolumeClaims, err := a.collectPersistentVolumeClaims(ctx)
if err != nil {
a.logger.Debug().Err(err).Str("cluster_id", a.clusterID).Msg("kubernetes persistent volume claims unavailable, continuing without persistent volume claim inventory")
}
events, err := a.collectEvents(ctx)
if err != nil {
a.logger.Debug().Err(err).Str("cluster_id", a.clusterID).Msg("kubernetes events unavailable, continuing without event inventory")
}
nodeUsage, podUsage, usageErr := a.collectUsageMetrics(ctx, nodes)
if usageErr != nil {
a.logger.Debug().Err(usageErr).Str("cluster_id", a.clusterID).Msg("kubernetes usage metrics unavailable, continuing with inventory-only report")
@@ -478,11 +523,21 @@ func (a *Agent) collectReport(ctx context.Context) (agentsk8s.Report, error) {
Context: a.clusterContext,
Version: a.clusterVersion,
},
Nodes: nodes,
Pods: pods,
Deployments: deployments,
Recovery: recoveryReport,
Timestamp: time.Now().UTC(),
Nodes: nodes,
Namespaces: namespaces,
Pods: pods,
Deployments: deployments,
StatefulSets: statefulSets,
DaemonSets: daemonSets,
Services: services,
Jobs: jobs,
CronJobs: cronJobs,
Ingresses: ingresses,
PersistentVolumes: persistentVolumes,
PersistentVolumeClaims: persistentVolumeClaims,
Events: events,
Recovery: recoveryReport,
Timestamp: time.Now().UTC(),
}, nil
}
@@ -1815,6 +1870,605 @@ func (a *Agent) collectDeployments(ctx context.Context) ([]agentsk8s.Deployment,
return items, nil
}
func (a *Agent) inventoryNamespaces() []string {
namespaces, explicit := explicitNamespaces(a.includeNamespaces)
if !explicit {
return []string{metav1.NamespaceAll}
}
return namespaces
}
func copyKubernetesStringMap(src map[string]string) map[string]string {
if len(src) == 0 {
return nil
}
out := make(map[string]string, len(src))
for k, v := range src {
out[k] = v
}
return out
}
func timePtrFromMeta(t metav1.Time) *time.Time {
if t.Time.IsZero() {
return nil
}
out := t.Time
return &out
}
func timePtrFromMicro(t metav1.MicroTime) *time.Time {
if t.Time.IsZero() {
return nil
}
out := t.Time
return &out
}
func accessModes(modes []corev1.PersistentVolumeAccessMode) []string {
if len(modes) == 0 {
return nil
}
out := make([]string, 0, len(modes))
for _, mode := range modes {
value := strings.TrimSpace(string(mode))
if value != "" {
out = append(out, value)
}
}
return out
}
func storageClassNamePtr(name *string) string {
if name == nil {
return ""
}
return strings.TrimSpace(*name)
}
func statefulSetDesiredReplicas(statefulSet appsv1.StatefulSet) int32 {
if statefulSet.Spec.Replicas == nil {
return 0
}
return *statefulSet.Spec.Replicas
}
func jobDesiredCompletions(job batchv1.Job) int32 {
if job.Spec.Completions == nil {
return 0
}
return *job.Spec.Completions
}
func cronJobSuspended(cronJob batchv1.CronJob) bool {
return cronJob.Spec.Suspend != nil && *cronJob.Spec.Suspend
}
func ingressClassName(ingress networkingv1.Ingress) string {
if ingress.Spec.IngressClassName == nil {
return ""
}
return strings.TrimSpace(*ingress.Spec.IngressClassName)
}
func ingressHosts(ingress networkingv1.Ingress) []string {
hosts := make([]string, 0, len(ingress.Spec.Rules))
seen := make(map[string]struct{}, len(ingress.Spec.Rules))
for _, rule := range ingress.Spec.Rules {
host := strings.TrimSpace(rule.Host)
if host == "" {
continue
}
if _, ok := seen[host]; ok {
continue
}
seen[host] = struct{}{}
hosts = append(hosts, host)
}
return hosts
}
func ingressAddresses(ingress networkingv1.Ingress) []string {
addresses := make([]string, 0, len(ingress.Status.LoadBalancer.Ingress))
seen := make(map[string]struct{}, len(ingress.Status.LoadBalancer.Ingress))
for _, entry := range ingress.Status.LoadBalancer.Ingress {
for _, value := range []string{entry.IP, entry.Hostname} {
value = strings.TrimSpace(value)
if value == "" {
continue
}
if _, ok := seen[value]; ok {
continue
}
seen[value] = struct{}{}
addresses = append(addresses, value)
}
}
return addresses
}
func (a *Agent) collectNamespaces(ctx context.Context) ([]agentsk8s.Namespace, error) {
items := make([]agentsk8s.Namespace, 0)
opts := metav1.ListOptions{Limit: listPageSize}
for {
var list *corev1.NamespaceList
err := a.runKubernetesCallWithRetry(ctx, "list namespaces", func(callCtx context.Context) error {
var err error
list, err = a.kubeClient.CoreV1().Namespaces().List(callCtx, opts)
return err
})
if err != nil {
return nil, err
}
for _, namespace := range list.Items {
if !a.namespaceAllowed(namespace.Name) {
continue
}
items = append(items, agentsk8s.Namespace{
UID: string(namespace.UID),
Name: namespace.Name,
Phase: string(namespace.Status.Phase),
CreatedAt: namespace.CreationTimestamp.Time,
Labels: copyKubernetesStringMap(namespace.Labels),
})
if len(items) >= maxInventoryItems {
return items, nil
}
}
if list.Continue == "" {
break
}
opts.Continue = list.Continue
}
return items, nil
}
func (a *Agent) collectServices(ctx context.Context) ([]agentsk8s.Service, error) {
items := make([]agentsk8s.Service, 0)
for _, namespace := range a.inventoryNamespaces() {
opts := metav1.ListOptions{Limit: listPageSize}
for {
var list *corev1.ServiceList
action := "list services"
if namespace != metav1.NamespaceAll {
action = fmt.Sprintf("list services in namespace %q", namespace)
}
err := a.runKubernetesCallWithRetry(ctx, action, func(callCtx context.Context) error {
var err error
list, err = a.kubeClient.CoreV1().Services(namespace).List(callCtx, opts)
return err
})
if err != nil {
return nil, err
}
for _, service := range list.Items {
if !a.namespaceAllowed(service.Namespace) {
continue
}
ports := make([]agentsk8s.ServicePort, 0, len(service.Spec.Ports))
for _, port := range service.Spec.Ports {
ports = append(ports, agentsk8s.ServicePort{
Name: strings.TrimSpace(port.Name),
Protocol: string(port.Protocol),
Port: port.Port,
TargetPort: port.TargetPort.String(),
NodePort: port.NodePort,
})
}
items = append(items, agentsk8s.Service{
UID: string(service.UID),
Name: service.Name,
Namespace: service.Namespace,
Type: string(service.Spec.Type),
ClusterIP: service.Spec.ClusterIP,
ExternalIPs: append([]string(nil), service.Spec.ExternalIPs...),
Ports: ports,
Selector: copyKubernetesStringMap(service.Spec.Selector),
CreatedAt: service.CreationTimestamp.Time,
Labels: copyKubernetesStringMap(service.Labels),
})
if len(items) >= maxInventoryItems {
return items, nil
}
}
if list.Continue == "" {
break
}
opts.Continue = list.Continue
}
}
return items, nil
}
func (a *Agent) collectStatefulSets(ctx context.Context) ([]agentsk8s.StatefulSet, error) {
items := make([]agentsk8s.StatefulSet, 0)
for _, namespace := range a.inventoryNamespaces() {
opts := metav1.ListOptions{Limit: listPageSize}
for {
var list *appsv1.StatefulSetList
action := "list statefulsets"
if namespace != metav1.NamespaceAll {
action = fmt.Sprintf("list statefulsets in namespace %q", namespace)
}
err := a.runKubernetesCallWithRetry(ctx, action, func(callCtx context.Context) error {
var err error
list, err = a.kubeClient.AppsV1().StatefulSets(namespace).List(callCtx, opts)
return err
})
if err != nil {
return nil, err
}
for _, statefulSet := range list.Items {
if !a.namespaceAllowed(statefulSet.Namespace) {
continue
}
items = append(items, agentsk8s.StatefulSet{
UID: string(statefulSet.UID),
Name: statefulSet.Name,
Namespace: statefulSet.Namespace,
DesiredReplicas: statefulSetDesiredReplicas(statefulSet),
ReadyReplicas: statefulSet.Status.ReadyReplicas,
CurrentReplicas: statefulSet.Status.CurrentReplicas,
UpdatedReplicas: statefulSet.Status.UpdatedReplicas,
AvailableReplicas: statefulSet.Status.AvailableReplicas,
ServiceName: statefulSet.Spec.ServiceName,
Labels: copyKubernetesStringMap(statefulSet.Labels),
})
if len(items) >= maxInventoryItems {
return items, nil
}
}
if list.Continue == "" {
break
}
opts.Continue = list.Continue
}
}
return items, nil
}
func (a *Agent) collectDaemonSets(ctx context.Context) ([]agentsk8s.DaemonSet, error) {
items := make([]agentsk8s.DaemonSet, 0)
for _, namespace := range a.inventoryNamespaces() {
opts := metav1.ListOptions{Limit: listPageSize}
for {
var list *appsv1.DaemonSetList
action := "list daemonsets"
if namespace != metav1.NamespaceAll {
action = fmt.Sprintf("list daemonsets in namespace %q", namespace)
}
err := a.runKubernetesCallWithRetry(ctx, action, func(callCtx context.Context) error {
var err error
list, err = a.kubeClient.AppsV1().DaemonSets(namespace).List(callCtx, opts)
return err
})
if err != nil {
return nil, err
}
for _, daemonSet := range list.Items {
if !a.namespaceAllowed(daemonSet.Namespace) {
continue
}
items = append(items, agentsk8s.DaemonSet{
UID: string(daemonSet.UID),
Name: daemonSet.Name,
Namespace: daemonSet.Namespace,
DesiredNumberScheduled: daemonSet.Status.DesiredNumberScheduled,
CurrentNumberScheduled: daemonSet.Status.CurrentNumberScheduled,
NumberReady: daemonSet.Status.NumberReady,
UpdatedNumberScheduled: daemonSet.Status.UpdatedNumberScheduled,
NumberAvailable: daemonSet.Status.NumberAvailable,
NumberUnavailable: daemonSet.Status.NumberUnavailable,
NumberMisscheduled: daemonSet.Status.NumberMisscheduled,
Labels: copyKubernetesStringMap(daemonSet.Labels),
})
if len(items) >= maxInventoryItems {
return items, nil
}
}
if list.Continue == "" {
break
}
opts.Continue = list.Continue
}
}
return items, nil
}
func (a *Agent) collectJobs(ctx context.Context) ([]agentsk8s.Job, error) {
items := make([]agentsk8s.Job, 0)
for _, namespace := range a.inventoryNamespaces() {
opts := metav1.ListOptions{Limit: listPageSize}
for {
var list *batchv1.JobList
action := "list jobs"
if namespace != metav1.NamespaceAll {
action = fmt.Sprintf("list jobs in namespace %q", namespace)
}
err := a.runKubernetesCallWithRetry(ctx, action, func(callCtx context.Context) error {
var err error
list, err = a.kubeClient.BatchV1().Jobs(namespace).List(callCtx, opts)
return err
})
if err != nil {
return nil, err
}
for _, job := range list.Items {
if !a.namespaceAllowed(job.Namespace) {
continue
}
items = append(items, agentsk8s.Job{
UID: string(job.UID),
Name: job.Name,
Namespace: job.Namespace,
DesiredCompletions: jobDesiredCompletions(job),
Succeeded: job.Status.Succeeded,
Failed: job.Status.Failed,
Active: job.Status.Active,
StartTime: timePtrFromMetaPtr(job.Status.StartTime),
CompletionTime: timePtrFromMetaPtr(job.Status.CompletionTime),
Labels: copyKubernetesStringMap(job.Labels),
})
if len(items) >= maxInventoryItems {
return items, nil
}
}
if list.Continue == "" {
break
}
opts.Continue = list.Continue
}
}
return items, nil
}
func (a *Agent) collectCronJobs(ctx context.Context) ([]agentsk8s.CronJob, error) {
items := make([]agentsk8s.CronJob, 0)
for _, namespace := range a.inventoryNamespaces() {
opts := metav1.ListOptions{Limit: listPageSize}
for {
var list *batchv1.CronJobList
action := "list cronjobs"
if namespace != metav1.NamespaceAll {
action = fmt.Sprintf("list cronjobs in namespace %q", namespace)
}
err := a.runKubernetesCallWithRetry(ctx, action, func(callCtx context.Context) error {
var err error
list, err = a.kubeClient.BatchV1().CronJobs(namespace).List(callCtx, opts)
return err
})
if err != nil {
return nil, err
}
for _, cronJob := range list.Items {
if !a.namespaceAllowed(cronJob.Namespace) {
continue
}
items = append(items, agentsk8s.CronJob{
UID: string(cronJob.UID),
Name: cronJob.Name,
Namespace: cronJob.Namespace,
Schedule: cronJob.Spec.Schedule,
Suspend: cronJobSuspended(cronJob),
Active: len(cronJob.Status.Active),
LastScheduleTime: timePtrFromMetaPtr(cronJob.Status.LastScheduleTime),
LastSuccessfulTime: timePtrFromMetaPtr(cronJob.Status.LastSuccessfulTime),
Labels: copyKubernetesStringMap(cronJob.Labels),
})
if len(items) >= maxInventoryItems {
return items, nil
}
}
if list.Continue == "" {
break
}
opts.Continue = list.Continue
}
}
return items, nil
}
func (a *Agent) collectIngresses(ctx context.Context) ([]agentsk8s.Ingress, error) {
items := make([]agentsk8s.Ingress, 0)
for _, namespace := range a.inventoryNamespaces() {
opts := metav1.ListOptions{Limit: listPageSize}
for {
var list *networkingv1.IngressList
action := "list ingresses"
if namespace != metav1.NamespaceAll {
action = fmt.Sprintf("list ingresses in namespace %q", namespace)
}
err := a.runKubernetesCallWithRetry(ctx, action, func(callCtx context.Context) error {
var err error
list, err = a.kubeClient.NetworkingV1().Ingresses(namespace).List(callCtx, opts)
return err
})
if err != nil {
return nil, err
}
for _, ingress := range list.Items {
if !a.namespaceAllowed(ingress.Namespace) {
continue
}
items = append(items, agentsk8s.Ingress{
UID: string(ingress.UID),
Name: ingress.Name,
Namespace: ingress.Namespace,
ClassName: ingressClassName(ingress),
Hosts: ingressHosts(ingress),
Addresses: ingressAddresses(ingress),
CreatedAt: ingress.CreationTimestamp.Time,
Labels: copyKubernetesStringMap(ingress.Labels),
})
if len(items) >= maxInventoryItems {
return items, nil
}
}
if list.Continue == "" {
break
}
opts.Continue = list.Continue
}
}
return items, nil
}
func (a *Agent) collectPersistentVolumes(ctx context.Context) ([]agentsk8s.PersistentVolume, error) {
items := make([]agentsk8s.PersistentVolume, 0)
opts := metav1.ListOptions{Limit: listPageSize}
for {
var list *corev1.PersistentVolumeList
err := a.runKubernetesCallWithRetry(ctx, "list persistent volumes", func(callCtx context.Context) error {
var err error
list, err = a.kubeClient.CoreV1().PersistentVolumes().List(callCtx, opts)
return err
})
if err != nil {
return nil, err
}
for _, volume := range list.Items {
claimNamespace := ""
claimName := ""
if volume.Spec.ClaimRef != nil {
claimNamespace = volume.Spec.ClaimRef.Namespace
claimName = volume.Spec.ClaimRef.Name
}
if claimNamespace != "" && !a.namespaceAllowed(claimNamespace) {
continue
}
items = append(items, agentsk8s.PersistentVolume{
UID: string(volume.UID),
Name: volume.Name,
Phase: string(volume.Status.Phase),
StorageClass: volume.Spec.StorageClassName,
CapacityBytes: volume.Spec.Capacity.Storage().Value(),
AccessModes: accessModes(volume.Spec.AccessModes),
ReclaimPolicy: string(volume.Spec.PersistentVolumeReclaimPolicy),
ClaimNamespace: claimNamespace,
ClaimName: claimName,
CreatedAt: volume.CreationTimestamp.Time,
Labels: copyKubernetesStringMap(volume.Labels),
})
if len(items) >= maxInventoryItems {
return items, nil
}
}
if list.Continue == "" {
break
}
opts.Continue = list.Continue
}
return items, nil
}
func (a *Agent) collectPersistentVolumeClaims(ctx context.Context) ([]agentsk8s.PersistentVolumeClaim, error) {
items := make([]agentsk8s.PersistentVolumeClaim, 0)
for _, namespace := range a.inventoryNamespaces() {
opts := metav1.ListOptions{Limit: listPageSize}
for {
var list *corev1.PersistentVolumeClaimList
action := "list persistent volume claims"
if namespace != metav1.NamespaceAll {
action = fmt.Sprintf("list persistent volume claims in namespace %q", namespace)
}
err := a.runKubernetesCallWithRetry(ctx, action, func(callCtx context.Context) error {
var err error
list, err = a.kubeClient.CoreV1().PersistentVolumeClaims(namespace).List(callCtx, opts)
return err
})
if err != nil {
return nil, err
}
for _, claim := range list.Items {
if !a.namespaceAllowed(claim.Namespace) {
continue
}
requested := int64(0)
if storageRequest, ok := claim.Spec.Resources.Requests[corev1.ResourceStorage]; ok {
requested = storageRequest.Value()
}
items = append(items, agentsk8s.PersistentVolumeClaim{
UID: string(claim.UID),
Name: claim.Name,
Namespace: claim.Namespace,
Phase: string(claim.Status.Phase),
StorageClass: storageClassNamePtr(claim.Spec.StorageClassName),
RequestedBytes: requested,
CapacityBytes: claim.Status.Capacity.Storage().Value(),
AccessModes: accessModes(claim.Spec.AccessModes),
VolumeName: claim.Spec.VolumeName,
CreatedAt: claim.CreationTimestamp.Time,
Labels: copyKubernetesStringMap(claim.Labels),
})
if len(items) >= maxInventoryItems {
return items, nil
}
}
if list.Continue == "" {
break
}
opts.Continue = list.Continue
}
}
return items, nil
}
func (a *Agent) collectEvents(ctx context.Context) ([]agentsk8s.Event, error) {
items := make([]agentsk8s.Event, 0)
for _, namespace := range a.inventoryNamespaces() {
opts := metav1.ListOptions{Limit: listPageSize}
for {
var list *corev1.EventList
action := "list events"
if namespace != metav1.NamespaceAll {
action = fmt.Sprintf("list events in namespace %q", namespace)
}
err := a.runKubernetesCallWithRetry(ctx, action, func(callCtx context.Context) error {
var err error
list, err = a.kubeClient.CoreV1().Events(namespace).List(callCtx, opts)
return err
})
if err != nil {
return nil, err
}
for _, event := range list.Items {
if event.Namespace != "" && !a.namespaceAllowed(event.Namespace) {
continue
}
items = append(items, agentsk8s.Event{
UID: string(event.UID),
Name: event.Name,
Namespace: event.Namespace,
Type: event.Type,
Reason: event.Reason,
Message: event.Message,
InvolvedKind: event.InvolvedObject.Kind,
InvolvedName: event.InvolvedObject.Name,
Count: event.Count,
FirstSeen: timePtrFromMeta(event.FirstTimestamp),
LastSeen: timePtrFromMeta(event.LastTimestamp),
EventTime: timePtrFromMicro(event.EventTime),
})
if len(items) >= maxEventItems {
return items, nil
}
}
if list.Continue == "" {
break
}
opts.Continue = list.Continue
}
}
return items, nil
}
func timePtrFromMetaPtr(t *metav1.Time) *time.Time {
if t == nil {
return nil
}
return timePtrFromMeta(*t)
}
func desiredReplicas(dep appsv1.Deployment) int32 {
if dep.Spec.Replicas == nil {
return 0
+209
View File
@@ -364,6 +364,58 @@ func cloneDockerContainers(src []DockerContainer) []DockerContainer {
return dest
}
func cloneDockerImages(src []DockerImage) []DockerImage {
if len(src) == 0 {
return nil
}
dest := make([]DockerImage, len(src))
for i, image := range src {
imageCopy := image
imageCopy.RepoTags = append([]string(nil), image.RepoTags...)
imageCopy.RepoDigests = append([]string(nil), image.RepoDigests...)
imageCopy.Labels = cloneStringMap(image.Labels)
dest[i] = imageCopy.NormalizeCollections()
}
return dest
}
func cloneDockerVolumes(src []DockerVolume) []DockerVolume {
if len(src) == 0 {
return nil
}
dest := make([]DockerVolume, len(src))
for i, volume := range src {
volumeCopy := volume
volumeCopy.Labels = cloneStringMap(volume.Labels)
volumeCopy.Options = cloneStringMap(volume.Options)
dest[i] = volumeCopy.NormalizeCollections()
}
return dest
}
func cloneDockerNetworks(src []DockerNetwork) []DockerNetwork {
if len(src) == 0 {
return nil
}
dest := make([]DockerNetwork, len(src))
for i, network := range src {
networkCopy := network
networkCopy.Subnets = append([]DockerNetworkSubnet(nil), network.Subnets...)
networkCopy.Labels = cloneStringMap(network.Labels)
networkCopy.Options = cloneStringMap(network.Options)
dest[i] = networkCopy.NormalizeCollections()
}
return dest
}
func cloneDockerStorageUsage(src *DockerStorageUsage) *DockerStorageUsage {
if src == nil {
return nil
}
dest := *src
return &dest
}
func cloneDockerServiceUpdate(src *DockerServiceUpdate) *DockerServiceUpdate {
if src == nil {
return nil
@@ -441,8 +493,12 @@ func cloneDockerHost(src DockerHost) DockerHost {
dest.Disks = append([]Disk(nil), src.Disks...)
dest.NetworkInterfaces = cloneHostNetworkInterfaces(src.NetworkInterfaces)
dest.Containers = cloneDockerContainers(src.Containers)
dest.Images = cloneDockerImages(src.Images)
dest.Volumes = cloneDockerVolumes(src.Volumes)
dest.Networks = cloneDockerNetworks(src.Networks)
dest.Services = cloneDockerServices(src.Services)
dest.Tasks = cloneDockerTasks(src.Tasks)
dest.StorageUsage = cloneDockerStorageUsage(src.StorageUsage)
dest.Swarm = cloneDockerSwarmInfo(src.Swarm)
dest.Security = cloneDockerHostSecurity(src.Security)
dest.Temperature = cloneFloat64Ptr(src.Temperature)
@@ -475,6 +531,19 @@ func cloneKubernetesNodes(src []KubernetesNode) []KubernetesNode {
return dest
}
func cloneKubernetesNamespaces(src []KubernetesNamespace) []KubernetesNamespace {
if len(src) == 0 {
return nil
}
dest := make([]KubernetesNamespace, len(src))
for i, namespace := range src {
namespaceCopy := namespace
namespaceCopy.Labels = cloneStringMap(namespace.Labels)
dest[i] = namespaceCopy.NormalizeCollections()
}
return dest
}
func cloneKubernetesPodContainers(src []KubernetesPodContainer) []KubernetesPodContainer {
return append([]KubernetesPodContainer(nil), src...)
}
@@ -507,11 +576,151 @@ func cloneKubernetesDeployments(src []KubernetesDeployment) []KubernetesDeployme
return dest
}
func cloneKubernetesStatefulSets(src []KubernetesStatefulSet) []KubernetesStatefulSet {
if len(src) == 0 {
return nil
}
dest := make([]KubernetesStatefulSet, len(src))
for i, statefulSet := range src {
copy := statefulSet
copy.Labels = cloneStringMap(statefulSet.Labels)
dest[i] = copy.NormalizeCollections()
}
return dest
}
func cloneKubernetesDaemonSets(src []KubernetesDaemonSet) []KubernetesDaemonSet {
if len(src) == 0 {
return nil
}
dest := make([]KubernetesDaemonSet, len(src))
for i, daemonSet := range src {
copy := daemonSet
copy.Labels = cloneStringMap(daemonSet.Labels)
dest[i] = copy.NormalizeCollections()
}
return dest
}
func cloneKubernetesServices(src []KubernetesService) []KubernetesService {
if len(src) == 0 {
return nil
}
dest := make([]KubernetesService, len(src))
for i, service := range src {
copy := service
copy.ExternalIPs = append([]string(nil), service.ExternalIPs...)
copy.Ports = append([]KubernetesServicePort(nil), service.Ports...)
copy.Selector = cloneStringMap(service.Selector)
copy.Labels = cloneStringMap(service.Labels)
dest[i] = copy.NormalizeCollections()
}
return dest
}
func cloneKubernetesJobs(src []KubernetesJob) []KubernetesJob {
if len(src) == 0 {
return nil
}
dest := make([]KubernetesJob, len(src))
for i, job := range src {
copy := job
copy.StartTime = cloneTimePtr(job.StartTime)
copy.CompletionTime = cloneTimePtr(job.CompletionTime)
copy.Labels = cloneStringMap(job.Labels)
dest[i] = copy.NormalizeCollections()
}
return dest
}
func cloneKubernetesCronJobs(src []KubernetesCronJob) []KubernetesCronJob {
if len(src) == 0 {
return nil
}
dest := make([]KubernetesCronJob, len(src))
for i, cronJob := range src {
copy := cronJob
copy.LastScheduleTime = cloneTimePtr(cronJob.LastScheduleTime)
copy.LastSuccessfulTime = cloneTimePtr(cronJob.LastSuccessfulTime)
copy.Labels = cloneStringMap(cronJob.Labels)
dest[i] = copy.NormalizeCollections()
}
return dest
}
func cloneKubernetesIngresses(src []KubernetesIngress) []KubernetesIngress {
if len(src) == 0 {
return nil
}
dest := make([]KubernetesIngress, len(src))
for i, ingress := range src {
copy := ingress
copy.Hosts = append([]string(nil), ingress.Hosts...)
copy.Addresses = append([]string(nil), ingress.Addresses...)
copy.Labels = cloneStringMap(ingress.Labels)
dest[i] = copy.NormalizeCollections()
}
return dest
}
func cloneKubernetesPersistentVolumes(src []KubernetesPersistentVolume) []KubernetesPersistentVolume {
if len(src) == 0 {
return nil
}
dest := make([]KubernetesPersistentVolume, len(src))
for i, volume := range src {
copy := volume
copy.AccessModes = append([]string(nil), volume.AccessModes...)
copy.Labels = cloneStringMap(volume.Labels)
dest[i] = copy.NormalizeCollections()
}
return dest
}
func cloneKubernetesPersistentVolumeClaims(src []KubernetesPersistentVolumeClaim) []KubernetesPersistentVolumeClaim {
if len(src) == 0 {
return nil
}
dest := make([]KubernetesPersistentVolumeClaim, len(src))
for i, claim := range src {
copy := claim
copy.AccessModes = append([]string(nil), claim.AccessModes...)
copy.Labels = cloneStringMap(claim.Labels)
dest[i] = copy.NormalizeCollections()
}
return dest
}
func cloneKubernetesEvents(src []KubernetesEvent) []KubernetesEvent {
if len(src) == 0 {
return nil
}
dest := make([]KubernetesEvent, len(src))
for i, event := range src {
copy := event
copy.FirstSeen = cloneTimePtr(event.FirstSeen)
copy.LastSeen = cloneTimePtr(event.LastSeen)
copy.EventTime = cloneTimePtr(event.EventTime)
dest[i] = copy
}
return dest
}
func cloneKubernetesCluster(src KubernetesCluster) KubernetesCluster {
dest := src
dest.Nodes = cloneKubernetesNodes(src.Nodes)
dest.Namespaces = cloneKubernetesNamespaces(src.Namespaces)
dest.Pods = cloneKubernetesPods(src.Pods)
dest.Deployments = cloneKubernetesDeployments(src.Deployments)
dest.StatefulSets = cloneKubernetesStatefulSets(src.StatefulSets)
dest.DaemonSets = cloneKubernetesDaemonSets(src.DaemonSets)
dest.Services = cloneKubernetesServices(src.Services)
dest.Jobs = cloneKubernetesJobs(src.Jobs)
dest.CronJobs = cloneKubernetesCronJobs(src.CronJobs)
dest.Ingresses = cloneKubernetesIngresses(src.Ingresses)
dest.PersistentVolumes = cloneKubernetesPersistentVolumes(src.PersistentVolumes)
dest.PersistentVolumeClaims = cloneKubernetesPersistentVolumeClaims(src.PersistentVolumeClaims)
dest.Events = cloneKubernetesEvents(src.Events)
dest.TokenLastUsedAt = cloneTimePtr(src.TokenLastUsedAt)
return dest.NormalizeCollections()
}
+412 -3
View File
@@ -653,8 +653,12 @@ type DockerHost struct {
IntervalSeconds int `json:"intervalSeconds"`
AgentVersion string `json:"agentVersion,omitempty"`
Containers []DockerContainer `json:"containers"`
Images []DockerImage `json:"images,omitempty"`
Volumes []DockerVolume `json:"volumes,omitempty"`
Networks []DockerNetwork `json:"networks,omitempty"`
Services []DockerService `json:"services,omitempty"`
Tasks []DockerTask `json:"tasks,omitempty"`
StorageUsage *DockerStorageUsage `json:"storageUsage,omitempty"`
Swarm *DockerSwarmInfo `json:"swarm,omitempty"`
Security *DockerHostSecurity `json:"security,omitempty"`
Temperature *float64 `json:"temperature,omitempty"` // Optional host temperature in Celsius
@@ -693,6 +697,24 @@ func (h DockerHost) NormalizeCollections() DockerHost {
for i := range h.Containers {
h.Containers[i] = h.Containers[i].NormalizeCollections()
}
if h.Images == nil {
h.Images = []DockerImage{}
}
for i := range h.Images {
h.Images[i] = h.Images[i].NormalizeCollections()
}
if h.Volumes == nil {
h.Volumes = []DockerVolume{}
}
for i := range h.Volumes {
h.Volumes[i] = h.Volumes[i].NormalizeCollections()
}
if h.Networks == nil {
h.Networks = []DockerNetwork{}
}
for i := range h.Networks {
h.Networks[i] = h.Networks[i].NormalizeCollections()
}
if h.Services == nil {
h.Services = []DockerService{}
}
@@ -791,6 +813,107 @@ func (c DockerContainer) NormalizeCollections() DockerContainer {
return c
}
// DockerImage represents a local image on a monitored Docker-compatible host.
type DockerImage struct {
ID string `json:"id"`
RepoTags []string `json:"repoTags,omitempty"`
RepoDigests []string `json:"repoDigests,omitempty"`
SizeBytes int64 `json:"sizeBytes,omitempty"`
SharedSizeBytes int64 `json:"sharedSizeBytes,omitempty"`
Containers int64 `json:"containers,omitempty"`
CreatedAt time.Time `json:"createdAt,omitempty"`
Labels map[string]string `json:"labels,omitempty"`
}
func (i DockerImage) NormalizeCollections() DockerImage {
if i.RepoTags == nil {
i.RepoTags = []string{}
}
if i.RepoDigests == nil {
i.RepoDigests = []string{}
}
if i.Labels == nil {
i.Labels = map[string]string{}
}
return i
}
// DockerVolume represents a local volume on a monitored Docker-compatible host.
type DockerVolume struct {
Name string `json:"name"`
Driver string `json:"driver,omitempty"`
Mountpoint string `json:"mountpoint,omitempty"`
Scope string `json:"scope,omitempty"`
CreatedAt string `json:"createdAt,omitempty"`
SizeBytes int64 `json:"sizeBytes,omitempty"`
RefCount int64 `json:"refCount,omitempty"`
Labels map[string]string `json:"labels,omitempty"`
Options map[string]string `json:"options,omitempty"`
}
func (v DockerVolume) NormalizeCollections() DockerVolume {
if v.Labels == nil {
v.Labels = map[string]string{}
}
if v.Options == nil {
v.Options = map[string]string{}
}
return v
}
// DockerNetwork represents a local network on a monitored Docker-compatible host.
type DockerNetwork struct {
ID string `json:"id"`
Name string `json:"name"`
Driver string `json:"driver,omitempty"`
Scope string `json:"scope,omitempty"`
CreatedAt time.Time `json:"createdAt,omitempty"`
EnableIPv4 bool `json:"enableIpv4,omitempty"`
EnableIPv6 bool `json:"enableIpv6,omitempty"`
Internal bool `json:"internal,omitempty"`
Attachable bool `json:"attachable,omitempty"`
Ingress bool `json:"ingress,omitempty"`
ConfigOnly bool `json:"configOnly,omitempty"`
Subnets []DockerNetworkSubnet `json:"subnets,omitempty"`
Labels map[string]string `json:"labels,omitempty"`
Options map[string]string `json:"options,omitempty"`
}
func (n DockerNetwork) NormalizeCollections() DockerNetwork {
if n.Subnets == nil {
n.Subnets = []DockerNetworkSubnet{}
}
if n.Labels == nil {
n.Labels = map[string]string{}
}
if n.Options == nil {
n.Options = map[string]string{}
}
return n
}
// DockerNetworkSubnet describes one Docker network IPAM subnet.
type DockerNetworkSubnet struct {
Subnet string `json:"subnet,omitempty"`
Gateway string `json:"gateway,omitempty"`
}
// DockerStorageUsage summarises Docker-compatible /system/df usage.
type DockerStorageUsage struct {
Images DockerStorageUsageBucket `json:"images,omitempty"`
Containers DockerStorageUsageBucket `json:"containers,omitempty"`
Volumes DockerStorageUsageBucket `json:"volumes,omitempty"`
BuildCache DockerStorageUsageBucket `json:"buildCache,omitempty"`
}
// DockerStorageUsageBucket captures one /system/df resource bucket.
type DockerStorageUsageBucket struct {
TotalCount int64 `json:"totalCount,omitempty"`
ActiveCount int64 `json:"activeCount,omitempty"`
TotalSizeBytes int64 `json:"totalSizeBytes,omitempty"`
ReclaimableBytes int64 `json:"reclaimableBytes,omitempty"`
}
// DockerContainerUpdateStatus tracks the image update status for a container.
type DockerContainerUpdateStatus struct {
UpdateAvailable bool `json:"updateAvailable"`
@@ -864,9 +987,19 @@ type KubernetesCluster struct {
IntervalSeconds int `json:"intervalSeconds"`
AgentVersion string `json:"agentVersion,omitempty"`
Nodes []KubernetesNode `json:"nodes,omitempty"`
Pods []KubernetesPod `json:"pods,omitempty"`
Deployments []KubernetesDeployment `json:"deployments,omitempty"`
Nodes []KubernetesNode `json:"nodes,omitempty"`
Namespaces []KubernetesNamespace `json:"namespaces,omitempty"`
Pods []KubernetesPod `json:"pods,omitempty"`
Deployments []KubernetesDeployment `json:"deployments,omitempty"`
StatefulSets []KubernetesStatefulSet `json:"statefulSets,omitempty"`
DaemonSets []KubernetesDaemonSet `json:"daemonSets,omitempty"`
Services []KubernetesService `json:"services,omitempty"`
Jobs []KubernetesJob `json:"jobs,omitempty"`
CronJobs []KubernetesCronJob `json:"cronJobs,omitempty"`
Ingresses []KubernetesIngress `json:"ingresses,omitempty"`
PersistentVolumes []KubernetesPersistentVolume `json:"persistentVolumes,omitempty"`
PersistentVolumeClaims []KubernetesPersistentVolumeClaim `json:"persistentVolumeClaims,omitempty"`
Events []KubernetesEvent `json:"events,omitempty"`
// Token information
TokenID string `json:"tokenId,omitempty"`
@@ -885,6 +1018,12 @@ func (c KubernetesCluster) NormalizeCollections() KubernetesCluster {
for i := range c.Nodes {
c.Nodes[i] = c.Nodes[i].NormalizeCollections()
}
if c.Namespaces == nil {
c.Namespaces = []KubernetesNamespace{}
}
for i := range c.Namespaces {
c.Namespaces[i] = c.Namespaces[i].NormalizeCollections()
}
if c.Pods == nil {
c.Pods = []KubernetesPod{}
}
@@ -897,6 +1036,57 @@ func (c KubernetesCluster) NormalizeCollections() KubernetesCluster {
for i := range c.Deployments {
c.Deployments[i] = c.Deployments[i].NormalizeCollections()
}
if c.StatefulSets == nil {
c.StatefulSets = []KubernetesStatefulSet{}
}
for i := range c.StatefulSets {
c.StatefulSets[i] = c.StatefulSets[i].NormalizeCollections()
}
if c.DaemonSets == nil {
c.DaemonSets = []KubernetesDaemonSet{}
}
for i := range c.DaemonSets {
c.DaemonSets[i] = c.DaemonSets[i].NormalizeCollections()
}
if c.Services == nil {
c.Services = []KubernetesService{}
}
for i := range c.Services {
c.Services[i] = c.Services[i].NormalizeCollections()
}
if c.Jobs == nil {
c.Jobs = []KubernetesJob{}
}
for i := range c.Jobs {
c.Jobs[i] = c.Jobs[i].NormalizeCollections()
}
if c.CronJobs == nil {
c.CronJobs = []KubernetesCronJob{}
}
for i := range c.CronJobs {
c.CronJobs[i] = c.CronJobs[i].NormalizeCollections()
}
if c.Ingresses == nil {
c.Ingresses = []KubernetesIngress{}
}
for i := range c.Ingresses {
c.Ingresses[i] = c.Ingresses[i].NormalizeCollections()
}
if c.PersistentVolumes == nil {
c.PersistentVolumes = []KubernetesPersistentVolume{}
}
for i := range c.PersistentVolumes {
c.PersistentVolumes[i] = c.PersistentVolumes[i].NormalizeCollections()
}
if c.PersistentVolumeClaims == nil {
c.PersistentVolumeClaims = []KubernetesPersistentVolumeClaim{}
}
for i := range c.PersistentVolumeClaims {
c.PersistentVolumeClaims[i] = c.PersistentVolumeClaims[i].NormalizeCollections()
}
if c.Events == nil {
c.Events = []KubernetesEvent{}
}
return c
}
@@ -1005,6 +1195,225 @@ func (d KubernetesDeployment) NormalizeCollections() KubernetesDeployment {
return d
}
type KubernetesNamespace struct {
UID string `json:"uid"`
Name string `json:"name"`
Phase string `json:"phase,omitempty"`
CreatedAt time.Time `json:"createdAt,omitempty"`
Labels map[string]string `json:"labels,omitempty"`
}
func (n KubernetesNamespace) NormalizeCollections() KubernetesNamespace {
if n.Labels == nil {
n.Labels = map[string]string{}
}
return n
}
type KubernetesStatefulSet struct {
UID string `json:"uid"`
Name string `json:"name"`
Namespace string `json:"namespace"`
DesiredReplicas int32 `json:"desiredReplicas,omitempty"`
ReadyReplicas int32 `json:"readyReplicas,omitempty"`
CurrentReplicas int32 `json:"currentReplicas,omitempty"`
UpdatedReplicas int32 `json:"updatedReplicas,omitempty"`
AvailableReplicas int32 `json:"availableReplicas,omitempty"`
ServiceName string `json:"serviceName,omitempty"`
Labels map[string]string `json:"labels,omitempty"`
}
func (s KubernetesStatefulSet) NormalizeCollections() KubernetesStatefulSet {
if s.Labels == nil {
s.Labels = map[string]string{}
}
return s
}
type KubernetesDaemonSet struct {
UID string `json:"uid"`
Name string `json:"name"`
Namespace string `json:"namespace"`
DesiredNumberScheduled int32 `json:"desiredNumberScheduled,omitempty"`
CurrentNumberScheduled int32 `json:"currentNumberScheduled,omitempty"`
NumberReady int32 `json:"numberReady,omitempty"`
UpdatedNumberScheduled int32 `json:"updatedNumberScheduled,omitempty"`
NumberAvailable int32 `json:"numberAvailable,omitempty"`
NumberUnavailable int32 `json:"numberUnavailable,omitempty"`
NumberMisscheduled int32 `json:"numberMisscheduled,omitempty"`
Labels map[string]string `json:"labels,omitempty"`
}
func (d KubernetesDaemonSet) NormalizeCollections() KubernetesDaemonSet {
if d.Labels == nil {
d.Labels = map[string]string{}
}
return d
}
type KubernetesService struct {
UID string `json:"uid"`
Name string `json:"name"`
Namespace string `json:"namespace"`
ServiceType string `json:"type,omitempty"`
ClusterIP string `json:"clusterIp,omitempty"`
ExternalIPs []string `json:"externalIps,omitempty"`
Ports []KubernetesServicePort `json:"ports,omitempty"`
Selector map[string]string `json:"selector,omitempty"`
CreatedAt time.Time `json:"createdAt,omitempty"`
Labels map[string]string `json:"labels,omitempty"`
}
func (s KubernetesService) NormalizeCollections() KubernetesService {
if s.ExternalIPs == nil {
s.ExternalIPs = []string{}
}
if s.Ports == nil {
s.Ports = []KubernetesServicePort{}
}
if s.Selector == nil {
s.Selector = map[string]string{}
}
if s.Labels == nil {
s.Labels = map[string]string{}
}
return s
}
type KubernetesServicePort struct {
Name string `json:"name,omitempty"`
Protocol string `json:"protocol,omitempty"`
Port int32 `json:"port,omitempty"`
TargetPort string `json:"targetPort,omitempty"`
NodePort int32 `json:"nodePort,omitempty"`
}
type KubernetesJob struct {
UID string `json:"uid"`
Name string `json:"name"`
Namespace string `json:"namespace"`
DesiredCompletions int32 `json:"desiredCompletions,omitempty"`
Succeeded int32 `json:"succeeded,omitempty"`
Failed int32 `json:"failed,omitempty"`
Active int32 `json:"active,omitempty"`
StartTime *time.Time `json:"startTime,omitempty"`
CompletionTime *time.Time `json:"completionTime,omitempty"`
Labels map[string]string `json:"labels,omitempty"`
}
func (j KubernetesJob) NormalizeCollections() KubernetesJob {
if j.Labels == nil {
j.Labels = map[string]string{}
}
return j
}
type KubernetesCronJob struct {
UID string `json:"uid"`
Name string `json:"name"`
Namespace string `json:"namespace"`
Schedule string `json:"schedule,omitempty"`
Suspend bool `json:"suspend,omitempty"`
Active int `json:"active,omitempty"`
LastScheduleTime *time.Time `json:"lastScheduleTime,omitempty"`
LastSuccessfulTime *time.Time `json:"lastSuccessfulTime,omitempty"`
Labels map[string]string `json:"labels,omitempty"`
}
func (j KubernetesCronJob) NormalizeCollections() KubernetesCronJob {
if j.Labels == nil {
j.Labels = map[string]string{}
}
return j
}
type KubernetesIngress struct {
UID string `json:"uid"`
Name string `json:"name"`
Namespace string `json:"namespace"`
ClassName string `json:"className,omitempty"`
Hosts []string `json:"hosts,omitempty"`
Addresses []string `json:"addresses,omitempty"`
CreatedAt time.Time `json:"createdAt,omitempty"`
Labels map[string]string `json:"labels,omitempty"`
}
func (i KubernetesIngress) NormalizeCollections() KubernetesIngress {
if i.Hosts == nil {
i.Hosts = []string{}
}
if i.Addresses == nil {
i.Addresses = []string{}
}
if i.Labels == nil {
i.Labels = map[string]string{}
}
return i
}
type KubernetesPersistentVolume struct {
UID string `json:"uid"`
Name string `json:"name"`
Phase string `json:"phase,omitempty"`
StorageClass string `json:"storageClass,omitempty"`
CapacityBytes int64 `json:"capacityBytes,omitempty"`
AccessModes []string `json:"accessModes,omitempty"`
ReclaimPolicy string `json:"reclaimPolicy,omitempty"`
ClaimNamespace string `json:"claimNamespace,omitempty"`
ClaimName string `json:"claimName,omitempty"`
CreatedAt time.Time `json:"createdAt,omitempty"`
Labels map[string]string `json:"labels,omitempty"`
}
func (p KubernetesPersistentVolume) NormalizeCollections() KubernetesPersistentVolume {
if p.AccessModes == nil {
p.AccessModes = []string{}
}
if p.Labels == nil {
p.Labels = map[string]string{}
}
return p
}
type KubernetesPersistentVolumeClaim struct {
UID string `json:"uid"`
Name string `json:"name"`
Namespace string `json:"namespace"`
Phase string `json:"phase,omitempty"`
StorageClass string `json:"storageClass,omitempty"`
RequestedBytes int64 `json:"requestedBytes,omitempty"`
CapacityBytes int64 `json:"capacityBytes,omitempty"`
AccessModes []string `json:"accessModes,omitempty"`
VolumeName string `json:"volumeName,omitempty"`
CreatedAt time.Time `json:"createdAt,omitempty"`
Labels map[string]string `json:"labels,omitempty"`
}
func (p KubernetesPersistentVolumeClaim) NormalizeCollections() KubernetesPersistentVolumeClaim {
if p.AccessModes == nil {
p.AccessModes = []string{}
}
if p.Labels == nil {
p.Labels = map[string]string{}
}
return p
}
type KubernetesEvent struct {
UID string `json:"uid"`
Name string `json:"name"`
Namespace string `json:"namespace,omitempty"`
EventType string `json:"type,omitempty"`
Reason string `json:"reason,omitempty"`
Message string `json:"message,omitempty"`
InvolvedKind string `json:"involvedKind,omitempty"`
InvolvedName string `json:"involvedName,omitempty"`
Count int32 `json:"count,omitempty"`
FirstSeen *time.Time `json:"firstSeen,omitempty"`
LastSeen *time.Time `json:"lastSeen,omitempty"`
EventTime *time.Time `json:"eventTime,omitempty"`
}
// DockerService summarises a Docker Swarm service.
type DockerService struct {
ID string `json:"id"`
@@ -85,6 +85,43 @@ func TestStandaloneInactiveDockerSwarmMetadataIsNotCapabilityEvidence(t *testing
}
}
func TestDockerInventoryConvertersPreserveNativeRuntimeFields(t *testing.T) {
createdAt := time.Date(2026, 5, 24, 8, 0, 0, 0, time.UTC)
images := convertDockerImages([]agentsdocker.Image{{
ID: " sha256:image1 ",
RepoTags: []string{"repo/app:latest"},
RepoDigests: []string{"repo/app@sha256:abc"},
SizeBytes: 1024,
CreatedAt: createdAt,
Labels: map[string]string{"tier": "web"},
}})
if len(images) != 1 || images[0].ID != "sha256:image1" || images[0].Labels["tier"] != "web" {
t.Fatalf("unexpected image conversion: %+v", images)
}
volumes := convertDockerVolumes([]agentsdocker.Volume{{
Name: " app-data ", Driver: " local ", SizeBytes: 2048, RefCount: 3, Labels: map[string]string{"backup": "true"},
}})
if len(volumes) != 1 || volumes[0].Name != "app-data" || volumes[0].SizeBytes != 2048 || volumes[0].Labels["backup"] != "true" {
t.Fatalf("unexpected volume conversion: %+v", volumes)
}
networks := convertDockerNetworks([]agentsdocker.Network{{
ID: " net1 ", Name: " app-net ", Driver: " bridge ", EnableIPv4: true, Subnets: []agentsdocker.NetworkSubnet{{Subnet: " 10.88.0.0/24 ", Gateway: " 10.88.0.1 "}},
}})
if len(networks) != 1 || networks[0].Name != "app-net" || networks[0].Subnets[0].Subnet != "10.88.0.0/24" {
t.Fatalf("unexpected network conversion: %+v", networks)
}
usage := convertDockerStorageUsage(&agentsdocker.StorageUsage{
Images: agentsdocker.StorageUsageBucket{TotalCount: 3, ActiveCount: 2, TotalSizeBytes: 4096, ReclaimableBytes: 512},
})
if usage == nil || usage.Images.TotalCount != 3 || usage.Images.ReclaimableBytes != 512 {
t.Fatalf("unexpected storage usage conversion: %+v", usage)
}
}
func TestPVEBackupPermissionWarningsPreserveTokenACLRepair(t *testing.T) {
warning := pveBackupPermissionWarning(&config.PVEInstance{
TokenName: "pulse-monitor@pve!pulse-example",
+270 -14
View File
@@ -252,23 +252,44 @@ func (m *Monitor) ApplyKubernetesReport(report agentsk8s.Report, tokenRecord *co
})
}
namespaces := convertKubernetesNamespaces(report.Namespaces)
statefulSets := convertKubernetesStatefulSets(report.StatefulSets)
daemonSets := convertKubernetesDaemonSets(report.DaemonSets)
services := convertKubernetesServices(report.Services)
jobs := convertKubernetesJobs(report.Jobs)
cronJobs := convertKubernetesCronJobs(report.CronJobs)
ingresses := convertKubernetesIngresses(report.Ingresses)
persistentVolumes := convertKubernetesPersistentVolumes(report.PersistentVolumes)
persistentVolumeClaims := convertKubernetesPersistentVolumeClaims(report.PersistentVolumeClaims)
events := convertKubernetesEvents(report.Events)
agentVersion := normalizeAgentVersion(report.Agent.Version)
cluster := models.KubernetesCluster{
ID: identifier,
AgentID: agentID,
Name: name,
DisplayName: displayName,
Server: strings.TrimSpace(report.Cluster.Server),
Context: strings.TrimSpace(report.Cluster.Context),
Version: strings.TrimSpace(report.Cluster.Version),
Status: "online",
LastSeen: timestamp,
IntervalSeconds: report.Agent.IntervalSeconds,
AgentVersion: agentVersion,
Nodes: nodes,
Pods: pods,
Deployments: deployments,
ID: identifier,
AgentID: agentID,
Name: name,
DisplayName: displayName,
Server: strings.TrimSpace(report.Cluster.Server),
Context: strings.TrimSpace(report.Cluster.Context),
Version: strings.TrimSpace(report.Cluster.Version),
Status: "online",
LastSeen: timestamp,
IntervalSeconds: report.Agent.IntervalSeconds,
AgentVersion: agentVersion,
Nodes: nodes,
Namespaces: namespaces,
Pods: pods,
Deployments: deployments,
StatefulSets: statefulSets,
DaemonSets: daemonSets,
Services: services,
Jobs: jobs,
CronJobs: cronJobs,
Ingresses: ingresses,
PersistentVolumes: persistentVolumes,
PersistentVolumeClaims: persistentVolumeClaims,
Events: events,
}
if tokenRecord != nil {
@@ -285,6 +306,241 @@ func (m *Monitor) ApplyKubernetesReport(report agentsk8s.Report, tokenRecord *co
return cluster, nil
}
func convertKubernetesNamespaces(namespaces []agentsk8s.Namespace) []models.KubernetesNamespace {
if len(namespaces) == 0 {
return nil
}
out := make([]models.KubernetesNamespace, 0, len(namespaces))
for _, namespace := range namespaces {
out = append(out, models.KubernetesNamespace{
UID: strings.TrimSpace(namespace.UID),
Name: strings.TrimSpace(namespace.Name),
Phase: strings.TrimSpace(namespace.Phase),
CreatedAt: namespace.CreatedAt,
Labels: cloneStringMap(namespace.Labels),
}.NormalizeCollections())
}
return out
}
func convertKubernetesStatefulSets(statefulSets []agentsk8s.StatefulSet) []models.KubernetesStatefulSet {
if len(statefulSets) == 0 {
return nil
}
out := make([]models.KubernetesStatefulSet, 0, len(statefulSets))
for _, statefulSet := range statefulSets {
out = append(out, models.KubernetesStatefulSet{
UID: strings.TrimSpace(statefulSet.UID),
Name: strings.TrimSpace(statefulSet.Name),
Namespace: strings.TrimSpace(statefulSet.Namespace),
DesiredReplicas: statefulSet.DesiredReplicas,
ReadyReplicas: statefulSet.ReadyReplicas,
CurrentReplicas: statefulSet.CurrentReplicas,
UpdatedReplicas: statefulSet.UpdatedReplicas,
AvailableReplicas: statefulSet.AvailableReplicas,
ServiceName: strings.TrimSpace(statefulSet.ServiceName),
Labels: cloneStringMap(statefulSet.Labels),
}.NormalizeCollections())
}
return out
}
func convertKubernetesDaemonSets(daemonSets []agentsk8s.DaemonSet) []models.KubernetesDaemonSet {
if len(daemonSets) == 0 {
return nil
}
out := make([]models.KubernetesDaemonSet, 0, len(daemonSets))
for _, daemonSet := range daemonSets {
out = append(out, models.KubernetesDaemonSet{
UID: strings.TrimSpace(daemonSet.UID),
Name: strings.TrimSpace(daemonSet.Name),
Namespace: strings.TrimSpace(daemonSet.Namespace),
DesiredNumberScheduled: daemonSet.DesiredNumberScheduled,
CurrentNumberScheduled: daemonSet.CurrentNumberScheduled,
NumberReady: daemonSet.NumberReady,
UpdatedNumberScheduled: daemonSet.UpdatedNumberScheduled,
NumberAvailable: daemonSet.NumberAvailable,
NumberUnavailable: daemonSet.NumberUnavailable,
NumberMisscheduled: daemonSet.NumberMisscheduled,
Labels: cloneStringMap(daemonSet.Labels),
}.NormalizeCollections())
}
return out
}
func convertKubernetesServices(services []agentsk8s.Service) []models.KubernetesService {
if len(services) == 0 {
return nil
}
out := make([]models.KubernetesService, 0, len(services))
for _, service := range services {
ports := make([]models.KubernetesServicePort, 0, len(service.Ports))
for _, port := range service.Ports {
ports = append(ports, models.KubernetesServicePort{
Name: strings.TrimSpace(port.Name),
Protocol: strings.TrimSpace(port.Protocol),
Port: port.Port,
TargetPort: strings.TrimSpace(port.TargetPort),
NodePort: port.NodePort,
})
}
out = append(out, models.KubernetesService{
UID: strings.TrimSpace(service.UID),
Name: strings.TrimSpace(service.Name),
Namespace: strings.TrimSpace(service.Namespace),
ServiceType: strings.TrimSpace(service.Type),
ClusterIP: strings.TrimSpace(service.ClusterIP),
ExternalIPs: append([]string(nil), service.ExternalIPs...),
Ports: ports,
Selector: cloneStringMap(service.Selector),
CreatedAt: service.CreatedAt,
Labels: cloneStringMap(service.Labels),
}.NormalizeCollections())
}
return out
}
func convertKubernetesJobs(jobs []agentsk8s.Job) []models.KubernetesJob {
if len(jobs) == 0 {
return nil
}
out := make([]models.KubernetesJob, 0, len(jobs))
for _, job := range jobs {
out = append(out, models.KubernetesJob{
UID: strings.TrimSpace(job.UID),
Name: strings.TrimSpace(job.Name),
Namespace: strings.TrimSpace(job.Namespace),
DesiredCompletions: job.DesiredCompletions,
Succeeded: job.Succeeded,
Failed: job.Failed,
Active: job.Active,
StartTime: cloneReportTimePtr(job.StartTime),
CompletionTime: cloneReportTimePtr(job.CompletionTime),
Labels: cloneStringMap(job.Labels),
}.NormalizeCollections())
}
return out
}
func convertKubernetesCronJobs(cronJobs []agentsk8s.CronJob) []models.KubernetesCronJob {
if len(cronJobs) == 0 {
return nil
}
out := make([]models.KubernetesCronJob, 0, len(cronJobs))
for _, cronJob := range cronJobs {
out = append(out, models.KubernetesCronJob{
UID: strings.TrimSpace(cronJob.UID),
Name: strings.TrimSpace(cronJob.Name),
Namespace: strings.TrimSpace(cronJob.Namespace),
Schedule: strings.TrimSpace(cronJob.Schedule),
Suspend: cronJob.Suspend,
Active: cronJob.Active,
LastScheduleTime: cloneReportTimePtr(cronJob.LastScheduleTime),
LastSuccessfulTime: cloneReportTimePtr(cronJob.LastSuccessfulTime),
Labels: cloneStringMap(cronJob.Labels),
}.NormalizeCollections())
}
return out
}
func convertKubernetesIngresses(ingresses []agentsk8s.Ingress) []models.KubernetesIngress {
if len(ingresses) == 0 {
return nil
}
out := make([]models.KubernetesIngress, 0, len(ingresses))
for _, ingress := range ingresses {
out = append(out, models.KubernetesIngress{
UID: strings.TrimSpace(ingress.UID),
Name: strings.TrimSpace(ingress.Name),
Namespace: strings.TrimSpace(ingress.Namespace),
ClassName: strings.TrimSpace(ingress.ClassName),
Hosts: append([]string(nil), ingress.Hosts...),
Addresses: append([]string(nil), ingress.Addresses...),
CreatedAt: ingress.CreatedAt,
Labels: cloneStringMap(ingress.Labels),
}.NormalizeCollections())
}
return out
}
func convertKubernetesPersistentVolumes(volumes []agentsk8s.PersistentVolume) []models.KubernetesPersistentVolume {
if len(volumes) == 0 {
return nil
}
out := make([]models.KubernetesPersistentVolume, 0, len(volumes))
for _, volume := range volumes {
out = append(out, models.KubernetesPersistentVolume{
UID: strings.TrimSpace(volume.UID),
Name: strings.TrimSpace(volume.Name),
Phase: strings.TrimSpace(volume.Phase),
StorageClass: strings.TrimSpace(volume.StorageClass),
CapacityBytes: volume.CapacityBytes,
AccessModes: append([]string(nil), volume.AccessModes...),
ReclaimPolicy: strings.TrimSpace(volume.ReclaimPolicy),
ClaimNamespace: strings.TrimSpace(volume.ClaimNamespace),
ClaimName: strings.TrimSpace(volume.ClaimName),
CreatedAt: volume.CreatedAt,
Labels: cloneStringMap(volume.Labels),
}.NormalizeCollections())
}
return out
}
func convertKubernetesPersistentVolumeClaims(claims []agentsk8s.PersistentVolumeClaim) []models.KubernetesPersistentVolumeClaim {
if len(claims) == 0 {
return nil
}
out := make([]models.KubernetesPersistentVolumeClaim, 0, len(claims))
for _, claim := range claims {
out = append(out, models.KubernetesPersistentVolumeClaim{
UID: strings.TrimSpace(claim.UID),
Name: strings.TrimSpace(claim.Name),
Namespace: strings.TrimSpace(claim.Namespace),
Phase: strings.TrimSpace(claim.Phase),
StorageClass: strings.TrimSpace(claim.StorageClass),
RequestedBytes: claim.RequestedBytes,
CapacityBytes: claim.CapacityBytes,
AccessModes: append([]string(nil), claim.AccessModes...),
VolumeName: strings.TrimSpace(claim.VolumeName),
CreatedAt: claim.CreatedAt,
Labels: cloneStringMap(claim.Labels),
}.NormalizeCollections())
}
return out
}
func convertKubernetesEvents(events []agentsk8s.Event) []models.KubernetesEvent {
if len(events) == 0 {
return nil
}
out := make([]models.KubernetesEvent, 0, len(events))
for _, event := range events {
out = append(out, models.KubernetesEvent{
UID: strings.TrimSpace(event.UID),
Name: strings.TrimSpace(event.Name),
Namespace: strings.TrimSpace(event.Namespace),
EventType: strings.TrimSpace(event.Type),
Reason: strings.TrimSpace(event.Reason),
Message: strings.TrimSpace(event.Message),
InvolvedKind: strings.TrimSpace(event.InvolvedKind),
InvolvedName: strings.TrimSpace(event.InvolvedName),
Count: event.Count,
FirstSeen: cloneReportTimePtr(event.FirstSeen),
LastSeen: cloneReportTimePtr(event.LastSeen),
EventTime: cloneReportTimePtr(event.EventTime),
})
}
return out
}
func cloneReportTimePtr(src *time.Time) *time.Time {
if src == nil {
return nil
}
dest := *src
return &dest
}
func kubernetesCPUUsagePercent(usageMilli, allocCPU, capacityCPU int64) float64 {
denom := allocCPU
if denom <= 0 {
@@ -86,6 +86,81 @@ func TestApplyKubernetesReport(t *testing.T) {
}
}
func TestApplyKubernetesReportPreservesNativeAPIInventory(t *testing.T) {
monitor := newKubernetesTestMonitor()
now := time.Date(2026, 5, 24, 8, 0, 0, 0, time.UTC)
report := agentsk8s.Report{
Agent: agentsk8s.AgentInfo{ID: "agent-1", IntervalSeconds: 10},
Cluster: agentsk8s.ClusterInfo{ID: "cluster-1", Name: "cluster"},
Namespaces: []agentsk8s.Namespace{{
UID: "ns-1",
Name: " services ",
Phase: " Active ",
CreatedAt: now,
Labels: map[string]string{"team": "checkout"},
}},
Services: []agentsk8s.Service{{
UID: "svc-1",
Name: " checkout ",
Namespace: " services ",
Type: " ClusterIP ",
ClusterIP: "10.96.0.10",
ExternalIPs: []string{"192.0.2.10"},
Ports: []agentsk8s.ServicePort{{Name: " http ", Protocol: " TCP ", Port: 80, TargetPort: "8080"}},
Selector: map[string]string{"app": "checkout"},
CreatedAt: now,
}},
StatefulSets: []agentsk8s.StatefulSet{{UID: "sts-1", Name: "db", Namespace: "services", DesiredReplicas: 3, ReadyReplicas: 2, ServiceName: "db-headless"}},
DaemonSets: []agentsk8s.DaemonSet{{UID: "ds-1", Name: "node-agent", Namespace: "kube-system", DesiredNumberScheduled: 3, NumberReady: 3}},
Jobs: []agentsk8s.Job{{UID: "job-1", Name: "backup", Namespace: "services", DesiredCompletions: 1, Succeeded: 1}},
CronJobs: []agentsk8s.CronJob{{UID: "cron-1", Name: "nightly", Namespace: "services", Schedule: "0 1 * * *"}},
Ingresses: []agentsk8s.Ingress{{UID: "ing-1", Name: "checkout", Namespace: "services", ClassName: "nginx", Hosts: []string{"checkout.example.test"}, Addresses: []string{"192.0.2.20"}, CreatedAt: now}},
PersistentVolumes: []agentsk8s.PersistentVolume{{
UID: "pv-1", Name: "pv-checkout", Phase: " Bound ", StorageClass: "fast", CapacityBytes: 10_000, AccessModes: []string{"ReadWriteOnce"}, ReclaimPolicy: "Retain", ClaimNamespace: "services", ClaimName: "checkout-data", CreatedAt: now,
}},
PersistentVolumeClaims: []agentsk8s.PersistentVolumeClaim{{
UID: "pvc-1", Name: "checkout-data", Namespace: "services", Phase: " Bound ", StorageClass: "fast", RequestedBytes: 10_000, CapacityBytes: 10_000, AccessModes: []string{"ReadWriteOnce"}, VolumeName: "pv-checkout", CreatedAt: now,
}},
Events: []agentsk8s.Event{{UID: "evt-1", Name: "checkout.1", Namespace: "services", Type: "Warning", Reason: "BackOff", Message: "retrying", InvolvedKind: "Pod", InvolvedName: "checkout-0", Count: 2}},
Timestamp: now,
}
cluster, err := monitor.ApplyKubernetesReport(report, nil)
if err != nil {
t.Fatalf("ApplyKubernetesReport: %v", err)
}
if len(cluster.Namespaces) != 1 || cluster.Namespaces[0].Name != "services" {
t.Fatalf("expected namespace inventory, got %+v", cluster.Namespaces)
}
if len(cluster.Services) != 1 || cluster.Services[0].ServiceType != "ClusterIP" || cluster.Services[0].Ports[0].TargetPort != "8080" {
t.Fatalf("expected service inventory, got %+v", cluster.Services)
}
if len(cluster.StatefulSets) != 1 || cluster.StatefulSets[0].ReadyReplicas != 2 {
t.Fatalf("expected statefulset inventory, got %+v", cluster.StatefulSets)
}
if len(cluster.DaemonSets) != 1 || cluster.DaemonSets[0].NumberReady != 3 {
t.Fatalf("expected daemonset inventory, got %+v", cluster.DaemonSets)
}
if len(cluster.Jobs) != 1 || cluster.Jobs[0].Succeeded != 1 {
t.Fatalf("expected job inventory, got %+v", cluster.Jobs)
}
if len(cluster.CronJobs) != 1 || cluster.CronJobs[0].Schedule != "0 1 * * *" {
t.Fatalf("expected cronjob inventory, got %+v", cluster.CronJobs)
}
if len(cluster.Ingresses) != 1 || cluster.Ingresses[0].Hosts[0] != "checkout.example.test" {
t.Fatalf("expected ingress inventory, got %+v", cluster.Ingresses)
}
if len(cluster.PersistentVolumes) != 1 || cluster.PersistentVolumes[0].ClaimName != "checkout-data" {
t.Fatalf("expected PV inventory, got %+v", cluster.PersistentVolumes)
}
if len(cluster.PersistentVolumeClaims) != 1 || cluster.PersistentVolumeClaims[0].VolumeName != "pv-checkout" {
t.Fatalf("expected PVC inventory, got %+v", cluster.PersistentVolumeClaims)
}
if len(cluster.Events) != 1 || cluster.Events[0].Reason != "BackOff" || cluster.Events[0].Count != 2 {
t.Fatalf("expected event inventory, got %+v", cluster.Events)
}
}
func TestApplyKubernetesReport_PodNetworkAndEphemeralMetrics(t *testing.T) {
monitor := newKubernetesTestMonitor()
monitor.rateTracker = NewRateTracker()
+8
View File
@@ -1237,8 +1237,12 @@ func (m *Monitor) ApplyDockerReport(report agentsdocker.Report, tokenRecord *con
containers = append(containers, container)
}
images := convertDockerImages(report.Images)
volumes := convertDockerVolumes(report.Volumes)
networks := convertDockerNetworks(report.Networks)
services := convertDockerServices(report.Services)
tasks := convertDockerTasks(report.Tasks)
storageUsage := convertDockerStorageUsage(report.StorageUsage)
swarmInfo := convertDockerSwarmInfo(report.Host.Swarm)
security := deriveDockerHostSecurity(report.Host.Security, runtime)
@@ -1341,8 +1345,12 @@ func (m *Monitor) ApplyDockerReport(report agentsdocker.Report, tokenRecord *con
IntervalSeconds: report.Agent.IntervalSeconds,
AgentVersion: agentVersion,
Containers: containers,
Images: images,
Volumes: volumes,
Networks: networks,
Services: services,
Tasks: tasks,
StorageUsage: storageUsage,
Swarm: swarmInfo,
Security: security,
IsLegacy: isLegacyAgent(report.Agent.Type),
+95
View File
@@ -190,6 +190,101 @@ func cloneStringMap(src map[string]string) map[string]string {
return out
}
func convertDockerImages(images []agentsdocker.Image) []models.DockerImage {
if len(images) == 0 {
return nil
}
result := make([]models.DockerImage, 0, len(images))
for _, image := range images {
result = append(result, models.DockerImage{
ID: strings.TrimSpace(image.ID),
RepoTags: append([]string(nil), image.RepoTags...),
RepoDigests: append([]string(nil), image.RepoDigests...),
SizeBytes: image.SizeBytes,
SharedSizeBytes: image.SharedSizeBytes,
Containers: image.Containers,
CreatedAt: image.CreatedAt,
Labels: cloneStringMap(image.Labels),
}.NormalizeCollections())
}
return result
}
func convertDockerVolumes(volumes []agentsdocker.Volume) []models.DockerVolume {
if len(volumes) == 0 {
return nil
}
result := make([]models.DockerVolume, 0, len(volumes))
for _, volume := range volumes {
result = append(result, models.DockerVolume{
Name: strings.TrimSpace(volume.Name),
Driver: strings.TrimSpace(volume.Driver),
Mountpoint: strings.TrimSpace(volume.Mountpoint),
Scope: strings.TrimSpace(volume.Scope),
CreatedAt: strings.TrimSpace(volume.CreatedAt),
SizeBytes: volume.SizeBytes,
RefCount: volume.RefCount,
Labels: cloneStringMap(volume.Labels),
Options: cloneStringMap(volume.Options),
}.NormalizeCollections())
}
return result
}
func convertDockerNetworks(networks []agentsdocker.Network) []models.DockerNetwork {
if len(networks) == 0 {
return nil
}
result := make([]models.DockerNetwork, 0, len(networks))
for _, network := range networks {
subnets := make([]models.DockerNetworkSubnet, 0, len(network.Subnets))
for _, subnet := range network.Subnets {
subnets = append(subnets, models.DockerNetworkSubnet{
Subnet: strings.TrimSpace(subnet.Subnet),
Gateway: strings.TrimSpace(subnet.Gateway),
})
}
result = append(result, models.DockerNetwork{
ID: strings.TrimSpace(network.ID),
Name: strings.TrimSpace(network.Name),
Driver: strings.TrimSpace(network.Driver),
Scope: strings.TrimSpace(network.Scope),
CreatedAt: network.CreatedAt,
EnableIPv4: network.EnableIPv4,
EnableIPv6: network.EnableIPv6,
Internal: network.Internal,
Attachable: network.Attachable,
Ingress: network.Ingress,
ConfigOnly: network.ConfigOnly,
Subnets: subnets,
Labels: cloneStringMap(network.Labels),
Options: cloneStringMap(network.Options),
}.NormalizeCollections())
}
return result
}
func convertDockerStorageUsage(usage *agentsdocker.StorageUsage) *models.DockerStorageUsage {
if usage == nil {
return nil
}
return &models.DockerStorageUsage{
Images: convertDockerStorageUsageBucket(usage.Images),
Containers: convertDockerStorageUsageBucket(usage.Containers),
Volumes: convertDockerStorageUsageBucket(usage.Volumes),
BuildCache: convertDockerStorageUsageBucket(usage.BuildCache),
}
}
func convertDockerStorageUsageBucket(bucket agentsdocker.StorageUsageBucket) models.DockerStorageUsageBucket {
return models.DockerStorageUsageBucket{
TotalCount: bucket.TotalCount,
ActiveCount: bucket.ActiveCount,
TotalSizeBytes: bucket.TotalSizeBytes,
ReclaimableBytes: bucket.ReclaimableBytes,
}
}
func convertDockerServices(services []agentsdocker.Service) []models.DockerService {
if len(services) == 0 {
return nil
@@ -384,6 +384,59 @@ func TestApplyHostReportAllowsTokenReuseAcrossHosts(t *testing.T) {
}
}
func TestApplyDockerReportPreservesNativeRuntimeInventory(t *testing.T) {
monitor := newTestMonitor(t)
now := time.Date(2026, 5, 24, 8, 0, 0, 0, time.UTC)
report := agentsdocker.Report{
Agent: agentsdocker.AgentInfo{ID: "docker-agent-1", Version: "1.0.0", IntervalSeconds: 30},
Host: agentsdocker.HostInfo{
Hostname: "docker-host",
Name: "Docker Host",
Runtime: "podman",
RuntimeVersion: "5.0.0",
},
Images: []agentsdocker.Image{{
ID: "sha256:image1",
RepoTags: []string{"repo/app:latest"},
RepoDigests: []string{"repo/app@sha256:abc"},
SizeBytes: 1024,
SharedSizeBytes: 128,
Containers: 2,
CreatedAt: now,
Labels: map[string]string{"tier": "web"},
}},
Volumes: []agentsdocker.Volume{{
Name: "app-data", Driver: "local", Mountpoint: "/var/lib/volumes/app-data", Scope: "local", SizeBytes: 2048, RefCount: 3,
}},
Networks: []agentsdocker.Network{{
ID: "net1", Name: "app-net", Driver: "bridge", Scope: "local", EnableIPv4: true, Attachable: true, Subnets: []agentsdocker.NetworkSubnet{{Subnet: "10.88.0.0/24", Gateway: "10.88.0.1"}},
}},
StorageUsage: &agentsdocker.StorageUsage{
Images: agentsdocker.StorageUsageBucket{TotalCount: 3, ActiveCount: 2, TotalSizeBytes: 4096, ReclaimableBytes: 512},
Volumes: agentsdocker.StorageUsageBucket{TotalCount: 1, ActiveCount: 1, TotalSizeBytes: 2048},
Containers: agentsdocker.StorageUsageBucket{TotalCount: 2, ActiveCount: 2},
},
Timestamp: now,
}
host, err := monitor.ApplyDockerReport(report, nil)
if err != nil {
t.Fatalf("ApplyDockerReport: %v", err)
}
if len(host.Images) != 1 || host.Images[0].ID != "sha256:image1" || host.Images[0].RepoTags[0] != "repo/app:latest" {
t.Fatalf("expected image inventory, got %+v", host.Images)
}
if len(host.Volumes) != 1 || host.Volumes[0].Name != "app-data" || host.Volumes[0].SizeBytes != 2048 {
t.Fatalf("expected volume inventory, got %+v", host.Volumes)
}
if len(host.Networks) != 1 || host.Networks[0].Name != "app-net" || host.Networks[0].Subnets[0].Gateway != "10.88.0.1" {
t.Fatalf("expected network inventory, got %+v", host.Networks)
}
if host.StorageUsage == nil || host.StorageUsage.Images.ReclaimableBytes != 512 {
t.Fatalf("expected storage usage inventory, got %+v", host.StorageUsage)
}
}
func TestApplyDockerReportDerivesCanonicalDockerSecurityPosture(t *testing.T) {
t.Helper()
@@ -374,6 +374,89 @@ func TestUnifiedAIAdapterClassificationAndStats(t *testing.T) {
}
}
func TestDockerNativeInventoryAdapters(t *testing.T) {
now := time.Date(2026, 5, 24, 8, 0, 0, 0, time.UTC)
host := models.DockerHost{ID: "docker-host-1", Hostname: "edge", Runtime: "podman", RuntimeVersion: "5.0.0", LastSeen: now}
imageResource, imageIdentity := resourceFromDockerImage(models.DockerImage{
ID: "sha256:image1", RepoTags: []string{"repo/app:latest"}, SizeBytes: 1024, Labels: map[string]string{"tier": "web"},
}, host)
if imageResource.Type != ResourceTypeDockerImage || imageResource.Docker == nil || imageResource.Docker.ImageID != "sha256:image1" || imageIdentity.Hostnames[0] != "repo/app:latest" {
t.Fatalf("unexpected image resource: resource=%+v identity=%+v", imageResource, imageIdentity)
}
volumeResource, _ := resourceFromDockerVolume(models.DockerVolume{Name: "app-data", Driver: "local", SizeBytes: 2048, RefCount: 3}, host)
if volumeResource.Type != ResourceTypeDockerVolume || volumeResource.Docker == nil || volumeResource.Docker.VolumeName != "app-data" || volumeResource.Docker.SizeBytes != 2048 {
t.Fatalf("unexpected volume resource: %+v", volumeResource)
}
networkResource, _ := resourceFromDockerNetwork(models.DockerNetwork{
ID: "net1", Name: "app-net", Driver: "bridge", EnableIPv4: true, Subnets: []models.DockerNetworkSubnet{{Subnet: "10.88.0.0/24", Gateway: "10.88.0.1"}},
}, host)
if networkResource.Type != ResourceTypeDockerNetwork || networkResource.Docker == nil || networkResource.Docker.NetworkID != "net1" || networkResource.Docker.Subnets[0].Gateway != "10.88.0.1" {
t.Fatalf("unexpected network resource: %+v", networkResource)
}
taskResource, taskIdentity := resourceFromDockerTask(models.DockerTask{
ID: "task-1", ServiceName: "api", ServiceID: "svc-1", Slot: 2, DesiredState: "running", CurrentState: "running", NodeName: "edge",
}, host)
if taskResource.Type != ResourceTypeDockerTask || taskResource.Name != "api.2" || taskResource.Status != StatusOnline || taskIdentity.Hostnames[0] != "api.2" {
t.Fatalf("unexpected task resource: resource=%+v identity=%+v", taskResource, taskIdentity)
}
}
func TestKubernetesNativeInventoryAdapters(t *testing.T) {
now := time.Date(2026, 5, 24, 8, 0, 0, 0, time.UTC)
cluster := models.KubernetesCluster{ID: "cluster-1", Name: "prod", DisplayName: "Production", LastSeen: now}
service, serviceIdentity := resourceFromKubernetesService(cluster, models.KubernetesService{
UID: "svc-1", Name: "checkout", Namespace: "services", ServiceType: "ClusterIP", ClusterIP: "10.96.0.10", Ports: []models.KubernetesServicePort{{Name: "http", Protocol: "TCP", Port: 80, TargetPort: "8080"}},
}, nil)
if service.Type != ResourceTypeK8sService || service.Kubernetes == nil || service.Kubernetes.ServiceType != "ClusterIP" || serviceIdentity.ClusterName != "Production" {
t.Fatalf("unexpected service resource: resource=%+v identity=%+v", service, serviceIdentity)
}
statefulSet, _ := resourceFromKubernetesStatefulSet(cluster, models.KubernetesStatefulSet{Name: "db", Namespace: "services", DesiredReplicas: 3, ReadyReplicas: 2}, nil)
if statefulSet.Type != ResourceTypeK8sStatefulSet || statefulSet.Status != StatusWarning {
t.Fatalf("unexpected statefulset resource: %+v", statefulSet)
}
daemonSet, _ := resourceFromKubernetesDaemonSet(cluster, models.KubernetesDaemonSet{Name: "node-agent", Namespace: "kube-system", DesiredNumberScheduled: 3, NumberReady: 3}, nil)
if daemonSet.Type != ResourceTypeK8sDaemonSet || daemonSet.Status != StatusOnline {
t.Fatalf("unexpected daemonset resource: %+v", daemonSet)
}
job, _ := resourceFromKubernetesJob(cluster, models.KubernetesJob{Name: "backup", Namespace: "services", DesiredCompletions: 1, Succeeded: 1}, nil)
if job.Type != ResourceTypeK8sJob || job.Status != StatusOnline {
t.Fatalf("unexpected job resource: %+v", job)
}
cronJob, _ := resourceFromKubernetesCronJob(cluster, models.KubernetesCronJob{Name: "nightly", Namespace: "services", Schedule: "0 1 * * *"}, nil)
if cronJob.Type != ResourceTypeK8sCronJob || cronJob.Kubernetes == nil || cronJob.Kubernetes.Schedule != "0 1 * * *" {
t.Fatalf("unexpected cronjob resource: %+v", cronJob)
}
ingress, _ := resourceFromKubernetesIngress(cluster, models.KubernetesIngress{Name: "checkout", Namespace: "services", Hosts: []string{"checkout.example.test"}}, nil)
if ingress.Type != ResourceTypeK8sIngress || ingress.Kubernetes == nil || ingress.Kubernetes.Hosts[0] != "checkout.example.test" {
t.Fatalf("unexpected ingress resource: %+v", ingress)
}
pv, _ := resourceFromKubernetesPersistentVolume(cluster, models.KubernetesPersistentVolume{Name: "pv-checkout", Phase: "Bound", CapacityBytes: 10_000, ClaimName: "checkout-data"}, nil)
if pv.Type != ResourceTypeK8sPV || pv.Kubernetes == nil || pv.Kubernetes.ClaimName != "checkout-data" {
t.Fatalf("unexpected pv resource: %+v", pv)
}
pvc, _ := resourceFromKubernetesPersistentVolumeClaim(cluster, models.KubernetesPersistentVolumeClaim{Name: "checkout-data", Namespace: "services", Phase: "Bound", VolumeName: "pv-checkout"}, nil)
if pvc.Type != ResourceTypeK8sPVC || pvc.Kubernetes == nil || pvc.Kubernetes.VolumeName != "pv-checkout" {
t.Fatalf("unexpected pvc resource: %+v", pvc)
}
event, _ := resourceFromKubernetesEvent(cluster, models.KubernetesEvent{Name: "checkout.1", Namespace: "services", EventType: "Warning", Reason: "BackOff", Count: 2}, nil)
if event.Type != ResourceTypeK8sEvent || event.Status != StatusWarning || event.Kubernetes == nil || event.Kubernetes.Reason != "BackOff" {
t.Fatalf("unexpected event resource: %+v", event)
}
}
func TestUnifiedAIAdapterGetTopByMetric(t *testing.T) {
registry := testRegistry(
Resource{
+522
View File
@@ -1,6 +1,7 @@
package unifiedresources
import (
"fmt"
"net"
"net/url"
"strconv"
@@ -1150,6 +1151,9 @@ func resourceFromDockerHost(host models.DockerHost) (Resource, ResourceIdentity)
DiskReadRate: host.DiskReadRate,
DiskWriteRate: host.DiskWriteRate,
ContainerCount: len(host.Containers),
ImageCount: len(host.Images),
VolumeCount: len(host.Volumes),
NetworkCount: len(host.Networks),
UpdatesAvailableCount: updatesAvailableCount,
UpdatesLastCheckedAt: updatesLastCheckedPtr,
TokenID: host.TokenID,
@@ -1164,9 +1168,18 @@ func resourceFromDockerHost(host models.DockerHost) (Resource, ResourceIdentity)
NetworkInterfaces: convertInterfaces(host.NetworkInterfaces),
Disks: convertDisks(host.Disks),
Containers: append([]models.DockerContainer(nil), host.Containers...),
Images: append([]models.DockerImage(nil), host.Images...),
Volumes: append([]models.DockerVolume(nil), host.Volumes...),
NetworksRaw: append([]models.DockerNetwork(nil), host.Networks...),
Services: append([]models.DockerService(nil), host.Services...),
Tasks: append([]models.DockerTask(nil), host.Tasks...),
}
if host.StorageUsage != nil {
docker.ImagesUsage = dockerStorageUsageMeta(host.StorageUsage.Images)
docker.ContainersUsage = dockerStorageUsageMeta(host.StorageUsage.Containers)
docker.VolumesUsage = dockerStorageUsageMeta(host.StorageUsage.Volumes)
docker.BuildCacheUsage = dockerStorageUsageMeta(host.StorageUsage.BuildCache)
}
metrics := metricsFromDockerHost(host)
@@ -2023,6 +2036,192 @@ func resourceFromDockerService(service models.DockerService, host models.DockerH
return resource, identity
}
func resourceFromDockerImage(image models.DockerImage, host models.DockerHost) (Resource, ResourceIdentity) {
name := firstNonEmpty(firstDockerImageReference(image), shortDigest(image.ID), image.ID)
labels := cloneLabelMap(image.Labels)
docker := &DockerData{
HostSourceID: host.ID,
Hostname: host.Hostname,
ImageID: strings.TrimSpace(image.ID),
Image: name,
RepoTags: append([]string(nil), image.RepoTags...),
RepoDigests: append([]string(nil), image.RepoDigests...),
SizeBytes: image.SizeBytes,
SharedSizeBytes: image.SharedSizeBytes,
ImageContainers: image.Containers,
Runtime: strings.TrimSpace(host.Runtime),
RuntimeVersion: strings.TrimSpace(host.RuntimeVersion),
DockerVersion: strings.TrimSpace(host.DockerVersion),
Labels: labels,
}
resource := Resource{
Type: ResourceTypeDockerImage,
Technology: firstNonEmpty(strings.TrimSpace(host.Runtime), "docker"),
Name: name,
Status: StatusOnline,
LastSeen: host.LastSeen,
UpdatedAt: time.Now().UTC(),
Docker: docker,
Tags: labelsToTags(labels),
}
identity := ResourceIdentity{
Hostnames: uniqueStrings([]string{name, host.Hostname + ":" + name}),
}
return resource, identity
}
func resourceFromDockerVolume(volume models.DockerVolume, host models.DockerHost) (Resource, ResourceIdentity) {
name := strings.TrimSpace(volume.Name)
labels := cloneLabelMap(volume.Labels)
docker := &DockerData{
HostSourceID: host.ID,
Hostname: host.Hostname,
VolumeName: name,
Driver: strings.TrimSpace(volume.Driver),
Mountpoint: strings.TrimSpace(volume.Mountpoint),
Scope: strings.TrimSpace(volume.Scope),
CreatedAt: strings.TrimSpace(volume.CreatedAt),
SizeBytes: volume.SizeBytes,
RefCount: volume.RefCount,
Runtime: strings.TrimSpace(host.Runtime),
Labels: labels,
Options: cloneLabelMap(volume.Options),
}
resource := Resource{
Type: ResourceTypeDockerVolume,
Technology: firstNonEmpty(strings.TrimSpace(host.Runtime), "docker"),
Name: name,
Status: StatusOnline,
LastSeen: host.LastSeen,
UpdatedAt: time.Now().UTC(),
Docker: docker,
Tags: labelsToTags(labels),
}
identity := ResourceIdentity{
Hostnames: uniqueStrings([]string{name, host.Hostname + ":" + name}),
}
return resource, identity
}
func resourceFromDockerNetwork(network models.DockerNetwork, host models.DockerHost) (Resource, ResourceIdentity) {
name := strings.TrimSpace(network.Name)
labels := cloneLabelMap(network.Labels)
subnets := make([]DockerNetworkSubnetMeta, 0, len(network.Subnets))
for _, subnet := range network.Subnets {
subnets = append(subnets, DockerNetworkSubnetMeta{
Subnet: strings.TrimSpace(subnet.Subnet),
Gateway: strings.TrimSpace(subnet.Gateway),
})
}
docker := &DockerData{
HostSourceID: host.ID,
Hostname: host.Hostname,
NetworkID: strings.TrimSpace(network.ID),
Driver: strings.TrimSpace(network.Driver),
Scope: strings.TrimSpace(network.Scope),
EnableIPv4: network.EnableIPv4,
EnableIPv6: network.EnableIPv6,
Internal: network.Internal,
Attachable: network.Attachable,
Ingress: network.Ingress,
ConfigOnly: network.ConfigOnly,
Runtime: strings.TrimSpace(host.Runtime),
Labels: labels,
Options: cloneLabelMap(network.Options),
Subnets: subnets,
}
resource := Resource{
Type: ResourceTypeDockerNetwork,
Technology: firstNonEmpty(strings.TrimSpace(host.Runtime), "docker"),
Name: name,
Status: StatusOnline,
LastSeen: host.LastSeen,
UpdatedAt: time.Now().UTC(),
Docker: docker,
Tags: labelsToTags(labels),
}
identity := ResourceIdentity{
Hostnames: uniqueStrings([]string{name, host.Hostname + ":" + name}),
}
return resource, identity
}
func resourceFromDockerTask(task models.DockerTask, host models.DockerHost) (Resource, ResourceIdentity) {
name := firstNonEmpty(strings.TrimSpace(task.ServiceName), strings.TrimSpace(task.ContainerName), shortDigest(task.ID), task.ID)
if task.Slot > 0 && task.ServiceName != "" {
name = fmt.Sprintf("%s.%d", task.ServiceName, task.Slot)
}
docker := &DockerData{
HostSourceID: host.ID,
Hostname: host.Hostname,
TaskID: strings.TrimSpace(task.ID),
ServiceID: strings.TrimSpace(task.ServiceID),
ServiceName: strings.TrimSpace(task.ServiceName),
Slot: task.Slot,
NodeID: strings.TrimSpace(task.NodeID),
NodeName: strings.TrimSpace(task.NodeName),
DesiredState: strings.TrimSpace(task.DesiredState),
CurrentState: strings.TrimSpace(task.CurrentState),
Error: strings.TrimSpace(task.Error),
Message: strings.TrimSpace(task.Message),
ContainerID: strings.TrimSpace(task.ContainerID),
ContainerState: strings.TrimSpace(task.CurrentState),
StartedAt: task.StartedAt,
CompletedAt: task.CompletedAt,
Swarm: convertSwarm(host.Swarm),
}
resource := Resource{
Type: ResourceTypeDockerTask,
Technology: "docker",
Name: name,
Status: statusFromDockerTask(task),
LastSeen: host.LastSeen,
UpdatedAt: time.Now().UTC(),
Docker: docker,
}
identity := ResourceIdentity{
Hostnames: uniqueStrings([]string{name, strings.TrimSpace(task.ID)}),
ClusterName: dockerSwarmClusterKeyFromMeta(docker.Swarm),
}
return resource, identity
}
func firstDockerImageReference(image models.DockerImage) string {
for _, candidate := range image.RepoTags {
candidate = strings.TrimSpace(candidate)
if candidate != "" && candidate != "<none>:<none>" {
return candidate
}
}
for _, candidate := range image.RepoDigests {
if value := strings.TrimSpace(candidate); value != "" {
return value
}
}
return ""
}
func shortDigest(value string) string {
value = strings.TrimSpace(value)
value = strings.TrimPrefix(value, "sha256:")
if len(value) > 12 {
return value[:12]
}
return value
}
func dockerStorageUsageMeta(bucket models.DockerStorageUsageBucket) *DockerStorageUsageMeta {
if bucket.TotalCount == 0 && bucket.ActiveCount == 0 && bucket.TotalSizeBytes == 0 && bucket.ReclaimableBytes == 0 {
return nil
}
return &DockerStorageUsageMeta{
TotalCount: bucket.TotalCount,
ActiveCount: bucket.ActiveCount,
TotalSizeBytes: bucket.TotalSizeBytes,
ReclaimableBytes: bucket.ReclaimableBytes,
}
}
func resourceFromKubernetesCluster(cluster models.KubernetesCluster, linkedHosts []*models.Host, capabilities *K8sMetricCapabilities) (Resource, ResourceIdentity) {
clusterName := kubernetesClusterDisplayName(cluster)
metrics := metricsFromKubernetesCluster(cluster, linkedHosts)
@@ -2218,6 +2417,329 @@ func resourceFromKubernetesDeployment(cluster models.KubernetesCluster, deployme
return resource, identity
}
func baseKubernetesData(cluster models.KubernetesCluster, clusterName, resourceKind string, capabilities *K8sMetricCapabilities) K8sData {
return K8sData{
ClusterID: cluster.ID,
ClusterName: clusterName,
ResourceKind: resourceKind,
AgentID: cluster.AgentID,
Context: cluster.Context,
Server: cluster.Server,
Version: cluster.Version,
MetricCapabilities: cloneKubernetesMetricCapabilities(capabilities),
}
}
func resourceFromKubernetesNamespace(cluster models.KubernetesCluster, namespace models.KubernetesNamespace, capabilities *K8sMetricCapabilities) (Resource, ResourceIdentity) {
clusterName := kubernetesClusterDisplayName(cluster)
labels := cloneLabelMap(namespace.Labels)
data := baseKubernetesData(cluster, clusterName, "Namespace", capabilities)
data.NamespaceUID = namespace.UID
data.ResourceUID = namespace.UID
data.Namespace = namespace.Name
data.Phase = namespace.Phase
data.Labels = labels
data.CreatedAt = zeroTimeToPtr(namespace.CreatedAt)
resource := Resource{
Type: ResourceTypeK8sNamespace,
Technology: "kubernetes",
Name: namespace.Name,
Status: statusFromKubernetesPhase(namespace.Phase),
LastSeen: cluster.LastSeen,
UpdatedAt: time.Now().UTC(),
Kubernetes: &data,
Tags: labelsToTags(labels),
}
identity := ResourceIdentity{
Hostnames: uniqueStrings([]string{namespace.Name, clusterName + ":" + namespace.Name}),
ClusterName: clusterName,
}
return resource, identity
}
func resourceFromKubernetesService(cluster models.KubernetesCluster, service models.KubernetesService, capabilities *K8sMetricCapabilities) (Resource, ResourceIdentity) {
clusterName := kubernetesClusterDisplayName(cluster)
labels := cloneLabelMap(service.Labels)
data := baseKubernetesData(cluster, clusterName, "Service", capabilities)
data.ServiceUID = service.UID
data.ResourceUID = service.UID
data.Namespace = service.Namespace
data.ServiceType = service.ServiceType
data.ClusterIP = service.ClusterIP
data.ExternalIPs = append([]string(nil), service.ExternalIPs...)
data.ServicePorts = k8sServicePorts(service.Ports)
data.Selector = cloneLabelMap(service.Selector)
data.Labels = labels
data.CreatedAt = zeroTimeToPtr(service.CreatedAt)
resource := Resource{
Type: ResourceTypeK8sService,
Technology: "kubernetes",
Name: service.Name,
Status: StatusOnline,
LastSeen: cluster.LastSeen,
UpdatedAt: time.Now().UTC(),
Kubernetes: &data,
Tags: labelsToTags(labels),
}
identity := namespacedKubernetesIdentity(clusterName, service.Namespace, service.Name)
return resource, identity
}
func resourceFromKubernetesStatefulSet(cluster models.KubernetesCluster, statefulSet models.KubernetesStatefulSet, capabilities *K8sMetricCapabilities) (Resource, ResourceIdentity) {
clusterName := kubernetesClusterDisplayName(cluster)
labels := cloneLabelMap(statefulSet.Labels)
data := baseKubernetesData(cluster, clusterName, "StatefulSet", capabilities)
data.StatefulSetUID = statefulSet.UID
data.ResourceUID = statefulSet.UID
data.Namespace = statefulSet.Namespace
data.DesiredReplicas = statefulSet.DesiredReplicas
data.ReadyReplicas = statefulSet.ReadyReplicas
data.CurrentReplicas = statefulSet.CurrentReplicas
data.UpdatedReplicas = statefulSet.UpdatedReplicas
data.AvailableReplicas = statefulSet.AvailableReplicas
data.ServiceName = statefulSet.ServiceName
data.Labels = labels
resource := Resource{
Type: ResourceTypeK8sStatefulSet,
Technology: "kubernetes",
Name: statefulSet.Name,
Status: statusFromKubernetesStatefulSet(statefulSet),
LastSeen: cluster.LastSeen,
UpdatedAt: time.Now().UTC(),
Kubernetes: &data,
Tags: labelsToTags(labels),
}
return resource, namespacedKubernetesIdentity(clusterName, statefulSet.Namespace, statefulSet.Name)
}
func resourceFromKubernetesDaemonSet(cluster models.KubernetesCluster, daemonSet models.KubernetesDaemonSet, capabilities *K8sMetricCapabilities) (Resource, ResourceIdentity) {
clusterName := kubernetesClusterDisplayName(cluster)
labels := cloneLabelMap(daemonSet.Labels)
data := baseKubernetesData(cluster, clusterName, "DaemonSet", capabilities)
data.DaemonSetUID = daemonSet.UID
data.ResourceUID = daemonSet.UID
data.Namespace = daemonSet.Namespace
data.DesiredNumberScheduled = daemonSet.DesiredNumberScheduled
data.CurrentNumberScheduled = daemonSet.CurrentNumberScheduled
data.NumberReady = daemonSet.NumberReady
data.UpdatedReplicas = daemonSet.UpdatedNumberScheduled
data.NumberAvailable = daemonSet.NumberAvailable
data.NumberUnavailable = daemonSet.NumberUnavailable
data.NumberMisscheduled = daemonSet.NumberMisscheduled
data.Labels = labels
resource := Resource{
Type: ResourceTypeK8sDaemonSet,
Technology: "kubernetes",
Name: daemonSet.Name,
Status: statusFromKubernetesDaemonSet(daemonSet),
LastSeen: cluster.LastSeen,
UpdatedAt: time.Now().UTC(),
Kubernetes: &data,
Tags: labelsToTags(labels),
}
return resource, namespacedKubernetesIdentity(clusterName, daemonSet.Namespace, daemonSet.Name)
}
func resourceFromKubernetesJob(cluster models.KubernetesCluster, job models.KubernetesJob, capabilities *K8sMetricCapabilities) (Resource, ResourceIdentity) {
clusterName := kubernetesClusterDisplayName(cluster)
labels := cloneLabelMap(job.Labels)
data := baseKubernetesData(cluster, clusterName, "Job", capabilities)
data.JobUID = job.UID
data.ResourceUID = job.UID
data.Namespace = job.Namespace
data.DesiredReplicas = job.DesiredCompletions
data.Succeeded = job.Succeeded
data.Failed = job.Failed
data.Active = job.Active
data.StartTime = job.StartTime
data.CompletionTime = job.CompletionTime
data.Labels = labels
resource := Resource{
Type: ResourceTypeK8sJob,
Technology: "kubernetes",
Name: job.Name,
Status: statusFromKubernetesJob(job),
LastSeen: cluster.LastSeen,
UpdatedAt: time.Now().UTC(),
Kubernetes: &data,
Tags: labelsToTags(labels),
}
return resource, namespacedKubernetesIdentity(clusterName, job.Namespace, job.Name)
}
func resourceFromKubernetesCronJob(cluster models.KubernetesCluster, cronJob models.KubernetesCronJob, capabilities *K8sMetricCapabilities) (Resource, ResourceIdentity) {
clusterName := kubernetesClusterDisplayName(cluster)
labels := cloneLabelMap(cronJob.Labels)
data := baseKubernetesData(cluster, clusterName, "CronJob", capabilities)
data.CronJobUID = cronJob.UID
data.ResourceUID = cronJob.UID
data.Namespace = cronJob.Namespace
data.Schedule = cronJob.Schedule
data.Suspend = cronJob.Suspend
data.Active = int32(cronJob.Active)
data.LastScheduleTime = cronJob.LastScheduleTime
data.LastSuccessfulTime = cronJob.LastSuccessfulTime
data.Labels = labels
resource := Resource{
Type: ResourceTypeK8sCronJob,
Technology: "kubernetes",
Name: cronJob.Name,
Status: statusFromKubernetesCronJob(cronJob),
LastSeen: cluster.LastSeen,
UpdatedAt: time.Now().UTC(),
Kubernetes: &data,
Tags: labelsToTags(labels),
}
return resource, namespacedKubernetesIdentity(clusterName, cronJob.Namespace, cronJob.Name)
}
func resourceFromKubernetesIngress(cluster models.KubernetesCluster, ingress models.KubernetesIngress, capabilities *K8sMetricCapabilities) (Resource, ResourceIdentity) {
clusterName := kubernetesClusterDisplayName(cluster)
labels := cloneLabelMap(ingress.Labels)
data := baseKubernetesData(cluster, clusterName, "Ingress", capabilities)
data.IngressUID = ingress.UID
data.ResourceUID = ingress.UID
data.Namespace = ingress.Namespace
data.ClassName = ingress.ClassName
data.Hosts = append([]string(nil), ingress.Hosts...)
data.Addresses = append([]string(nil), ingress.Addresses...)
data.Labels = labels
data.CreatedAt = zeroTimeToPtr(ingress.CreatedAt)
resource := Resource{
Type: ResourceTypeK8sIngress,
Technology: "kubernetes",
Name: ingress.Name,
Status: StatusOnline,
LastSeen: cluster.LastSeen,
UpdatedAt: time.Now().UTC(),
Kubernetes: &data,
Tags: labelsToTags(labels),
}
return resource, namespacedKubernetesIdentity(clusterName, ingress.Namespace, ingress.Name)
}
func resourceFromKubernetesPersistentVolume(cluster models.KubernetesCluster, volume models.KubernetesPersistentVolume, capabilities *K8sMetricCapabilities) (Resource, ResourceIdentity) {
clusterName := kubernetesClusterDisplayName(cluster)
labels := cloneLabelMap(volume.Labels)
data := baseKubernetesData(cluster, clusterName, "PersistentVolume", capabilities)
data.PersistentVolumeUID = volume.UID
data.ResourceUID = volume.UID
data.Phase = volume.Phase
data.StorageClass = volume.StorageClass
data.CapacityBytes = volume.CapacityBytes
data.AccessModes = append([]string(nil), volume.AccessModes...)
data.ReclaimPolicy = volume.ReclaimPolicy
data.ClaimNamespace = volume.ClaimNamespace
data.ClaimName = volume.ClaimName
data.Labels = labels
data.CreatedAt = zeroTimeToPtr(volume.CreatedAt)
resource := Resource{
Type: ResourceTypeK8sPV,
Technology: "kubernetes",
Name: volume.Name,
Status: statusFromKubernetesPhase(volume.Phase),
LastSeen: cluster.LastSeen,
UpdatedAt: time.Now().UTC(),
Kubernetes: &data,
Tags: labelsToTags(labels),
}
identity := ResourceIdentity{
Hostnames: uniqueStrings([]string{volume.Name, clusterName + ":" + volume.Name}),
ClusterName: clusterName,
}
return resource, identity
}
func resourceFromKubernetesPersistentVolumeClaim(cluster models.KubernetesCluster, claim models.KubernetesPersistentVolumeClaim, capabilities *K8sMetricCapabilities) (Resource, ResourceIdentity) {
clusterName := kubernetesClusterDisplayName(cluster)
labels := cloneLabelMap(claim.Labels)
data := baseKubernetesData(cluster, clusterName, "PersistentVolumeClaim", capabilities)
data.PersistentVolumeClaimUID = claim.UID
data.ResourceUID = claim.UID
data.Namespace = claim.Namespace
data.Phase = claim.Phase
data.StorageClass = claim.StorageClass
data.RequestedBytes = claim.RequestedBytes
data.CapacityBytes = claim.CapacityBytes
data.AccessModes = append([]string(nil), claim.AccessModes...)
data.VolumeName = claim.VolumeName
data.Labels = labels
data.CreatedAt = zeroTimeToPtr(claim.CreatedAt)
resource := Resource{
Type: ResourceTypeK8sPVC,
Technology: "kubernetes",
Name: claim.Name,
Status: statusFromKubernetesPhase(claim.Phase),
LastSeen: cluster.LastSeen,
UpdatedAt: time.Now().UTC(),
Kubernetes: &data,
Tags: labelsToTags(labels),
}
return resource, namespacedKubernetesIdentity(clusterName, claim.Namespace, claim.Name)
}
func resourceFromKubernetesEvent(cluster models.KubernetesCluster, event models.KubernetesEvent, capabilities *K8sMetricCapabilities) (Resource, ResourceIdentity) {
clusterName := kubernetesClusterDisplayName(cluster)
data := baseKubernetesData(cluster, clusterName, "Event", capabilities)
data.EventUID = event.UID
data.ResourceUID = event.UID
data.Namespace = event.Namespace
data.EventType = event.EventType
data.Reason = event.Reason
data.Message = event.Message
data.InvolvedKind = event.InvolvedKind
data.InvolvedName = event.InvolvedName
data.Count = event.Count
data.FirstSeen = event.FirstSeen
data.EventTime = event.EventTime
if event.LastSeen != nil {
data.CreatedAt = event.LastSeen
}
resource := Resource{
Type: ResourceTypeK8sEvent,
Technology: "kubernetes",
Name: firstNonEmpty(event.Name, event.Reason, event.InvolvedName),
Status: statusFromKubernetesEvent(event),
LastSeen: cluster.LastSeen,
UpdatedAt: time.Now().UTC(),
Kubernetes: &data,
}
return resource, namespacedKubernetesIdentity(clusterName, event.Namespace, resource.Name)
}
func k8sServicePorts(in []models.KubernetesServicePort) []K8sServicePort {
if len(in) == 0 {
return nil
}
out := make([]K8sServicePort, 0, len(in))
for _, port := range in {
out = append(out, K8sServicePort{
Name: strings.TrimSpace(port.Name),
Protocol: strings.TrimSpace(port.Protocol),
Port: port.Port,
TargetPort: strings.TrimSpace(port.TargetPort),
NodePort: port.NodePort,
})
}
return out
}
func namespacedKubernetesIdentity(clusterName, namespace, name string) ResourceIdentity {
namespace = strings.TrimSpace(namespace)
name = strings.TrimSpace(name)
values := []string{name}
if namespace != "" && name != "" {
values = append(values, namespace+"/"+name)
}
if clusterName != "" && name != "" {
values = append(values, clusterName+":"+name)
}
return ResourceIdentity{
Hostnames: uniqueStrings(values),
ClusterName: clusterName,
}
}
func kubernetesClusterDisplayName(cluster models.KubernetesCluster) string {
if v := strings.TrimSpace(cluster.CustomDisplayName); v != "" {
return v
@@ -75,6 +75,26 @@ func TestContractResourceType(t *testing.T) {
},
want: ResourceTypeNetworkShare,
},
{
name: "docker image passthrough remains canonical",
resource: Resource{Type: ResourceTypeDockerImage, Docker: &DockerData{ImageID: "sha256:image1"}},
want: ResourceTypeDockerImage,
},
{
name: "docker volume passthrough remains canonical",
resource: Resource{Type: ResourceTypeDockerVolume, Docker: &DockerData{VolumeName: "app-data"}},
want: ResourceTypeDockerVolume,
},
{
name: "kubernetes service passthrough remains canonical",
resource: Resource{Type: ResourceTypeK8sService, Kubernetes: &K8sData{ServiceType: "ClusterIP"}},
want: ResourceTypeK8sService,
},
{
name: "kubernetes event passthrough remains canonical",
resource: Resource{Type: ResourceTypeK8sEvent, Kubernetes: &K8sData{Reason: "BackOff"}},
want: ResourceTypeK8sEvent,
},
}
for _, tt := range tests {
+53
View File
@@ -199,16 +199,30 @@ func cloneDockerData(in *DockerData) *DockerData {
out.Temperature = cloneFloat64Ptr(in.Temperature)
out.LoadAverage = cloneFloat64Slice(in.LoadAverage)
out.TokenLastUsedAt = cloneTimePtr(in.TokenLastUsedAt)
out.UpdatesLastCheckedAt = cloneTimePtr(in.UpdatesLastCheckedAt)
out.ImagesUsage = cloneDockerStorageUsageMeta(in.ImagesUsage)
out.ContainersUsage = cloneDockerStorageUsageMeta(in.ContainersUsage)
out.VolumesUsage = cloneDockerStorageUsageMeta(in.VolumesUsage)
out.BuildCacheUsage = cloneDockerStorageUsageMeta(in.BuildCacheUsage)
out.Ports = cloneDockerPortMetaSlice(in.Ports)
out.Labels = cloneStringMap(in.Labels)
out.Networks = cloneDockerNetworkMetaSlice(in.Networks)
out.Mounts = cloneDockerMountMetaSlice(in.Mounts)
out.UpdateStatus = cloneDockerUpdateStatusMeta(in.UpdateStatus)
out.RepoTags = cloneStringSlice(in.RepoTags)
out.RepoDigests = cloneStringSlice(in.RepoDigests)
out.Options = cloneStringMap(in.Options)
out.Subnets = cloneDockerNetworkSubnetMetaSlice(in.Subnets)
out.StartedAt = cloneTimePtr(in.StartedAt)
out.CompletedAt = cloneTimePtr(in.CompletedAt)
out.Command = cloneDockerHostCommandStatus(in.Command)
out.Swarm = cloneDockerSwarmInfo(in.Swarm)
out.NetworkInterfaces = cloneNetworkInterfaces(in.NetworkInterfaces)
out.Disks = cloneDiskInfos(in.Disks)
out.Containers = append([]models.DockerContainer(nil), in.Containers...)
out.Images = append([]models.DockerImage(nil), in.Images...)
out.Volumes = append([]models.DockerVolume(nil), in.Volumes...)
out.NetworksRaw = append([]models.DockerNetwork(nil), in.NetworksRaw...)
out.Services = append([]models.DockerService(nil), in.Services...)
out.Tasks = append([]models.DockerTask(nil), in.Tasks...)
return &out
@@ -398,11 +412,33 @@ func cloneK8sData(in *K8sData) *K8sData {
out.Roles = cloneStringSlice(in.Roles)
out.Temperature = cloneFloat64Ptr(in.Temperature)
out.Labels = cloneStringMap(in.Labels)
out.ExternalIPs = cloneStringSlice(in.ExternalIPs)
out.ServicePorts = cloneK8sServicePortSlice(in.ServicePorts)
out.Selector = cloneStringMap(in.Selector)
out.LastScheduleTime = cloneTimePtr(in.LastScheduleTime)
out.LastSuccessfulTime = cloneTimePtr(in.LastSuccessfulTime)
out.StartTime = cloneTimePtr(in.StartTime)
out.CompletionTime = cloneTimePtr(in.CompletionTime)
out.Hosts = cloneStringSlice(in.Hosts)
out.Addresses = cloneStringSlice(in.Addresses)
out.AccessModes = cloneStringSlice(in.AccessModes)
out.FirstSeen = cloneTimePtr(in.FirstSeen)
out.EventTime = cloneTimePtr(in.EventTime)
out.CreatedAt = cloneTimePtr(in.CreatedAt)
out.MetricCapabilities = cloneKubernetesMetricCapabilities(in.MetricCapabilities)
out.PodContainers = cloneK8sPodContainerSlice(in.PodContainers)
return &out
}
func cloneK8sServicePortSlice(in []K8sServicePort) []K8sServicePort {
if len(in) == 0 {
return nil
}
out := make([]K8sServicePort, len(in))
copy(out, in)
return out
}
func cloneK8sPodContainerSlice(in []K8sPodContainer) []K8sPodContainer {
if len(in) == 0 {
return nil
@@ -620,6 +656,14 @@ func cloneDockerUpdateStatusMeta(in *DockerUpdateStatusMeta) *DockerUpdateStatus
return &out
}
func cloneDockerStorageUsageMeta(in *DockerStorageUsageMeta) *DockerStorageUsageMeta {
if in == nil {
return nil
}
out := *in
return &out
}
func cloneDockerSwarmInfo(in *DockerSwarmInfo) *DockerSwarmInfo {
if in == nil {
return nil
@@ -814,6 +858,15 @@ func cloneDockerNetworkMetaSlice(in []DockerNetworkMeta) []DockerNetworkMeta {
return out
}
func cloneDockerNetworkSubnetMetaSlice(in []DockerNetworkSubnetMeta) []DockerNetworkSubnetMeta {
if in == nil {
return nil
}
out := make([]DockerNetworkSubnetMeta, len(in))
copy(out, in)
return out
}
func cloneDockerMountMetaSlice(in []DockerMountMeta) []DockerMountMeta {
if in == nil {
return nil
@@ -55,6 +55,28 @@ func TestIngestSnapshotIncludesKubernetesHierarchy(t *testing.T) {
AvailableReplicas: 2,
},
},
Namespaces: []models.KubernetesNamespace{{UID: "namespace-uid-1", Name: "default", Phase: "Active"}},
Services: []models.KubernetesService{{
UID: "service-uid-1", Name: "api", Namespace: "default", ServiceType: "ClusterIP", ClusterIP: "10.96.0.10",
}},
StatefulSets: []models.KubernetesStatefulSet{{
UID: "statefulset-uid-1", Name: "db", Namespace: "default", DesiredReplicas: 3, ReadyReplicas: 2,
}},
DaemonSets: []models.KubernetesDaemonSet{{
UID: "daemonset-uid-1", Name: "node-agent", Namespace: "kube-system", DesiredNumberScheduled: 1, NumberReady: 1,
}},
Jobs: []models.KubernetesJob{{UID: "job-uid-1", Name: "backup", Namespace: "default", DesiredCompletions: 1, Succeeded: 1}},
CronJobs: []models.KubernetesCronJob{{UID: "cronjob-uid-1", Name: "nightly", Namespace: "default", Schedule: "0 1 * * *"}},
Ingresses: []models.KubernetesIngress{{
UID: "ingress-uid-1", Name: "api", Namespace: "default", Hosts: []string{"api.example.test"},
}},
PersistentVolumes: []models.KubernetesPersistentVolume{{
UID: "pv-uid-1", Name: "pv-api", Phase: "Bound", ClaimNamespace: "default", ClaimName: "api-data",
}},
PersistentVolumeClaims: []models.KubernetesPersistentVolumeClaim{{
UID: "pvc-uid-1", Name: "api-data", Namespace: "default", Phase: "Bound", VolumeName: "pv-api",
}},
Events: []models.KubernetesEvent{{UID: "event-uid-1", Name: "api.1", Namespace: "default", EventType: "Warning", Reason: "BackOff", Count: 2}},
},
},
}
@@ -63,17 +85,19 @@ func TestIngestSnapshotIncludesKubernetesHierarchy(t *testing.T) {
registry.IngestSnapshot(snapshot)
resources := registry.List()
if len(resources) != 4 {
t.Fatalf("expected 4 kubernetes resources, got %d", len(resources))
if len(resources) != 14 {
t.Fatalf("expected 14 kubernetes resources, got %d", len(resources))
}
var clusterResource *Resource
var nodeResource *Resource
var podResource *Resource
var deploymentResource *Resource
seenTypes := make(map[ResourceType]int)
for i := range resources {
resource := resources[i]
seenTypes[resource.Type]++
switch resource.Type {
case ResourceTypeK8sCluster:
clusterResource = &resource
@@ -85,6 +109,22 @@ func TestIngestSnapshotIncludesKubernetesHierarchy(t *testing.T) {
deploymentResource = &resource
}
}
for _, resourceType := range []ResourceType{
ResourceTypeK8sNamespace,
ResourceTypeK8sService,
ResourceTypeK8sStatefulSet,
ResourceTypeK8sDaemonSet,
ResourceTypeK8sJob,
ResourceTypeK8sCronJob,
ResourceTypeK8sIngress,
ResourceTypeK8sPV,
ResourceTypeK8sPVC,
ResourceTypeK8sEvent,
} {
if seenTypes[resourceType] != 1 {
t.Fatalf("expected one %s resource, got counts %#v", resourceType, seenTypes)
}
}
if clusterResource == nil {
t.Fatal("expected kubernetes cluster resource")
+423
View File
@@ -179,6 +179,15 @@ func (rr *ResourceRegistry) IngestSnapshot(snapshot models.StateSnapshot) {
for _, dc := range dh.Containers {
rr.ingestDockerContainer(dc, dh)
}
for _, image := range dh.Images {
rr.ingestDockerImage(image, dh)
}
for _, volume := range dh.Volumes {
rr.ingestDockerVolume(volume, dh)
}
for _, network := range dh.Networks {
rr.ingestDockerNetwork(network, dh)
}
}
// Swarm services are cluster-scoped; multiple nodes can report identical
@@ -221,6 +230,11 @@ func (rr *ResourceRegistry) IngestSnapshot(snapshot models.StateSnapshot) {
for _, candidate := range serviceByID {
rr.ingestDockerService(candidate.service, candidate.host)
}
for _, dh := range snapshot.DockerHosts {
for _, task := range dh.Tasks {
rr.ingestDockerTask(task, dh)
}
}
kubernetesHostLookup := buildKubernetesNodeHostLookup(snapshot.Hosts)
for _, cluster := range snapshot.KubernetesClusters {
@@ -240,6 +254,36 @@ func (rr *ResourceRegistry) IngestSnapshot(snapshot models.StateSnapshot) {
for _, deployment := range cluster.Deployments {
rr.ingestKubernetesDeployment(cluster, deployment, clusterID, capabilities)
}
for _, namespace := range cluster.Namespaces {
rr.ingestKubernetesNamespace(cluster, namespace, clusterID, capabilities)
}
for _, service := range cluster.Services {
rr.ingestKubernetesService(cluster, service, clusterID, capabilities)
}
for _, statefulSet := range cluster.StatefulSets {
rr.ingestKubernetesStatefulSet(cluster, statefulSet, clusterID, capabilities)
}
for _, daemonSet := range cluster.DaemonSets {
rr.ingestKubernetesDaemonSet(cluster, daemonSet, clusterID, capabilities)
}
for _, job := range cluster.Jobs {
rr.ingestKubernetesJob(cluster, job, clusterID, capabilities)
}
for _, cronJob := range cluster.CronJobs {
rr.ingestKubernetesCronJob(cluster, cronJob, clusterID, capabilities)
}
for _, ingress := range cluster.Ingresses {
rr.ingestKubernetesIngress(cluster, ingress, clusterID, capabilities)
}
for _, volume := range cluster.PersistentVolumes {
rr.ingestKubernetesPersistentVolume(cluster, volume, clusterID, capabilities)
}
for _, claim := range cluster.PersistentVolumeClaims {
rr.ingestKubernetesPersistentVolumeClaim(cluster, claim, clusterID, capabilities)
}
for _, event := range cluster.Events {
rr.ingestKubernetesEvent(cluster, event, clusterID, capabilities)
}
}
rr.mu.Lock()
@@ -454,6 +498,52 @@ func (rr *ResourceRegistry) seedSourceIDForResourceLocked(resource *Resource, so
return ""
}
return fmt.Sprintf("%s:service:%s", clusterKey, serviceID)
case ResourceTypeDockerImage:
hostSourceID := strings.TrimSpace(resource.Docker.HostSourceID)
imageID := strings.TrimSpace(resource.Docker.ImageID)
if imageID == "" {
imageID = strings.TrimSpace(resource.Name)
}
if hostSourceID == "" || imageID == "" {
return ""
}
return hostSourceID + "/image/" + imageID
case ResourceTypeDockerVolume:
hostSourceID := strings.TrimSpace(resource.Docker.HostSourceID)
volumeName := strings.TrimSpace(resource.Docker.VolumeName)
if volumeName == "" {
volumeName = strings.TrimSpace(resource.Name)
}
if hostSourceID == "" || volumeName == "" {
return ""
}
return hostSourceID + "/volume/" + volumeName
case ResourceTypeDockerNetwork:
hostSourceID := strings.TrimSpace(resource.Docker.HostSourceID)
networkID := strings.TrimSpace(resource.Docker.NetworkID)
if networkID == "" {
networkID = strings.TrimSpace(resource.Name)
}
if hostSourceID == "" || networkID == "" {
return ""
}
return hostSourceID + "/network/" + networkID
case ResourceTypeDockerTask:
taskID := strings.TrimSpace(resource.Docker.TaskID)
if taskID == "" {
taskID = strings.TrimSpace(resource.Name)
}
if taskID == "" {
return ""
}
if clusterKey := dockerSwarmClusterKeyFromMeta(resource.Docker.Swarm); clusterKey != "" {
return fmt.Sprintf("%s:task:%s", clusterKey, taskID)
}
hostSourceID := strings.TrimSpace(resource.Docker.HostSourceID)
if hostSourceID == "" {
return ""
}
return hostSourceID + "/task/" + taskID
default:
return strings.TrimSpace(resource.Docker.HostSourceID)
}
@@ -501,6 +591,26 @@ func (rr *ResourceRegistry) seedSourceIDForResourceLocked(resource *Resource, so
return ""
}
return fmt.Sprintf("%s:deployment:%s", clusterSourceID, deploymentID)
case ResourceTypeK8sNamespace:
return seededKubernetesTypedSourceID(clusterSourceID, "namespace", resource, resource.Kubernetes.NamespaceUID)
case ResourceTypeK8sService:
return seededKubernetesTypedSourceID(clusterSourceID, "service", resource, resource.Kubernetes.ServiceUID)
case ResourceTypeK8sStatefulSet:
return seededKubernetesTypedSourceID(clusterSourceID, "statefulset", resource, resource.Kubernetes.StatefulSetUID)
case ResourceTypeK8sDaemonSet:
return seededKubernetesTypedSourceID(clusterSourceID, "daemonset", resource, resource.Kubernetes.DaemonSetUID)
case ResourceTypeK8sJob:
return seededKubernetesTypedSourceID(clusterSourceID, "job", resource, resource.Kubernetes.JobUID)
case ResourceTypeK8sCronJob:
return seededKubernetesTypedSourceID(clusterSourceID, "cronjob", resource, resource.Kubernetes.CronJobUID)
case ResourceTypeK8sIngress:
return seededKubernetesTypedSourceID(clusterSourceID, "ingress", resource, resource.Kubernetes.IngressUID)
case ResourceTypeK8sPV:
return seededKubernetesTypedSourceID(clusterSourceID, "persistentvolume", resource, resource.Kubernetes.PersistentVolumeUID)
case ResourceTypeK8sPVC:
return seededKubernetesTypedSourceID(clusterSourceID, "persistentvolumeclaim", resource, resource.Kubernetes.PersistentVolumeClaimUID)
case ResourceTypeK8sEvent:
return seededKubernetesTypedSourceID(clusterSourceID, "event", resource, resource.Kubernetes.EventUID)
}
case SourceVMware:
return seededVMwareSourceID(resource)
@@ -595,6 +705,28 @@ func seededKubernetesDeploymentIdentity(resource *Resource) string {
return namespace + "/" + name
}
func seededKubernetesTypedSourceID(clusterSourceID, kind string, resource *Resource, uid string) string {
if resource == nil || resource.Kubernetes == nil {
return ""
}
id := strings.TrimSpace(uid)
if id == "" {
namespace := strings.TrimSpace(resource.Kubernetes.Namespace)
name := strings.TrimSpace(resource.Name)
if namespace != "" && name != "" {
id = namespace + "/" + name
} else {
id = name
}
}
clusterSourceID = strings.TrimSpace(clusterSourceID)
kind = strings.TrimSpace(kind)
if clusterSourceID == "" || kind == "" || id == "" {
return ""
}
return fmt.Sprintf("%s:%s:%s", clusterSourceID, kind, id)
}
func seededVMwareSourceID(resource *Resource) string {
if resource == nil || resource.VMware == nil {
return ""
@@ -1087,6 +1219,61 @@ func (rr *ResourceRegistry) ingestDockerService(service models.DockerService, ho
rr.ingest(SourceDocker, sourceID, resource, identity)
}
func (rr *ResourceRegistry) ingestDockerImage(image models.DockerImage, host models.DockerHost) {
resource, identity := resourceFromDockerImage(image, host)
if parentID := rr.sourceResourceID(SourceDocker, host.ID); parentID != "" {
resource.ParentID = &parentID
}
sourceID := dockerImageSourceID(host, image)
if sourceID == "" {
return
}
rr.ingest(SourceDocker, sourceID, resource, identity)
}
func (rr *ResourceRegistry) ingestDockerVolume(volume models.DockerVolume, host models.DockerHost) {
resource, identity := resourceFromDockerVolume(volume, host)
if parentID := rr.sourceResourceID(SourceDocker, host.ID); parentID != "" {
resource.ParentID = &parentID
}
sourceID := dockerVolumeSourceID(host, volume)
if sourceID == "" {
return
}
rr.ingest(SourceDocker, sourceID, resource, identity)
}
func (rr *ResourceRegistry) ingestDockerNetwork(network models.DockerNetwork, host models.DockerHost) {
resource, identity := resourceFromDockerNetwork(network, host)
if parentID := rr.sourceResourceID(SourceDocker, host.ID); parentID != "" {
resource.ParentID = &parentID
}
sourceID := dockerNetworkSourceID(host, network)
if sourceID == "" {
return
}
rr.ingest(SourceDocker, sourceID, resource, identity)
}
func (rr *ResourceRegistry) ingestDockerTask(task models.DockerTask, host models.DockerHost) {
resource, identity := resourceFromDockerTask(task, host)
parentID := ""
if serviceSourceID := dockerServiceSourceID(host, models.DockerService{ID: task.ServiceID, Name: task.ServiceName}); serviceSourceID != "" {
parentID = rr.sourceResourceID(SourceDocker, serviceSourceID)
}
if parentID == "" {
parentID = rr.sourceResourceID(SourceDocker, host.ID)
}
if parentID != "" {
resource.ParentID = &parentID
}
sourceID := dockerTaskSourceID(host, task)
if sourceID == "" {
return
}
rr.ingest(SourceDocker, sourceID, resource, identity)
}
func (rr *ResourceRegistry) ingestKubernetesCluster(cluster models.KubernetesCluster, linkedHosts []*models.Host, capabilities *K8sMetricCapabilities) string {
resource, identity := resourceFromKubernetesCluster(cluster, linkedHosts, capabilities)
sourceID := kubernetesClusterSourceID(cluster)
@@ -1111,6 +1298,126 @@ func (rr *ResourceRegistry) ingestKubernetesNode(cluster models.KubernetesCluste
rr.ingest(SourceK8s, sourceID, resource, identity)
}
func (rr *ResourceRegistry) ingestKubernetesNamespace(cluster models.KubernetesCluster, namespace models.KubernetesNamespace, clusterResourceID string, capabilities *K8sMetricCapabilities) {
resource, identity := resourceFromKubernetesNamespace(cluster, namespace, capabilities)
if clusterResourceID != "" {
resource.ParentID = &clusterResourceID
}
sourceID := kubernetesNamespaceSourceID(kubernetesClusterSourceID(cluster), namespace)
if sourceID == "" {
return
}
rr.ingest(SourceK8s, sourceID, resource, identity)
}
func (rr *ResourceRegistry) ingestKubernetesService(cluster models.KubernetesCluster, service models.KubernetesService, clusterResourceID string, capabilities *K8sMetricCapabilities) {
resource, identity := resourceFromKubernetesService(cluster, service, capabilities)
if clusterResourceID != "" {
resource.ParentID = &clusterResourceID
}
sourceID := kubernetesServiceSourceID(kubernetesClusterSourceID(cluster), service)
if sourceID == "" {
return
}
rr.ingest(SourceK8s, sourceID, resource, identity)
}
func (rr *ResourceRegistry) ingestKubernetesStatefulSet(cluster models.KubernetesCluster, statefulSet models.KubernetesStatefulSet, clusterResourceID string, capabilities *K8sMetricCapabilities) {
resource, identity := resourceFromKubernetesStatefulSet(cluster, statefulSet, capabilities)
if clusterResourceID != "" {
resource.ParentID = &clusterResourceID
}
sourceID := kubernetesStatefulSetSourceID(kubernetesClusterSourceID(cluster), statefulSet)
if sourceID == "" {
return
}
rr.ingest(SourceK8s, sourceID, resource, identity)
}
func (rr *ResourceRegistry) ingestKubernetesDaemonSet(cluster models.KubernetesCluster, daemonSet models.KubernetesDaemonSet, clusterResourceID string, capabilities *K8sMetricCapabilities) {
resource, identity := resourceFromKubernetesDaemonSet(cluster, daemonSet, capabilities)
if clusterResourceID != "" {
resource.ParentID = &clusterResourceID
}
sourceID := kubernetesDaemonSetSourceID(kubernetesClusterSourceID(cluster), daemonSet)
if sourceID == "" {
return
}
rr.ingest(SourceK8s, sourceID, resource, identity)
}
func (rr *ResourceRegistry) ingestKubernetesJob(cluster models.KubernetesCluster, job models.KubernetesJob, clusterResourceID string, capabilities *K8sMetricCapabilities) {
resource, identity := resourceFromKubernetesJob(cluster, job, capabilities)
if clusterResourceID != "" {
resource.ParentID = &clusterResourceID
}
sourceID := kubernetesJobSourceID(kubernetesClusterSourceID(cluster), job)
if sourceID == "" {
return
}
rr.ingest(SourceK8s, sourceID, resource, identity)
}
func (rr *ResourceRegistry) ingestKubernetesCronJob(cluster models.KubernetesCluster, cronJob models.KubernetesCronJob, clusterResourceID string, capabilities *K8sMetricCapabilities) {
resource, identity := resourceFromKubernetesCronJob(cluster, cronJob, capabilities)
if clusterResourceID != "" {
resource.ParentID = &clusterResourceID
}
sourceID := kubernetesCronJobSourceID(kubernetesClusterSourceID(cluster), cronJob)
if sourceID == "" {
return
}
rr.ingest(SourceK8s, sourceID, resource, identity)
}
func (rr *ResourceRegistry) ingestKubernetesIngress(cluster models.KubernetesCluster, ingress models.KubernetesIngress, clusterResourceID string, capabilities *K8sMetricCapabilities) {
resource, identity := resourceFromKubernetesIngress(cluster, ingress, capabilities)
if clusterResourceID != "" {
resource.ParentID = &clusterResourceID
}
sourceID := kubernetesIngressSourceID(kubernetesClusterSourceID(cluster), ingress)
if sourceID == "" {
return
}
rr.ingest(SourceK8s, sourceID, resource, identity)
}
func (rr *ResourceRegistry) ingestKubernetesPersistentVolume(cluster models.KubernetesCluster, volume models.KubernetesPersistentVolume, clusterResourceID string, capabilities *K8sMetricCapabilities) {
resource, identity := resourceFromKubernetesPersistentVolume(cluster, volume, capabilities)
if clusterResourceID != "" {
resource.ParentID = &clusterResourceID
}
sourceID := kubernetesPersistentVolumeSourceID(kubernetesClusterSourceID(cluster), volume)
if sourceID == "" {
return
}
rr.ingest(SourceK8s, sourceID, resource, identity)
}
func (rr *ResourceRegistry) ingestKubernetesPersistentVolumeClaim(cluster models.KubernetesCluster, claim models.KubernetesPersistentVolumeClaim, clusterResourceID string, capabilities *K8sMetricCapabilities) {
resource, identity := resourceFromKubernetesPersistentVolumeClaim(cluster, claim, capabilities)
if clusterResourceID != "" {
resource.ParentID = &clusterResourceID
}
sourceID := kubernetesPersistentVolumeClaimSourceID(kubernetesClusterSourceID(cluster), claim)
if sourceID == "" {
return
}
rr.ingest(SourceK8s, sourceID, resource, identity)
}
func (rr *ResourceRegistry) ingestKubernetesEvent(cluster models.KubernetesCluster, event models.KubernetesEvent, clusterResourceID string, capabilities *K8sMetricCapabilities) {
resource, identity := resourceFromKubernetesEvent(cluster, event, capabilities)
if clusterResourceID != "" {
resource.ParentID = &clusterResourceID
}
sourceID := kubernetesEventSourceID(kubernetesClusterSourceID(cluster), event)
if sourceID == "" {
return
}
rr.ingest(SourceK8s, sourceID, resource, identity)
}
func (rr *ResourceRegistry) ingestKubernetesPod(cluster models.KubernetesCluster, pod models.KubernetesPod, clusterResourceID string, capabilities *K8sMetricCapabilities) {
resource, identity := resourceFromKubernetesPod(cluster, pod, capabilities)
if clusterResourceID != "" {
@@ -2352,6 +2659,65 @@ func kubernetesDeploymentSourceID(clusterSourceID string, deployment models.Kube
return CanonicalKubernetesDeploymentSourceID(clusterSourceID, deployment)
}
func kubernetesNamespaceSourceID(clusterSourceID string, namespace models.KubernetesNamespace) string {
return canonicalKubernetesTypedSourceID(clusterSourceID, "namespace", namespace.UID, "", namespace.Name)
}
func kubernetesServiceSourceID(clusterSourceID string, service models.KubernetesService) string {
return canonicalKubernetesTypedSourceID(clusterSourceID, "service", service.UID, service.Namespace, service.Name)
}
func kubernetesStatefulSetSourceID(clusterSourceID string, statefulSet models.KubernetesStatefulSet) string {
return canonicalKubernetesTypedSourceID(clusterSourceID, "statefulset", statefulSet.UID, statefulSet.Namespace, statefulSet.Name)
}
func kubernetesDaemonSetSourceID(clusterSourceID string, daemonSet models.KubernetesDaemonSet) string {
return canonicalKubernetesTypedSourceID(clusterSourceID, "daemonset", daemonSet.UID, daemonSet.Namespace, daemonSet.Name)
}
func kubernetesJobSourceID(clusterSourceID string, job models.KubernetesJob) string {
return canonicalKubernetesTypedSourceID(clusterSourceID, "job", job.UID, job.Namespace, job.Name)
}
func kubernetesCronJobSourceID(clusterSourceID string, cronJob models.KubernetesCronJob) string {
return canonicalKubernetesTypedSourceID(clusterSourceID, "cronjob", cronJob.UID, cronJob.Namespace, cronJob.Name)
}
func kubernetesIngressSourceID(clusterSourceID string, ingress models.KubernetesIngress) string {
return canonicalKubernetesTypedSourceID(clusterSourceID, "ingress", ingress.UID, ingress.Namespace, ingress.Name)
}
func kubernetesPersistentVolumeSourceID(clusterSourceID string, volume models.KubernetesPersistentVolume) string {
return canonicalKubernetesTypedSourceID(clusterSourceID, "persistentvolume", volume.UID, "", volume.Name)
}
func kubernetesPersistentVolumeClaimSourceID(clusterSourceID string, claim models.KubernetesPersistentVolumeClaim) string {
return canonicalKubernetesTypedSourceID(clusterSourceID, "persistentvolumeclaim", claim.UID, claim.Namespace, claim.Name)
}
func kubernetesEventSourceID(clusterSourceID string, event models.KubernetesEvent) string {
return canonicalKubernetesTypedSourceID(clusterSourceID, "event", event.UID, event.Namespace, event.Name)
}
func canonicalKubernetesTypedSourceID(clusterSourceID, kind, uid, namespace, name string) string {
clusterKey := strings.TrimSpace(clusterSourceID)
kind = strings.TrimSpace(kind)
id := strings.TrimSpace(uid)
if id == "" {
namespace = strings.TrimSpace(namespace)
name = strings.TrimSpace(name)
if namespace != "" && name != "" {
id = namespace + "/" + name
} else {
id = name
}
}
if clusterKey == "" || kind == "" || id == "" {
return ""
}
return fmt.Sprintf("%s:%s:%s", clusterKey, kind, id)
}
func buildKubernetesNodeHostLookup(hosts []models.Host) map[string]*models.Host {
lookup := make(map[string]*models.Host, len(hosts)*2)
for i := range hosts {
@@ -2494,6 +2860,63 @@ func dockerServiceSourceID(host models.DockerHost, service models.DockerService)
return fmt.Sprintf("%s:service:%s", cluster, serviceID)
}
func dockerImageSourceID(host models.DockerHost, image models.DockerImage) string {
hostID := strings.TrimSpace(host.ID)
imageID := strings.TrimSpace(image.ID)
if imageID == "" {
imageID = firstDockerImageReference(image)
}
if hostID == "" || imageID == "" {
return ""
}
return hostID + "/image/" + imageID
}
func dockerVolumeSourceID(host models.DockerHost, volume models.DockerVolume) string {
hostID := strings.TrimSpace(host.ID)
name := strings.TrimSpace(volume.Name)
if hostID == "" || name == "" {
return ""
}
return hostID + "/volume/" + name
}
func dockerNetworkSourceID(host models.DockerHost, network models.DockerNetwork) string {
hostID := strings.TrimSpace(host.ID)
networkID := strings.TrimSpace(network.ID)
if networkID == "" {
networkID = strings.TrimSpace(network.Name)
}
if hostID == "" || networkID == "" {
return ""
}
return hostID + "/network/" + networkID
}
func dockerTaskSourceID(host models.DockerHost, task models.DockerTask) string {
taskID := strings.TrimSpace(task.ID)
if taskID == "" {
taskID = strings.TrimSpace(task.ContainerID)
}
if taskID == "" {
taskID = strings.TrimSpace(task.ServiceName)
if task.Slot > 0 && taskID != "" {
taskID = fmt.Sprintf("%s.%d", taskID, task.Slot)
}
}
if taskID == "" {
return ""
}
if cluster := dockerSwarmClusterKey(host); cluster != "" {
return fmt.Sprintf("%s:task:%s", cluster, taskID)
}
hostID := strings.TrimSpace(host.ID)
if hostID == "" {
return ""
}
return hostID + "/task/" + taskID
}
func mergeIdentity(existing ResourceIdentity, incoming ResourceIdentity) ResourceIdentity {
if existing.MachineID == "" {
existing.MachineID = incoming.MachineID
@@ -3860,3 +3860,42 @@ func TestRegistryIngestKeepsDockerContainersScopedToTheirHost(t *testing.T) {
}
}
}
func TestRegistryIngestSnapshotPublishesDockerNativeInventory(t *testing.T) {
now := time.Date(2026, 5, 24, 8, 0, 0, 0, time.UTC)
rr := NewRegistry(NewMemoryStore())
rr.IngestSnapshot(models.StateSnapshot{
DockerHosts: []models.DockerHost{{
ID: "docker-host-1",
Hostname: "edge",
Runtime: "docker",
Status: "online",
LastSeen: now,
Images: []models.DockerImage{{
ID: "sha256:image1", RepoTags: []string{"repo/app:latest"}, SizeBytes: 1024,
}},
Volumes: []models.DockerVolume{{Name: "app-data", Driver: "local", SizeBytes: 2048}},
Networks: []models.DockerNetwork{{
ID: "net1", Name: "app-net", Driver: "bridge", Subnets: []models.DockerNetworkSubnet{{Subnet: "10.88.0.0/24", Gateway: "10.88.0.1"}},
}},
Tasks: []models.DockerTask{{
ID: "task-1", ServiceID: "svc-1", ServiceName: "api", Slot: 1, DesiredState: "running", CurrentState: "running",
}},
}},
})
counts := map[ResourceType]int{}
for _, resource := range rr.List() {
counts[resource.Type]++
}
for _, resourceType := range []ResourceType{
ResourceTypeDockerImage,
ResourceTypeDockerVolume,
ResourceTypeDockerNetwork,
ResourceTypeDockerTask,
} {
if counts[resourceType] != 1 {
t.Fatalf("expected one %s resource, got counts %#v", resourceType, counts)
}
}
}
+89
View File
@@ -106,6 +106,23 @@ func statusFromDockerService(service models.DockerService) ResourceStatus {
return StatusWarning
}
func statusFromDockerTask(task models.DockerTask) ResourceStatus {
current := strings.ToLower(strings.TrimSpace(task.CurrentState))
desired := strings.ToLower(strings.TrimSpace(task.DesiredState))
switch {
case strings.Contains(current, "running"):
return StatusOnline
case strings.Contains(current, "failed"), strings.Contains(current, "rejected"), strings.TrimSpace(task.Error) != "":
return StatusOffline
case strings.Contains(current, "pending"), strings.Contains(current, "starting"), strings.Contains(current, "preparing"), strings.Contains(current, "accepted"):
return StatusWarning
case desired == "shutdown" || strings.Contains(current, "complete"):
return StatusOffline
default:
return StatusUnknown
}
}
func statusFromPBSInstance(instance models.PBSInstance) ResourceStatus {
primary := strings.ToLower(strings.TrimSpace(instance.Status))
health := strings.ToLower(strings.TrimSpace(instance.ConnectionHealth))
@@ -238,3 +255,75 @@ func statusFromKubernetesDeployment(deployment models.KubernetesDeployment) Reso
}
return StatusWarning
}
func statusFromKubernetesStatefulSet(statefulSet models.KubernetesStatefulSet) ResourceStatus {
return statusFromReplicaAvailability(statefulSet.DesiredReplicas, statefulSet.ReadyReplicas)
}
func statusFromKubernetesDaemonSet(daemonSet models.KubernetesDaemonSet) ResourceStatus {
desired := daemonSet.DesiredNumberScheduled
ready := daemonSet.NumberReady
if daemonSet.NumberMisscheduled > 0 || daemonSet.NumberUnavailable > 0 {
if ready > 0 {
return StatusWarning
}
return StatusOffline
}
return statusFromReplicaAvailability(desired, ready)
}
func statusFromKubernetesJob(job models.KubernetesJob) ResourceStatus {
if job.Failed > 0 {
return StatusOffline
}
if job.Succeeded > 0 {
return StatusOnline
}
if job.Active > 0 {
return StatusWarning
}
return StatusUnknown
}
func statusFromKubernetesCronJob(cronJob models.KubernetesCronJob) ResourceStatus {
if cronJob.Suspend {
return StatusWarning
}
return StatusOnline
}
func statusFromKubernetesPhase(phase string) ResourceStatus {
switch strings.ToLower(strings.TrimSpace(phase)) {
case "active", "bound", "available":
return StatusOnline
case "pending", "released":
return StatusWarning
case "failed", "lost", "terminating":
return StatusOffline
default:
return StatusUnknown
}
}
func statusFromKubernetesEvent(event models.KubernetesEvent) ResourceStatus {
if strings.EqualFold(strings.TrimSpace(event.EventType), "warning") {
return StatusWarning
}
return StatusOnline
}
func statusFromReplicaAvailability(desired, ready int32) ResourceStatus {
if desired <= 0 {
if ready > 0 {
return StatusOnline
}
return StatusUnknown
}
if ready >= desired {
return StatusOnline
}
if ready == 0 {
return StatusOffline
}
return StatusWarning
}
+191 -49
View File
@@ -125,10 +125,24 @@ const (
ResourceTypeSystemContainer ResourceType = "system-container"
ResourceTypeAppContainer ResourceType = "app-container"
ResourceTypeDockerService ResourceType = "docker-service"
ResourceTypeDockerImage ResourceType = "docker-image"
ResourceTypeDockerVolume ResourceType = "docker-volume"
ResourceTypeDockerNetwork ResourceType = "docker-network"
ResourceTypeDockerTask ResourceType = "docker-task"
ResourceTypeK8sCluster ResourceType = "k8s-cluster"
ResourceTypeK8sNode ResourceType = "k8s-node"
ResourceTypePod ResourceType = "pod"
ResourceTypeK8sDeployment ResourceType = "k8s-deployment"
ResourceTypeK8sNamespace ResourceType = "k8s-namespace"
ResourceTypeK8sService ResourceType = "k8s-service"
ResourceTypeK8sStatefulSet ResourceType = "k8s-statefulset"
ResourceTypeK8sDaemonSet ResourceType = "k8s-daemonset"
ResourceTypeK8sJob ResourceType = "k8s-job"
ResourceTypeK8sCronJob ResourceType = "k8s-cronjob"
ResourceTypeK8sIngress ResourceType = "k8s-ingress"
ResourceTypeK8sPV ResourceType = "k8s-persistent-volume"
ResourceTypeK8sPVC ResourceType = "k8s-persistent-volume-claim"
ResourceTypeK8sEvent ResourceType = "k8s-event"
ResourceTypeStorage ResourceType = "storage"
ResourceTypeNetwork ResourceType = "network"
ResourceTypePBS ResourceType = "pbs"
@@ -735,6 +749,12 @@ type DockerNetworkMeta struct {
IPv6 string `json:"ipv6,omitempty"`
}
// DockerNetworkSubnetMeta describes an IPAM subnet on a Docker network.
type DockerNetworkSubnetMeta struct {
Subnet string `json:"subnet,omitempty"`
Gateway string `json:"gateway,omitempty"`
}
// DockerMountMeta describes a container volume mount.
type DockerMountMeta struct {
Type string `json:"type,omitempty"`
@@ -769,6 +789,14 @@ type DockerServicePortMeta struct {
PublishMode string `json:"publishMode,omitempty"`
}
// DockerStorageUsageMeta captures one /system/df resource bucket.
type DockerStorageUsageMeta struct {
TotalCount int64 `json:"totalCount,omitempty"`
ActiveCount int64 `json:"activeCount,omitempty"`
TotalSizeBytes int64 `json:"totalSizeBytes,omitempty"`
ReclaimableBytes int64 `json:"reclaimableBytes,omitempty"`
}
// DockerData contains Docker host- and container-specific data.
type DockerData struct {
HostSourceID string `json:"hostSourceId,omitempty"` // raw model ID for the docker host
@@ -799,8 +827,15 @@ type DockerData struct {
// Host-level summary fields (populated when Resource.Type == ResourceTypeAgent and Docker != nil)
ContainerCount int `json:"containerCount,omitempty"`
ImageCount int `json:"imageCount,omitempty"`
VolumeCount int `json:"volumeCount,omitempty"`
NetworkCount int `json:"networkCount,omitempty"`
UpdatesAvailableCount int `json:"updatesAvailableCount,omitempty"`
UpdatesLastCheckedAt *time.Time `json:"updatesLastCheckedAt,omitempty"`
ImagesUsage *DockerStorageUsageMeta `json:"imagesUsage,omitempty"`
ContainersUsage *DockerStorageUsageMeta `json:"containersUsage,omitempty"`
VolumesUsage *DockerStorageUsageMeta `json:"volumesUsage,omitempty"`
BuildCacheUsage *DockerStorageUsageMeta `json:"buildCacheUsage,omitempty"`
TokenID string `json:"tokenId,omitempty"`
TokenName string `json:"tokenName,omitempty"`
TokenHint string `json:"tokenHint,omitempty"`
@@ -823,6 +858,7 @@ type DockerData struct {
// Service-specific fields (populated when Resource.Type == ResourceTypeDockerService)
ServiceID string `json:"serviceId,omitempty"`
ServiceName string `json:"serviceName,omitempty"`
Stack string `json:"stack,omitempty"`
Mode string `json:"mode,omitempty"`
DesiredTasks int `json:"desiredTasks,omitempty"`
@@ -831,14 +867,56 @@ type DockerData struct {
ServiceUpdate *DockerServiceUpdateMeta `json:"serviceUpdate,omitempty"`
EndpointPorts []DockerServicePortMeta `json:"endpointPorts,omitempty"`
// Image-specific fields (populated when Resource.Type == ResourceTypeDockerImage)
ImageID string `json:"imageId,omitempty"`
RepoTags []string `json:"repoTags,omitempty"`
RepoDigests []string `json:"repoDigests,omitempty"`
SizeBytes int64 `json:"sizeBytes,omitempty"`
SharedSizeBytes int64 `json:"sharedSizeBytes,omitempty"`
ImageContainers int64 `json:"imageContainers,omitempty"`
// Volume-specific fields (populated when Resource.Type == ResourceTypeDockerVolume)
VolumeName string `json:"volumeName,omitempty"`
Driver string `json:"driver,omitempty"`
Mountpoint string `json:"mountpoint,omitempty"`
Scope string `json:"scope,omitempty"`
CreatedAt string `json:"createdAt,omitempty"`
RefCount int64 `json:"refCount,omitempty"`
Options map[string]string `json:"options,omitempty"`
// Network-specific fields (populated when Resource.Type == ResourceTypeDockerNetwork)
NetworkID string `json:"networkId,omitempty"`
EnableIPv4 bool `json:"enableIpv4,omitempty"`
EnableIPv6 bool `json:"enableIpv6,omitempty"`
Internal bool `json:"internal,omitempty"`
Attachable bool `json:"attachable,omitempty"`
Ingress bool `json:"ingress,omitempty"`
ConfigOnly bool `json:"configOnly,omitempty"`
Subnets []DockerNetworkSubnetMeta `json:"subnets,omitempty"`
// Task-specific fields (populated when Resource.Type == ResourceTypeDockerTask)
TaskID string `json:"taskId,omitempty"`
NodeID string `json:"nodeId,omitempty"`
NodeName string `json:"nodeName,omitempty"`
Slot int `json:"slot,omitempty"`
DesiredState string `json:"desiredState,omitempty"`
CurrentState string `json:"currentState,omitempty"`
Error string `json:"error,omitempty"`
Message string `json:"message,omitempty"`
StartedAt *time.Time `json:"startedAt,omitempty"`
CompletedAt *time.Time `json:"completedAt,omitempty"`
Swarm *DockerSwarmInfo `json:"swarm,omitempty"`
NetworkInterfaces []NetworkInterface `json:"networkInterfaces,omitempty"`
Disks []DiskInfo `json:"disks,omitempty"`
// These hold raw data for tools access
Containers []models.DockerContainer `json:"-"`
Services []models.DockerService `json:"-"`
Tasks []models.DockerTask `json:"-"`
Containers []models.DockerContainer `json:"-"`
Images []models.DockerImage `json:"-"`
Volumes []models.DockerVolume `json:"-"`
NetworksRaw []models.DockerNetwork `json:"-"`
Services []models.DockerService `json:"-"`
Tasks []models.DockerTask `json:"-"`
}
// PBSData contains Proxmox Backup Server data.
@@ -1349,52 +1427,116 @@ type K8sMetricCapabilities struct {
// K8sData contains Kubernetes data.
type K8sData struct {
ClusterID string `json:"clusterId,omitempty"`
ClusterName string `json:"clusterName,omitempty"`
SourceName string `json:"sourceName,omitempty"` // raw model Name before display-name resolution
SourceStatus string `json:"sourceStatus,omitempty"` // raw model Status before normalization
AgentID string `json:"agentId,omitempty"`
Context string `json:"context,omitempty"`
Server string `json:"server,omitempty"`
Version string `json:"version,omitempty"`
PendingUninstall bool `json:"pendingUninstall,omitempty"`
AgentVersion string `json:"agentVersion,omitempty"` // cluster: k8s agent version
IntervalSeconds int `json:"intervalSeconds,omitempty"` // cluster: telemetry interval
NodeUID string `json:"nodeUid,omitempty"`
NodeName string `json:"nodeName,omitempty"`
Ready bool `json:"ready,omitempty"`
Unschedulable bool `json:"unschedulable,omitempty"`
Roles []string `json:"roles,omitempty"`
KubeletVersion string `json:"kubeletVersion,omitempty"`
ContainerRuntimeVersion string `json:"containerRuntimeVersion,omitempty"`
OSImage string `json:"osImage,omitempty"`
KernelVersion string `json:"kernelVersion,omitempty"`
Architecture string `json:"architecture,omitempty"`
CapacityCPU int64 `json:"capacityCpuCores,omitempty"`
CapacityMemoryBytes int64 `json:"capacityMemoryBytes,omitempty"`
CapacityPods int64 `json:"capacityPods,omitempty"`
AllocCPU int64 `json:"allocatableCpuCores,omitempty"`
AllocMemoryBytes int64 `json:"allocatableMemoryBytes,omitempty"`
AllocPods int64 `json:"allocatablePods,omitempty"`
Namespace string `json:"namespace,omitempty"`
PodUID string `json:"podUid,omitempty"`
PodPhase string `json:"podPhase,omitempty"`
PodReason string `json:"podReason,omitempty"` // pod: status reason
PodMessage string `json:"podMessage,omitempty"` // pod: status message
PodContainers []K8sPodContainer `json:"podContainers,omitempty"` // pod: sub-containers
UptimeSeconds int64 `json:"uptimeSeconds,omitempty"`
Temperature *float64 `json:"temperature,omitempty"`
Restarts int `json:"restarts,omitempty"`
OwnerKind string `json:"ownerKind,omitempty"`
OwnerName string `json:"ownerName,omitempty"`
Image string `json:"image,omitempty"`
Labels map[string]string `json:"labels,omitempty"`
DeploymentUID string `json:"deploymentUid,omitempty"`
DesiredReplicas int32 `json:"desiredReplicas,omitempty"`
UpdatedReplicas int32 `json:"updatedReplicas,omitempty"`
ReadyReplicas int32 `json:"readyReplicas,omitempty"`
AvailableReplicas int32 `json:"availableReplicas,omitempty"`
MetricCapabilities *K8sMetricCapabilities `json:"metricCapabilities,omitempty"`
ClusterID string `json:"clusterId,omitempty"`
ClusterName string `json:"clusterName,omitempty"`
ResourceUID string `json:"resourceUid,omitempty"`
ResourceKind string `json:"resourceKind,omitempty"`
SourceName string `json:"sourceName,omitempty"` // raw model Name before display-name resolution
SourceStatus string `json:"sourceStatus,omitempty"` // raw model Status before normalization
AgentID string `json:"agentId,omitempty"`
Context string `json:"context,omitempty"`
Server string `json:"server,omitempty"`
Version string `json:"version,omitempty"`
PendingUninstall bool `json:"pendingUninstall,omitempty"`
AgentVersion string `json:"agentVersion,omitempty"` // cluster: k8s agent version
IntervalSeconds int `json:"intervalSeconds,omitempty"` // cluster: telemetry interval
NodeUID string `json:"nodeUid,omitempty"`
NodeName string `json:"nodeName,omitempty"`
Ready bool `json:"ready,omitempty"`
Unschedulable bool `json:"unschedulable,omitempty"`
Roles []string `json:"roles,omitempty"`
KubeletVersion string `json:"kubeletVersion,omitempty"`
ContainerRuntimeVersion string `json:"containerRuntimeVersion,omitempty"`
OSImage string `json:"osImage,omitempty"`
KernelVersion string `json:"kernelVersion,omitempty"`
Architecture string `json:"architecture,omitempty"`
CapacityCPU int64 `json:"capacityCpuCores,omitempty"`
CapacityMemoryBytes int64 `json:"capacityMemoryBytes,omitempty"`
CapacityPods int64 `json:"capacityPods,omitempty"`
AllocCPU int64 `json:"allocatableCpuCores,omitempty"`
AllocMemoryBytes int64 `json:"allocatableMemoryBytes,omitempty"`
AllocPods int64 `json:"allocatablePods,omitempty"`
Namespace string `json:"namespace,omitempty"`
PodUID string `json:"podUid,omitempty"`
PodPhase string `json:"podPhase,omitempty"`
PodReason string `json:"podReason,omitempty"` // pod: status reason
PodMessage string `json:"podMessage,omitempty"` // pod: status message
PodContainers []K8sPodContainer `json:"podContainers,omitempty"` // pod: sub-containers
UptimeSeconds int64 `json:"uptimeSeconds,omitempty"`
Temperature *float64 `json:"temperature,omitempty"`
Restarts int `json:"restarts,omitempty"`
OwnerKind string `json:"ownerKind,omitempty"`
OwnerName string `json:"ownerName,omitempty"`
Image string `json:"image,omitempty"`
Labels map[string]string `json:"labels,omitempty"`
DeploymentUID string `json:"deploymentUid,omitempty"`
StatefulSetUID string `json:"statefulSetUid,omitempty"`
DaemonSetUID string `json:"daemonSetUid,omitempty"`
ServiceUID string `json:"serviceUid,omitempty"`
JobUID string `json:"jobUid,omitempty"`
CronJobUID string `json:"cronJobUid,omitempty"`
IngressUID string `json:"ingressUid,omitempty"`
PersistentVolumeUID string `json:"persistentVolumeUid,omitempty"`
PersistentVolumeClaimUID string `json:"persistentVolumeClaimUid,omitempty"`
EventUID string `json:"eventUid,omitempty"`
NamespaceUID string `json:"namespaceUid,omitempty"`
DesiredReplicas int32 `json:"desiredReplicas,omitempty"`
UpdatedReplicas int32 `json:"updatedReplicas,omitempty"`
ReadyReplicas int32 `json:"readyReplicas,omitempty"`
AvailableReplicas int32 `json:"availableReplicas,omitempty"`
CurrentReplicas int32 `json:"currentReplicas,omitempty"`
DesiredNumberScheduled int32 `json:"desiredNumberScheduled,omitempty"`
CurrentNumberScheduled int32 `json:"currentNumberScheduled,omitempty"`
NumberReady int32 `json:"numberReady,omitempty"`
NumberAvailable int32 `json:"numberAvailable,omitempty"`
NumberUnavailable int32 `json:"numberUnavailable,omitempty"`
NumberMisscheduled int32 `json:"numberMisscheduled,omitempty"`
ServiceName string `json:"serviceName,omitempty"`
ServiceType string `json:"serviceType,omitempty"`
ClusterIP string `json:"clusterIp,omitempty"`
ExternalIPs []string `json:"externalIps,omitempty"`
ServicePorts []K8sServicePort `json:"servicePorts,omitempty"`
Selector map[string]string `json:"selector,omitempty"`
Succeeded int32 `json:"succeeded,omitempty"`
Failed int32 `json:"failed,omitempty"`
Active int32 `json:"active,omitempty"`
Schedule string `json:"schedule,omitempty"`
Suspend bool `json:"suspend,omitempty"`
LastScheduleTime *time.Time `json:"lastScheduleTime,omitempty"`
LastSuccessfulTime *time.Time `json:"lastSuccessfulTime,omitempty"`
StartTime *time.Time `json:"startTime,omitempty"`
CompletionTime *time.Time `json:"completionTime,omitempty"`
ClassName string `json:"className,omitempty"`
Hosts []string `json:"hosts,omitempty"`
Addresses []string `json:"addresses,omitempty"`
Phase string `json:"phase,omitempty"`
StorageClass string `json:"storageClass,omitempty"`
CapacityBytes int64 `json:"capacityBytes,omitempty"`
RequestedBytes int64 `json:"requestedBytes,omitempty"`
AccessModes []string `json:"accessModes,omitempty"`
ReclaimPolicy string `json:"reclaimPolicy,omitempty"`
ClaimNamespace string `json:"claimNamespace,omitempty"`
ClaimName string `json:"claimName,omitempty"`
VolumeName string `json:"volumeName,omitempty"`
EventType string `json:"eventType,omitempty"`
Reason string `json:"reason,omitempty"`
Message string `json:"message,omitempty"`
InvolvedKind string `json:"involvedKind,omitempty"`
InvolvedName string `json:"involvedName,omitempty"`
Count int32 `json:"count,omitempty"`
FirstSeen *time.Time `json:"firstSeen,omitempty"`
EventTime *time.Time `json:"eventTime,omitempty"`
CreatedAt *time.Time `json:"createdAt,omitempty"`
MetricCapabilities *K8sMetricCapabilities `json:"metricCapabilities,omitempty"`
}
// K8sServicePort describes a Kubernetes service port.
type K8sServicePort struct {
Name string `json:"name,omitempty"`
Protocol string `json:"protocol,omitempty"`
Port int32 `json:"port,omitempty"`
TargetPort string `json:"targetPort,omitempty"`
NodePort int32 `json:"nodePort,omitempty"`
}
// K8sPodContainer describes a container within a Kubernetes pod.
+75 -6
View File
@@ -12,12 +12,16 @@ type NetworkInterface = hostagent.NetworkInterface
// Report represents a single heartbeat from the Docker agent to Pulse.
type Report struct {
Agent AgentInfo `json:"agent"`
Host HostInfo `json:"host"`
Containers []Container `json:"containers"`
Services []Service `json:"services,omitempty"`
Tasks []Task `json:"tasks,omitempty"`
Timestamp time.Time `json:"timestamp"`
Agent AgentInfo `json:"agent"`
Host HostInfo `json:"host"`
Containers []Container `json:"containers"`
Images []Image `json:"images,omitempty"`
Volumes []Volume `json:"volumes,omitempty"`
Networks []Network `json:"networks,omitempty"`
Services []Service `json:"services,omitempty"`
Tasks []Task `json:"tasks,omitempty"`
StorageUsage *StorageUsage `json:"storageUsage,omitempty"`
Timestamp time.Time `json:"timestamp"`
}
// AgentInfo describes the reporting agent instance.
@@ -145,6 +149,71 @@ type UpdateStatus struct {
Error string `json:"error,omitempty"` // e.g., "rate limited", "auth required"
}
// Image summarises a local image available through the Docker-compatible API.
type Image struct {
ID string `json:"id"`
RepoTags []string `json:"repoTags,omitempty"`
RepoDigests []string `json:"repoDigests,omitempty"`
SizeBytes int64 `json:"sizeBytes,omitempty"`
SharedSizeBytes int64 `json:"sharedSizeBytes,omitempty"`
Containers int64 `json:"containers,omitempty"`
CreatedAt time.Time `json:"createdAt,omitempty"`
Labels map[string]string `json:"labels,omitempty"`
}
// Volume summarises a local volume available through the Docker-compatible API.
type Volume struct {
Name string `json:"name"`
Driver string `json:"driver,omitempty"`
Mountpoint string `json:"mountpoint,omitempty"`
Scope string `json:"scope,omitempty"`
CreatedAt string `json:"createdAt,omitempty"`
SizeBytes int64 `json:"sizeBytes,omitempty"`
RefCount int64 `json:"refCount,omitempty"`
Labels map[string]string `json:"labels,omitempty"`
Options map[string]string `json:"options,omitempty"`
}
// Network summarises a local network available through the Docker-compatible API.
type Network struct {
ID string `json:"id"`
Name string `json:"name"`
Driver string `json:"driver,omitempty"`
Scope string `json:"scope,omitempty"`
CreatedAt time.Time `json:"createdAt,omitempty"`
EnableIPv4 bool `json:"enableIpv4,omitempty"`
EnableIPv6 bool `json:"enableIpv6,omitempty"`
Internal bool `json:"internal,omitempty"`
Attachable bool `json:"attachable,omitempty"`
Ingress bool `json:"ingress,omitempty"`
ConfigOnly bool `json:"configOnly,omitempty"`
Subnets []NetworkSubnet `json:"subnets,omitempty"`
Labels map[string]string `json:"labels,omitempty"`
Options map[string]string `json:"options,omitempty"`
}
// NetworkSubnet summarises a network IPAM subnet.
type NetworkSubnet struct {
Subnet string `json:"subnet,omitempty"`
Gateway string `json:"gateway,omitempty"`
}
// StorageUsage summarises the Docker-compatible /system/df API buckets.
type StorageUsage struct {
Images StorageUsageBucket `json:"images,omitempty"`
Containers StorageUsageBucket `json:"containers,omitempty"`
Volumes StorageUsageBucket `json:"volumes,omitempty"`
BuildCache StorageUsageBucket `json:"buildCache,omitempty"`
}
// StorageUsageBucket captures one /system/df resource family.
type StorageUsageBucket struct {
TotalCount int64 `json:"totalCount,omitempty"`
ActiveCount int64 `json:"activeCount,omitempty"`
TotalSizeBytes int64 `json:"totalSizeBytes,omitempty"`
ReclaimableBytes int64 `json:"reclaimableBytes,omitempty"`
}
// AgentKey returns the stable identifier for a reporting agent.
func (r Report) AgentKey() string {
if r.Agent.ID != "" {
+163 -7
View File
@@ -5,13 +5,23 @@ import "time"
// Report represents a single heartbeat from the Kubernetes agent to Pulse.
// It is designed to be useful without requiring Metrics Server (metrics.k8s.io).
type Report struct {
Agent AgentInfo `json:"agent"`
Cluster ClusterInfo `json:"cluster"`
Nodes []Node `json:"nodes,omitempty"`
Pods []Pod `json:"pods,omitempty"`
Deployments []Deployment `json:"deployments,omitempty"`
Recovery *RecoveryReport `json:"recovery,omitempty"`
Timestamp time.Time `json:"timestamp"`
Agent AgentInfo `json:"agent"`
Cluster ClusterInfo `json:"cluster"`
Nodes []Node `json:"nodes,omitempty"`
Namespaces []Namespace `json:"namespaces,omitempty"`
Pods []Pod `json:"pods,omitempty"`
Deployments []Deployment `json:"deployments,omitempty"`
StatefulSets []StatefulSet `json:"statefulSets,omitempty"`
DaemonSets []DaemonSet `json:"daemonSets,omitempty"`
Services []Service `json:"services,omitempty"`
Jobs []Job `json:"jobs,omitempty"`
CronJobs []CronJob `json:"cronJobs,omitempty"`
Ingresses []Ingress `json:"ingresses,omitempty"`
PersistentVolumes []PersistentVolume `json:"persistentVolumes,omitempty"`
PersistentVolumeClaims []PersistentVolumeClaim `json:"persistentVolumeClaims,omitempty"`
Events []Event `json:"events,omitempty"`
Recovery *RecoveryReport `json:"recovery,omitempty"`
Timestamp time.Time `json:"timestamp"`
}
// AgentInfo describes the reporting agent instance.
@@ -114,6 +124,152 @@ type Deployment struct {
Labels map[string]string `json:"labels,omitempty"`
}
// Namespace represents a Kubernetes namespace at report time.
type Namespace struct {
UID string `json:"uid"`
Name string `json:"name"`
Phase string `json:"phase,omitempty"`
CreatedAt time.Time `json:"createdAt,omitempty"`
Labels map[string]string `json:"labels,omitempty"`
}
// StatefulSet represents a Kubernetes stateful set at report time.
type StatefulSet struct {
UID string `json:"uid"`
Name string `json:"name"`
Namespace string `json:"namespace"`
DesiredReplicas int32 `json:"desiredReplicas,omitempty"`
ReadyReplicas int32 `json:"readyReplicas,omitempty"`
CurrentReplicas int32 `json:"currentReplicas,omitempty"`
UpdatedReplicas int32 `json:"updatedReplicas,omitempty"`
AvailableReplicas int32 `json:"availableReplicas,omitempty"`
ServiceName string `json:"serviceName,omitempty"`
Labels map[string]string `json:"labels,omitempty"`
}
// DaemonSet represents a Kubernetes daemon set at report time.
type DaemonSet struct {
UID string `json:"uid"`
Name string `json:"name"`
Namespace string `json:"namespace"`
DesiredNumberScheduled int32 `json:"desiredNumberScheduled,omitempty"`
CurrentNumberScheduled int32 `json:"currentNumberScheduled,omitempty"`
NumberReady int32 `json:"numberReady,omitempty"`
UpdatedNumberScheduled int32 `json:"updatedNumberScheduled,omitempty"`
NumberAvailable int32 `json:"numberAvailable,omitempty"`
NumberUnavailable int32 `json:"numberUnavailable,omitempty"`
NumberMisscheduled int32 `json:"numberMisscheduled,omitempty"`
Labels map[string]string `json:"labels,omitempty"`
}
// Service represents a Kubernetes service at report time.
type Service struct {
UID string `json:"uid"`
Name string `json:"name"`
Namespace string `json:"namespace"`
Type string `json:"type,omitempty"`
ClusterIP string `json:"clusterIp,omitempty"`
ExternalIPs []string `json:"externalIps,omitempty"`
Ports []ServicePort `json:"ports,omitempty"`
Selector map[string]string `json:"selector,omitempty"`
CreatedAt time.Time `json:"createdAt,omitempty"`
Labels map[string]string `json:"labels,omitempty"`
}
// ServicePort describes one Kubernetes service port.
type ServicePort struct {
Name string `json:"name,omitempty"`
Protocol string `json:"protocol,omitempty"`
Port int32 `json:"port,omitempty"`
TargetPort string `json:"targetPort,omitempty"`
NodePort int32 `json:"nodePort,omitempty"`
}
// Job represents a Kubernetes job at report time.
type Job struct {
UID string `json:"uid"`
Name string `json:"name"`
Namespace string `json:"namespace"`
DesiredCompletions int32 `json:"desiredCompletions,omitempty"`
Succeeded int32 `json:"succeeded,omitempty"`
Failed int32 `json:"failed,omitempty"`
Active int32 `json:"active,omitempty"`
StartTime *time.Time `json:"startTime,omitempty"`
CompletionTime *time.Time `json:"completionTime,omitempty"`
Labels map[string]string `json:"labels,omitempty"`
}
// CronJob represents a Kubernetes cron job at report time.
type CronJob struct {
UID string `json:"uid"`
Name string `json:"name"`
Namespace string `json:"namespace"`
Schedule string `json:"schedule,omitempty"`
Suspend bool `json:"suspend,omitempty"`
Active int `json:"active,omitempty"`
LastScheduleTime *time.Time `json:"lastScheduleTime,omitempty"`
LastSuccessfulTime *time.Time `json:"lastSuccessfulTime,omitempty"`
Labels map[string]string `json:"labels,omitempty"`
}
// Ingress represents a Kubernetes ingress at report time.
type Ingress struct {
UID string `json:"uid"`
Name string `json:"name"`
Namespace string `json:"namespace"`
ClassName string `json:"className,omitempty"`
Hosts []string `json:"hosts,omitempty"`
Addresses []string `json:"addresses,omitempty"`
CreatedAt time.Time `json:"createdAt,omitempty"`
Labels map[string]string `json:"labels,omitempty"`
}
// PersistentVolume represents a Kubernetes persistent volume at report time.
type PersistentVolume struct {
UID string `json:"uid"`
Name string `json:"name"`
Phase string `json:"phase,omitempty"`
StorageClass string `json:"storageClass,omitempty"`
CapacityBytes int64 `json:"capacityBytes,omitempty"`
AccessModes []string `json:"accessModes,omitempty"`
ReclaimPolicy string `json:"reclaimPolicy,omitempty"`
ClaimNamespace string `json:"claimNamespace,omitempty"`
ClaimName string `json:"claimName,omitempty"`
CreatedAt time.Time `json:"createdAt,omitempty"`
Labels map[string]string `json:"labels,omitempty"`
}
// PersistentVolumeClaim represents a Kubernetes persistent volume claim at report time.
type PersistentVolumeClaim struct {
UID string `json:"uid"`
Name string `json:"name"`
Namespace string `json:"namespace"`
Phase string `json:"phase,omitempty"`
StorageClass string `json:"storageClass,omitempty"`
RequestedBytes int64 `json:"requestedBytes,omitempty"`
CapacityBytes int64 `json:"capacityBytes,omitempty"`
AccessModes []string `json:"accessModes,omitempty"`
VolumeName string `json:"volumeName,omitempty"`
CreatedAt time.Time `json:"createdAt,omitempty"`
Labels map[string]string `json:"labels,omitempty"`
}
// Event represents a Kubernetes event at report time.
type Event struct {
UID string `json:"uid"`
Name string `json:"name"`
Namespace string `json:"namespace,omitempty"`
Type string `json:"type,omitempty"`
Reason string `json:"reason,omitempty"`
Message string `json:"message,omitempty"`
InvolvedKind string `json:"involvedKind,omitempty"`
InvolvedName string `json:"involvedName,omitempty"`
Count int32 `json:"count,omitempty"`
FirstSeen *time.Time `json:"firstSeen,omitempty"`
LastSeen *time.Time `json:"lastSeen,omitempty"`
EventTime *time.Time `json:"eventTime,omitempty"`
}
// RecoveryReport contains optional, best-effort "recovery point" artifacts discovered by the agent.
// This is intentionally capped and time-bounded by the agent to keep payloads small.
type RecoveryReport struct {
@@ -3586,13 +3586,13 @@ class SubsystemLookupTest(unittest.TestCase):
self.assertEqual(
api_match["matched_contract_references"],
[
{
"heading": "## Shared Boundaries",
"path": "internal/api/access_control_handlers.go",
"line": 229,
"heading_line": 109,
}
],
{
"heading": "## Shared Boundaries",
"path": "internal/api/access_control_handlers.go",
"line": 238,
"heading_line": 109,
}
],
)
organization_match = by_subsystem["organization-settings"]
@@ -3628,7 +3628,7 @@ class SubsystemLookupTest(unittest.TestCase):
{
"heading": "## Extension Points",
"path": "internal/api/access_control_handlers.go",
"line": 81,
"line": 87,
"heading_line": 70,
},
],