diff --git a/docs/release-control/v6/internal/PLATFORM_SUPPORT_MANIFEST.json b/docs/release-control/v6/internal/PLATFORM_SUPPORT_MANIFEST.json index bedf32a45..3ceff9a68 100644 --- a/docs/release-control/v6/internal/PLATFORM_SUPPORT_MANIFEST.json +++ b/docs/release-control/v6/internal/PLATFORM_SUPPORT_MANIFEST.json @@ -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", diff --git a/docs/release-control/v6/internal/PLATFORM_SUPPORT_MODEL.md b/docs/release-control/v6/internal/PLATFORM_SUPPORT_MODEL.md index 61f649ef9..68e2d8bd5 100644 --- a/docs/release-control/v6/internal/PLATFORM_SUPPORT_MODEL.md +++ b/docs/release-control/v6/internal/PLATFORM_SUPPORT_MODEL.md @@ -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 | diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index 8cea889fa..1e2f29bcc 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -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 diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index 2242d2e39..8425c20f4 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -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 diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 6301a97ee..48ce32a56 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -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 diff --git a/docs/release-control/v6/internal/subsystems/monitoring.md b/docs/release-control/v6/internal/subsystems/monitoring.md index 0419d3971..023292589 100644 --- a/docs/release-control/v6/internal/subsystems/monitoring.md +++ b/docs/release-control/v6/internal/subsystems/monitoring.md @@ -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 diff --git a/docs/release-control/v6/internal/subsystems/organization-settings.md b/docs/release-control/v6/internal/subsystems/organization-settings.md index 8ca060b41..deb172809 100644 --- a/docs/release-control/v6/internal/subsystems/organization-settings.md +++ b/docs/release-control/v6/internal/subsystems/organization-settings.md @@ -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 diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index 23fc6286a..a92c2b509 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -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` diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index 1f4716c56..edc6f4f7c 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -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 diff --git a/frontend-modern/src/features/docker/DockerInventoryTable.tsx b/frontend-modern/src/features/docker/DockerInventoryTable.tsx new file mode 100644 index 000000000..a05771514 --- /dev/null +++ b/frontend-modern/src/features/docker/DockerInventoryTable.tsx @@ -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 => ( + {value ?? 0} +); +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 ( + 0} + fallback={ + + } + > +
+ + + + + 0} + fallback={ + + } + > + + + + + + + + + {(resource) => } + + +
+
+
+
+
+ ); +}; + +const DockerInventoryHeader: Component<{ variant: DockerInventoryVariant }> = (props) => { + if (props.variant === 'images') { + return ( + + + Image + + + + Size + + + In Use + + + + ); + } + if (props.variant === 'volumes') { + return ( + + + Volume + + + Driver + + + + Size + + + + + ); + } + if (props.variant === 'networks') { + return ( + + + Network + + + Driver + + + + Host + + ); + } + return ( + + Task + + + Desired + + + Current + + + + ); +}; + +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 ( + + +
+ + + {name()} + +
+
+ + + + {byteValue(props.resource.docker?.sizeBytes)} + + + {numberValue(props.resource.docker?.imageContainers)} + + + + + + {textValue(props.resource.docker?.driver)} + + + + {byteValue(props.resource.docker?.sizeBytes)} + + + + + + + {textValue(props.resource.docker?.driver)} + + + + + {host()} + + + + + + {textValue(props.resource.docker?.desiredState)} + + + {textValue(props.resource.docker?.currentState)} + + + +
+ ); +}; + +export default DockerInventoryTable; diff --git a/frontend-modern/src/features/docker/DockerPageSurface.tsx b/frontend-modern/src/features/docker/DockerPageSurface.tsx index f4bc70e2c..94ddfb5d8 100644 --- a/frontend-modern/src/features/docker/DockerPageSurface.tsx +++ b/frontend-modern/src/features/docker/DockerPageSurface.tsx @@ -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(DOCKER_TAB_SPECS.map((tab) => tab.id)); const dockerIcon = () => ; 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(() => { + 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() {
@@ -129,80 +139,65 @@ export function DockerPageSurface() { /> } > -
- -
- - Boolean( - workloadsState.selectedGuestId() || - workloadsState.focusedSummaryWorkloadGroupId(), - ) - } - onClearPinnedSelection={workloadsState.clearPinnedSummaryScope} - /> -
-
- + + + + + + + - + + - - - -
+ + + + + + + + + + @@ -211,3 +206,120 @@ export function DockerPageSurface() { } export default DockerPageSurface; + +type DockerWorkloadsState = ReturnType; + +function DockerWorkloadsFilter(props: { workloadsState: DockerWorkloadsState }) { + return ( +
+ + Boolean( + props.workloadsState.selectedGuestId() || + props.workloadsState.focusedSummaryWorkloadGroupId(), + ) + } + onClearPinnedSelection={props.workloadsState.clearPinnedSummaryScope} + /> +
+ ); +} + +function DockerContainers(props: { workloadsState: DockerWorkloadsState }) { + return ( +
+ + +
+ ); +} + +function DockerOverview(props: { + showSharedFilterToolbar: boolean; + workloadsState: DockerWorkloadsState; + hosts: ReturnType['hosts']; + hostSourceCount: number; + filteredServices: ReturnType['services']; + serviceSourceCount: number; + showServicesSection: boolean; +}) { + return ( +
+ + + + + + + + +
+ ); +} diff --git a/frontend-modern/src/features/docker/__tests__/dockerPageModel.test.ts b/frontend-modern/src/features/docker/__tests__/dockerPageModel.test.ts index 6891add03..ba8d8cc27 100644 --- a/frontend-modern/src/features/docker/__tests__/dockerPageModel.test.ts +++ b/frontend-modern/src/features/docker/__tests__/dockerPageModel.test.ts @@ -62,7 +62,7 @@ const makeDockerService = (overrides: Partial = {}): 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(), ); }); diff --git a/frontend-modern/src/features/docker/dockerPageModel.ts b/frontend-modern/src/features/docker/dockerPageModel.ts index 1b5f989d3..93ac57763 100644 --- a/frontend-modern/src/features/docker/dockerPageModel.ts +++ b/frontend-modern/src/features/docker/dockerPageModel.ts @@ -11,6 +11,33 @@ import { buildAppContainerDefaultHiddenColumnIds } from '@/features/platformPage const DOCKER_HOST_TYPES = new Set(['agent', 'docker-host']); const DOCKER_CONTAINER_TYPES = new Set(['app-container']); const DOCKER_SERVICE_TYPES = new Set(['docker-service']); +const DOCKER_IMAGE_TYPES = new Set(['docker-image']); +const DOCKER_VOLUME_TYPES = new Set(['docker-volume']); +const DOCKER_NETWORK_TYPES = new Set(['docker-network']); +const DOCKER_TASK_TYPES = new Set(['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 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), + ); +} diff --git a/frontend-modern/src/features/kubernetes/KubernetesInventoryTable.tsx b/frontend-modern/src/features/kubernetes/KubernetesInventoryTable.tsx new file mode 100644 index 000000000..3b527451c --- /dev/null +++ b/frontend-modern/src/features/kubernetes/KubernetesInventoryTable.tsx @@ -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 => ( + {value ?? 0} +); +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 ( + 0} + fallback={ + + } + > +
+ + + + 0} + fallback={ + + } + > + + + + + + + + + {(resource) => ( + + )} + + +
+
+
+
+
+ ); +}; + +const KubernetesInventoryHeader: Component<{ variant: KubernetesInventoryVariant }> = (props) => ( + + Name + Kind + + + Phase + + + Size + + + + + Type + + + + + + Desired + + + Ready + + + + + Phase + + + + Reason + + + Count + + + + +); + +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 ( + + +
+ + + {name()} + +
+
+ + {kind()} + + + + + {textValue(props.resource.kubernetes?.phase)} + + + + {byteValue( + props.resource.kubernetes?.capacityBytes || props.resource.kubernetes?.requestedBytes, + )} + + + + + + {textValue( + props.resource.kubernetes?.serviceType || props.resource.kubernetes?.className, + )} + + + + + + + {numberValue(desired())} + + + {numberValue(ready())} + + + + + + {textValue(props.resource.kubernetes?.phase)} + + + + + + {textValue(props.resource.kubernetes?.reason)} + + + + {numberValue(props.resource.kubernetes?.count)} + + + +
+ ); +}; + +export default KubernetesInventoryTable; diff --git a/frontend-modern/src/features/kubernetes/KubernetesPageSurface.tsx b/frontend-modern/src/features/kubernetes/KubernetesPageSurface.tsx index e4f8c8948..ae147d1e4 100644 --- a/frontend-modern/src/features/kubernetes/KubernetesPageSurface.tsx +++ b/frontend-modern/src/features/kubernetes/KubernetesPageSurface.tsx @@ -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(KUBERNETES_TAB_SPECS.map((tab) => tab.id)); const k8sIcon = () => ; 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(() => { + 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 (
@@ -83,7 +95,57 @@ export function KubernetesPageSurface() { /> } > - + + + + + + + + + + + + + + + + + + + + + @@ -96,6 +158,16 @@ interface KubernetesOverviewProps { } function KubernetesOverview(props: KubernetesOverviewProps) { + return ; +} + +function KubernetesWorkloads(props: KubernetesOverviewProps) { + return ; +} + +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 (
@@ -178,21 +260,23 @@ function KubernetesOverview(props: KubernetesOverviewProps) { />
- - + + + + 0}> + 0}> + + & Pick { - 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', () => { diff --git a/frontend-modern/src/features/kubernetes/kubernetesPageModel.ts b/frontend-modern/src/features/kubernetes/kubernetesPageModel.ts index 789e293b2..c89d5435f 100644 --- a/frontend-modern/src/features/kubernetes/kubernetesPageModel.ts +++ b/frontend-modern/src/features/kubernetes/kubernetesPageModel.ts @@ -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([ @@ -31,6 +31,16 @@ const KUBERNETES_RESOURCE_TYPES = new Set([ '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, }; } diff --git a/frontend-modern/src/features/platformPage/sharedPlatformPage.tsx b/frontend-modern/src/features/platformPage/sharedPlatformPage.tsx index 82eaa4a7a..1a93a989f 100644 --- a/frontend-modern/src/features/platformPage/sharedPlatformPage.tsx +++ b/frontend-modern/src/features/platformPage/sharedPlatformPage.tsx @@ -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') diff --git a/frontend-modern/src/hooks/useUnifiedResources.ts b/frontend-modern/src/hooks/useUnifiedResources.ts index 9efb6a3f2..541b4567b 100644 --- a/frontend-modern/src/hooks/useUnifiedResources.ts +++ b/frontend-modern/src/hooks/useUnifiedResources.ts @@ -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': diff --git a/frontend-modern/src/types/__tests__/resource.test.ts b/frontend-modern/src/types/__tests__/resource.test.ts index 56015d90c..094d413a8 100644 --- a/frontend-modern/src/types/__tests__/resource.test.ts +++ b/frontend-modern/src/types/__tests__/resource.test.ts @@ -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', diff --git a/frontend-modern/src/types/resource.ts b/frontend-modern/src/types/resource.ts index 25385c917..5c5e2bb69 100644 --- a/frontend-modern/src/types/resource.ts +++ b/frontend-modern/src/types/resource.ts @@ -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; 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; + volumeName?: string; + driver?: string; + mountpoint?: string; + scope?: string; + createdAt?: string; + refCount?: number; + options?: Record; + 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; + 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; diff --git a/frontend-modern/src/utils/__tests__/canonicalResourceTypes.test.ts b/frontend-modern/src/utils/__tests__/canonicalResourceTypes.test.ts index c3d4a9bdf..e96d223cb 100644 --- a/frontend-modern/src/utils/__tests__/canonicalResourceTypes.test.ts +++ b/frontend-modern/src/utils/__tests__/canonicalResourceTypes.test.ts @@ -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); }); diff --git a/frontend-modern/src/utils/__tests__/reportableResourceTypes.test.ts b/frontend-modern/src/utils/__tests__/reportableResourceTypes.test.ts index ab18ed39f..1aa43add5 100644 --- a/frontend-modern/src/utils/__tests__/reportableResourceTypes.test.ts +++ b/frontend-modern/src/utils/__tests__/reportableResourceTypes.test.ts @@ -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'); diff --git a/frontend-modern/src/utils/__tests__/reportingResourceTypes.test.ts b/frontend-modern/src/utils/__tests__/reportingResourceTypes.test.ts index 5d9957918..8bf3e7ef7 100644 --- a/frontend-modern/src/utils/__tests__/reportingResourceTypes.test.ts +++ b/frontend-modern/src/utils/__tests__/reportingResourceTypes.test.ts @@ -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'); }); }); diff --git a/frontend-modern/src/utils/__tests__/resourceTypeCompat.test.ts b/frontend-modern/src/utils/__tests__/resourceTypeCompat.test.ts index ef2dc368d..04936581b 100644 --- a/frontend-modern/src/utils/__tests__/resourceTypeCompat.test.ts +++ b/frontend-modern/src/utils/__tests__/resourceTypeCompat.test.ts @@ -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', () => { diff --git a/frontend-modern/src/utils/__tests__/resourceTypePresentation.test.ts b/frontend-modern/src/utils/__tests__/resourceTypePresentation.test.ts index 41d9eae56..073cf10b0 100644 --- a/frontend-modern/src/utils/__tests__/resourceTypePresentation.test.ts +++ b/frontend-modern/src/utils/__tests__/resourceTypePresentation.test.ts @@ -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', () => { diff --git a/frontend-modern/src/utils/canonicalResourceTypes.ts b/frontend-modern/src/utils/canonicalResourceTypes.ts index 6b8806b30..86c03734d 100644 --- a/frontend-modern/src/utils/canonicalResourceTypes.ts +++ b/frontend-modern/src/utils/canonicalResourceTypes.ts @@ -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', diff --git a/frontend-modern/src/utils/platformSupportManifest.generated.ts b/frontend-modern/src/utils/platformSupportManifest.generated.ts index e2140bcaf..8f374bf3e 100644 --- a/frontend-modern/src/utils/platformSupportManifest.generated.ts +++ b/frontend-modern/src/utils/platformSupportManifest.generated.ts @@ -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', diff --git a/frontend-modern/src/utils/reportingResourceTypes.ts b/frontend-modern/src/utils/reportingResourceTypes.ts index f67a499db..85eb90c88 100644 --- a/frontend-modern/src/utils/reportingResourceTypes.ts +++ b/frontend-modern/src/utils/reportingResourceTypes.ts @@ -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'; diff --git a/frontend-modern/src/utils/resourceSearchMatch.ts b/frontend-modern/src/utils/resourceSearchMatch.ts index b7d4ee736..087011eae 100644 --- a/frontend-modern/src/utils/resourceSearchMatch.ts +++ b/frontend-modern/src/utils/resourceSearchMatch.ts @@ -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)); diff --git a/frontend-modern/src/utils/resourceTypeCompat.ts b/frontend-modern/src/utils/resourceTypeCompat.ts index 202132b93..7f30096b9 100644 --- a/frontend-modern/src/utils/resourceTypeCompat.ts +++ b/frontend-modern/src/utils/resourceTypeCompat.ts @@ -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: diff --git a/frontend-modern/src/utils/resourceTypePresentation.ts b/frontend-modern/src/utils/resourceTypePresentation.ts index 7faf1f59e..d05eaca9f 100644 --- a/frontend-modern/src/utils/resourceTypePresentation.ts +++ b/frontend-modern/src/utils/resourceTypePresentation.ts @@ -54,6 +54,22 @@ const RESOURCE_TYPE_PRESENTATION: Partial 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() diff --git a/internal/dockeragent/docker_client.go b/internal/dockeragent/docker_client.go index ed60c93d1..7104a13be 100644 --- a/internal/dockeragent/docker_client.go +++ b/internal/dockeragent/docker_client.go @@ -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 { diff --git a/internal/dockeragent/hardening.go b/internal/dockeragent/hardening.go index f705a76cb..b532e0a75 100644 --- a/internal/dockeragent/hardening.go +++ b/internal/dockeragent/hardening.go @@ -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 diff --git a/internal/dockeragent/test_helpers_test.go b/internal/dockeragent/test_helpers_test.go index 0bfa91854..75457b01f 100644 --- a/internal/dockeragent/test_helpers_test.go +++ b/internal/dockeragent/test_helpers_test.go @@ -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") diff --git a/internal/kubernetesagent/agent.go b/internal/kubernetesagent/agent.go index 284207e3b..d4f34cbb1 100644 --- a/internal/kubernetesagent/agent.go +++ b/internal/kubernetesagent/agent.go @@ -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 diff --git a/internal/models/deepcopy.go b/internal/models/deepcopy.go index e0a496502..330cd208a 100644 --- a/internal/models/deepcopy.go +++ b/internal/models/deepcopy.go @@ -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() } diff --git a/internal/models/models.go b/internal/models/models.go index 711894d3d..09b03eb7b 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -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"` diff --git a/internal/monitoring/canonical_guardrails_test.go b/internal/monitoring/canonical_guardrails_test.go index 188cc09c3..5e27769aa 100644 --- a/internal/monitoring/canonical_guardrails_test.go +++ b/internal/monitoring/canonical_guardrails_test.go @@ -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", diff --git a/internal/monitoring/kubernetes_agents.go b/internal/monitoring/kubernetes_agents.go index e364ae3cb..e0c13d233 100644 --- a/internal/monitoring/kubernetes_agents.go +++ b/internal/monitoring/kubernetes_agents.go @@ -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 { diff --git a/internal/monitoring/kubernetes_agents_test.go b/internal/monitoring/kubernetes_agents_test.go index 71effb973..1961b2270 100644 --- a/internal/monitoring/kubernetes_agents_test.go +++ b/internal/monitoring/kubernetes_agents_test.go @@ -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() diff --git a/internal/monitoring/monitor_agents.go b/internal/monitoring/monitor_agents.go index 84729e23d..52e03853e 100644 --- a/internal/monitoring/monitor_agents.go +++ b/internal/monitoring/monitor_agents.go @@ -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), diff --git a/internal/monitoring/monitor_helpers.go b/internal/monitoring/monitor_helpers.go index 1f306e4c9..214db544b 100644 --- a/internal/monitoring/monitor_helpers.go +++ b/internal/monitoring/monitor_helpers.go @@ -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 diff --git a/internal/monitoring/monitor_host_agents_test.go b/internal/monitoring/monitor_host_agents_test.go index 7999b142a..47a889469 100644 --- a/internal/monitoring/monitor_host_agents_test.go +++ b/internal/monitoring/monitor_host_agents_test.go @@ -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() diff --git a/internal/unifiedresources/adapter_coverage_test.go b/internal/unifiedresources/adapter_coverage_test.go index 9335b9e29..1d9ee009a 100644 --- a/internal/unifiedresources/adapter_coverage_test.go +++ b/internal/unifiedresources/adapter_coverage_test.go @@ -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{ diff --git a/internal/unifiedresources/adapters.go b/internal/unifiedresources/adapters.go index 5785bf0dd..31ac38fcd 100644 --- a/internal/unifiedresources/adapters.go +++ b/internal/unifiedresources/adapters.go @@ -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 != ":" { + 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 diff --git a/internal/unifiedresources/canonical_ids_types_test.go b/internal/unifiedresources/canonical_ids_types_test.go index 5ea52c0c0..f8a007e23 100644 --- a/internal/unifiedresources/canonical_ids_types_test.go +++ b/internal/unifiedresources/canonical_ids_types_test.go @@ -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 { diff --git a/internal/unifiedresources/clone.go b/internal/unifiedresources/clone.go index d44fa4381..fb5c121e4 100644 --- a/internal/unifiedresources/clone.go +++ b/internal/unifiedresources/clone.go @@ -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 diff --git a/internal/unifiedresources/kubernetes_registry_test.go b/internal/unifiedresources/kubernetes_registry_test.go index c85b8d83b..99e22da93 100644 --- a/internal/unifiedresources/kubernetes_registry_test.go +++ b/internal/unifiedresources/kubernetes_registry_test.go @@ -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") diff --git a/internal/unifiedresources/registry.go b/internal/unifiedresources/registry.go index 0b2356b2d..c9b45942c 100644 --- a/internal/unifiedresources/registry.go +++ b/internal/unifiedresources/registry.go @@ -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 diff --git a/internal/unifiedresources/registry_test.go b/internal/unifiedresources/registry_test.go index 936d93bba..a80525e50 100644 --- a/internal/unifiedresources/registry_test.go +++ b/internal/unifiedresources/registry_test.go @@ -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) + } + } +} diff --git a/internal/unifiedresources/status.go b/internal/unifiedresources/status.go index 9c780a44f..f72624744 100644 --- a/internal/unifiedresources/status.go +++ b/internal/unifiedresources/status.go @@ -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 +} diff --git a/internal/unifiedresources/types.go b/internal/unifiedresources/types.go index fa75c2515..c9b41ab16 100644 --- a/internal/unifiedresources/types.go +++ b/internal/unifiedresources/types.go @@ -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. diff --git a/pkg/agents/docker/report.go b/pkg/agents/docker/report.go index b884dfd95..3358fac87 100644 --- a/pkg/agents/docker/report.go +++ b/pkg/agents/docker/report.go @@ -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 != "" { diff --git a/pkg/agents/kubernetes/report.go b/pkg/agents/kubernetes/report.go index cf46a20aa..ea7c88cfd 100644 --- a/pkg/agents/kubernetes/report.go +++ b/pkg/agents/kubernetes/report.go @@ -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 { diff --git a/scripts/release_control/subsystem_lookup_test.py b/scripts/release_control/subsystem_lookup_test.py index 598045339..05e99778e 100644 --- a/scripts/release_control/subsystem_lookup_test.py +++ b/scripts/release_control/subsystem_lookup_test.py @@ -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, }, ],