mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 02:25:56 +00:00
Expand Docker and Kubernetes platform projections
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()})
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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},
|
||||
}
|
||||
|
||||
|
||||
@@ -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{}{}},
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 != "" {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user