@@ -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,
},
],