diff --git a/docs/release-control/v6/internal/PLATFORM_SUPPORT_MANIFEST.json b/docs/release-control/v6/internal/PLATFORM_SUPPORT_MANIFEST.json index 1e8f2ce19..369938646 100644 --- a/docs/release-control/v6/internal/PLATFORM_SUPPORT_MANIFEST.json +++ b/docs/release-control/v6/internal/PLATFORM_SUPPORT_MANIFEST.json @@ -86,7 +86,8 @@ "docker-volume", "docker-network", "docker-service", - "docker-task" + "docker-task", + "docker-swarm-node" ], "support_floor": { "setup": "supported", diff --git a/docs/release-control/v6/internal/PLATFORM_SUPPORT_MODEL.md b/docs/release-control/v6/internal/PLATFORM_SUPPORT_MODEL.md index a1b1ddcf8..bd2a736f7 100644 --- a/docs/release-control/v6/internal/PLATFORM_SUPPORT_MODEL.md +++ b/docs/release-control/v6/internal/PLATFORM_SUPPORT_MODEL.md @@ -208,7 +208,10 @@ physical host. TrueNAS-managed apps. 5. Docker / Podman runtime inventory projects as `app-container`, `docker-image`, `docker-volume`, `docker-network`, `docker-service`, and - `docker-task` under the `docker` runtime lens. + `docker-task` under the `docker` runtime lens. Docker Swarm node inventory + projects as `docker-swarm-node` when a Docker manager exposes the Swarm + Nodes API; Podman pod inventory remains out of scope until Pulse has a + libpod-native collector path instead of the Docker-compatible client. 6. Kubernetes projects as `k8s-cluster`, `k8s-node`, `pod`, `k8s-deployment`, `k8s-replicaset`, `k8s-statefulset`, `k8s-daemonset`, `k8s-service`, `k8s-job`, `k8s-cronjob`, @@ -324,7 +327,7 @@ Support floor fields are recorded in this order: `setup`, `visibility`, | Platform | Surface kind | Governance | Readiness | Primary mode | Onboarding | Canonical projections | Support floor | | --- | --- | --- | --- | --- | --- | --- | --- | | `agent` | `platform` | `supported` | `supported` | `agent-backed` | `install-workspace` | `agent`, `storage`, `physical-disk` | `setup=supported`; `visibility=supported`; `workloads=n/a`; `storage=supported`; `recovery=n/a`; `alerts=supported`; `assistant_read=supported`; `assistant_control=supported` | -| `docker` | `runtime-lens` | `supported` | `supported` | `agent-backed` | `install-workspace` | `agent`, `app-container`, `docker-image`, `docker-volume`, `docker-network`, `docker-service`, `docker-task` | `setup=supported`; `visibility=supported`; `workloads=supported`; `storage=supported`; `recovery=n/a`; `alerts=supported`; `assistant_read=supported`; `assistant_control=supported` | +| `docker` | `runtime-lens` | `supported` | `supported` | `agent-backed` | `install-workspace` | `agent`, `app-container`, `docker-image`, `docker-volume`, `docker-network`, `docker-service`, `docker-task`, `docker-swarm-node` | `setup=supported`; `visibility=supported`; `workloads=supported`; `storage=supported`; `recovery=n/a`; `alerts=supported`; `assistant_read=supported`; `assistant_control=supported` | | `kubernetes` | `platform` | `supported` | `supported` | `agent-backed` | `install-workspace` | `k8s-cluster`, `k8s-node`, `pod`, `k8s-deployment`, `k8s-replicaset`, `k8s-statefulset`, `k8s-daemonset`, `k8s-service`, `k8s-job`, `k8s-cronjob`, `k8s-ingress`, `k8s-endpoint-slice`, `k8s-network-policy`, `k8s-persistent-volume`, `k8s-persistent-volume-claim`, `k8s-storage-class`, `k8s-namespace`, `k8s-configmap`, `k8s-serviceaccount`, `k8s-event` | `setup=supported`; `visibility=supported`; `workloads=supported`; `storage=supported`; `recovery=supported`; `alerts=supported`; `assistant_read=supported`; `assistant_control=supported` | | `proxmox-pve` | `platform` | `supported` | `supported` | `api-backed` | `platform-connections` | `agent`, `vm`, `system-container`, `storage`, `ceph`, `physical-disk` | `setup=supported`; `visibility=supported`; `workloads=supported`; `storage=supported`; `recovery=supported`; `alerts=supported`; `assistant_read=supported`; `assistant_control=augmentation-only` | | `proxmox-pbs` | `platform` | `supported` | `supported` | `api-backed` | `platform-connections` | `pbs`, `storage` | `setup=supported`; `visibility=supported`; `workloads=n/a`; `storage=supported`; `recovery=supported`; `alerts=supported`; `assistant_read=supported`; `assistant_control=read-only` | @@ -383,7 +386,7 @@ or runtime platform fallbacks that are not declared in this document. | Platform | Surface kind | Family | Primary mode | Optional augmentation | Canonical projections | | ---------------- | ------------- | ------------------ | ------------ | ------------------------------------- | --------------------------------------------------------------------- | | `agent` | platform | Pulse-managed host | agent-backed | none | `agent`, `storage`, `physical-disk` | -| `docker` | runtime-lens | container runtime | agent-backed | none | `agent`, `app-container`, `docker-image`, `docker-volume`, `docker-network`, `docker-service`, `docker-task` | +| `docker` | runtime-lens | container runtime | agent-backed | none | `agent`, `app-container`, `docker-image`, `docker-volume`, `docker-network`, `docker-service`, `docker-task`, `docker-swarm-node` | | `kubernetes` | platform | cluster runtime | agent-backed | none | `k8s-cluster`, `k8s-node`, `pod`, `k8s-deployment`, `k8s-replicaset`, `k8s-statefulset`, `k8s-daemonset`, `k8s-service`, `k8s-job`, `k8s-cronjob`, `k8s-ingress`, `k8s-endpoint-slice`, `k8s-network-policy`, `k8s-persistent-volume`, `k8s-persistent-volume-claim`, `k8s-storage-class`, `k8s-namespace`, `k8s-configmap`, `k8s-serviceaccount`, `k8s-event` | | `proxmox-pve` | platform | Proxmox | api-backed | host agent may augment into hybrid | `agent`, `vm`, `system-container`, `storage`, `ceph`, `physical-disk` | | `proxmox-pbs` | platform | Proxmox | api-backed | host agent may augment into hybrid | `pbs`, `storage` | diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index c87bdd37f..5576120f3 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -182,11 +182,15 @@ agent install target, a command-agent authority, or a reason to widen setup tokens. Docker / Podman native inventory and Kubernetes native API inventory follow that same lifecycle boundary. Images, volumes, networks, Swarm tasks, -Kubernetes Services, controllers, EndpointSlices, NetworkPolicies, PV/PVC -records, StorageClasses, ConfigMaps, ServiceAccounts, ingresses, namespaces, -and events may appear as API/unified-resource evidence, but lifecycle surfaces -must not treat those child inventory rows as agent enrollment candidates, -install targets, fleet command authorities, or setup-token scopes. +Swarm nodes, engine storage-usage buckets, Kubernetes Services, controllers, +EndpointSlices, NetworkPolicies, PV/PVC records, StorageClasses, ConfigMaps, +ServiceAccounts, ingresses, namespaces, and events may appear as +API/unified-resource evidence, but lifecycle surfaces must not treat those +child inventory rows as agent enrollment candidates, install targets, fleet +command authorities, or setup-token scopes. Podman libpod pod records are not +part of that lifecycle-visible inventory until a libpod-native collector owns +them explicitly; Docker-compatible Podman container labels remain runtime +metadata only. VMware vSphere `network` resources follow that same lifecycle boundary. A vCenter connection may project networks into platform tables, resource pickers, Assistant context, or monitored-system previews through the shared diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index 0f9ef80b0..afb5539f6 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -118,14 +118,15 @@ checks, merge tenants, or become the source of truth for telemetry freshness. The `/api/resources` type filter is the REST contract boundary for platform page native inventory. It must accept canonical Docker / Podman runtime tokens -(`docker-image`, `docker-volume`, `docker-network`, `docker-task`) and -canonical Kubernetes API object tokens (`k8s-namespace`, `k8s-service`, -`k8s-replicaset`, `k8s-statefulset`, `k8s-daemonset`, `k8s-job`, -`k8s-cronjob`, `k8s-ingress`, `k8s-endpoint-slice`, -`k8s-network-policy`, `k8s-persistent-volume`, +(`docker-image`, `docker-volume`, `docker-network`, `docker-task`, +`docker-swarm-node`) and canonical Kubernetes API object tokens +(`k8s-namespace`, `k8s-service`, `k8s-replicaset`, `k8s-statefulset`, +`k8s-daemonset`, `k8s-job`, `k8s-cronjob`, `k8s-ingress`, +`k8s-endpoint-slice`, `k8s-network-policy`, `k8s-persistent-volume`, `k8s-persistent-volume-claim`, `k8s-storage-class`, `k8s-configmap`, -`k8s-serviceaccount`, `k8s-event`) whenever -unified resources can publish those records. Unsupported legacy aliases should +`k8s-serviceaccount`, `k8s-event`) whenever unified resources can publish +those records. Docker node and Swarm node aliases may normalize to the +canonical `docker-swarm-node` token, but unsupported legacy aliases should continue to fail closed instead of silently widening platform queries. Discovery read endpoints are a canonical API payload boundary even when Pulse diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 2de34178a..710215704 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -169,7 +169,10 @@ Platform page subnavigation is a shared frontend primitive. Docker / Podman and Kubernetes platform pages may add native API-backed sections, but the tabs must use `PlatformSectionTabs`, canonical table alignment helpers, and shared resource type presentation/reporting helpers rather than page-local tab shells, -alignment classes, or ad hoc report-category coercion. +alignment classes, or ad hoc report-category coercion. Docker / Podman native +subsections now include engine storage usage and Swarm node inventory where the +documented Docker APIs report those resources; Podman-only libpod pod inventory +must not be represented until the collector has a libpod-native source. 1. `frontend-modern/src/components/Settings/APIAccessPanel.tsx` shared with `security-privacy`: the API Access settings intro is both a security/privacy token-management trust surface and a canonical settings-shell presentation boundary. The panel may own shell placement and local action layout, but diff --git a/docs/release-control/v6/internal/subsystems/monitoring.md b/docs/release-control/v6/internal/subsystems/monitoring.md index 96d786276..9957d9486 100644 --- a/docs/release-control/v6/internal/subsystems/monitoring.md +++ b/docs/release-control/v6/internal/subsystems/monitoring.md @@ -68,16 +68,21 @@ truth for live infrastructure data. 3. Add typed read access through `internal/unifiedresources/views.go` 4. Add unified supplemental ingest through `internal/monitoring/poll_providers.go` 5. Add or change container startup ownership/bootstrap behavior for hosted or managed Pulse runtime mounts through `docker-entrypoint.sh` -6. Add or change Docker Swarm manager task/service runtime collection through `internal/dockeragent/swarm.go` +6. Add or change Docker Swarm manager service, task, or node runtime collection through `internal/dockeragent/swarm.go` + Swarm node inventory is manager-sourced through the documented nodes API + when available and falls back to the local `system/info` Swarm node + metadata when a worker or non-manager runtime cannot list cluster nodes. + Manager-side list failures are warnings, not host-report failures. 7. Add or change Docker or Podman container stats compatibility and runtime metric semantics through `internal/dockeragent/collect.go` Docker / Podman collection now owns native runtime inventory as well as container metrics. It may collect image summaries, volume summaries, - network summaries, Swarm tasks, and daemon storage-usage buckets from the - documented runtime API, then publish those records through the Docker agent - report for unified-resource ingestion. Failures in image, volume, network, - or storage-usage collection are best-effort warnings and must not make the - whole host report fail when container/runtime health data is otherwise - usable. + network summaries, Swarm services, Swarm tasks, Swarm nodes, and daemon + storage-usage buckets from the documented runtime API, then publish those + records through the Docker agent report for unified-resource ingestion. + Failures in image, volume, network, node, or storage-usage collection are + best-effort warnings and must not make the whole host report fail when + container/runtime health data is otherwise usable. Podman libpod pods remain + outside this collector until a libpod-native collector owns that API shape. 8. Add or change Proxmox Ceph compatibility payload decoding through `pkg/proxmox/ceph.go` 9. Add or change Proxmox ZFS compatibility payload decoding and vdev-role normalization through `pkg/proxmox/zfs.go` 10. Add or change mock chart synthesis, seeded history continuity, or mock-owned diff --git a/docs/release-control/v6/internal/subsystems/organization-settings.md b/docs/release-control/v6/internal/subsystems/organization-settings.md index 680e8e221..9086c3cf9 100644 --- a/docs/release-control/v6/internal/subsystems/organization-settings.md +++ b/docs/release-control/v6/internal/subsystems/organization-settings.md @@ -81,7 +81,7 @@ create, review, and approve cross-organization shares. Organization resource shares may reference the canonical Docker / Podman and Kubernetes inventory resource types exposed by `/api/resources`, but the organization layer only validates the bounded resource-type vocabulary. It - must not reinterpret a Docker image/volume/network/task or Kubernetes + must not reinterpret a Docker image/volume/network/task/Swarm-node row or Kubernetes Service/controller/storage/networking/config/event row, including ReplicaSets, EndpointSlices, NetworkPolicies, StorageClasses, ConfigMaps, or ServiceAccounts, as a broader organization role, agent enrollment authority, diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index 1af78a998..38651899a 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -163,6 +163,11 @@ volume rows or Kubernetes PV/PVC/StorageClass rows. Other Kubernetes native inventory objects that flow through the shared resource decoder, such as ConfigMaps and ServiceAccounts, remain platform configuration evidence only and must not become storage/recovery ownership, restore scope, or secret material. +Docker engine `/system/df` storage-usage buckets are host-level runtime +capacity evidence for the Docker page and unified-resource Docker host facet; +they are not storage/recovery resources, recovery-point sources, or restore +entitlements. Docker Swarm node records are likewise runtime topology context, +not storage owners or recovery scope. 1. Add or change recovery-point persistence, rollups, or series derivation through `internal/recovery/` 2. Add or change recovery page UX through `frontend-modern/src/components/Recovery/` and keep canonical route/query/filter state ownership in `frontend-modern/src/features/recovery/useRecoverySurfaceState.ts` diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index 08a3796c1..b78c8162b 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -226,10 +226,18 @@ reintroduce false Swarm capability surfaces. preserving service name, boot enablement, runtime state, and process IDs through clone, merge, transport, and frontend decode paths. Docker / Podman inventory extends that same canonical type contract beyond - containers and Swarm services. Runtime image, volume, network, task, and - storage-usage evidence must enter through `DockerData` and the typed Docker - resource records (`docker-image`, `docker-volume`, `docker-network`, - `docker-task`) rather than being inferred inside the container page. + containers and Swarm services. Runtime image, volume, network, task, Swarm + node, and storage-usage evidence must enter through `DockerData` and the + typed Docker resource records (`docker-image`, `docker-volume`, + `docker-network`, `docker-task`, `docker-swarm-node`) rather than being + inferred inside the container page. Swarm node records must preserve node + id, hostname, role, availability, state, manager reachability, manager + address, leader state, engine version, platform, resource capacity, labels, + and engine labels under the owning Docker host or Swarm cluster identity. + Host `/system/df` buckets remain Docker host facet data, not generic storage + resources. Podman libpod pod records must not be projected until a + libpod-native source can populate a native contract instead of deriving pods + from Docker-compatible container labels. Kubernetes inventory likewise projects native API objects as first-class unified resources: namespaces, Services, ReplicaSets, StatefulSets, DaemonSets, Jobs, CronJobs, Ingresses, EndpointSlices, NetworkPolicies, @@ -1974,15 +1982,18 @@ Swarm only.`) instead of explanatory sentences, so mismatch fallback state stays readable without turning support surfaces into inline documentation. Docker platform subtabs now follow canonical resource evidence as well: `Containers` is visible only when Docker/Podman app-container resources exist, -and `Swarm services` is visible only when canonical `docker-service` resources -exist. Inactive standalone Docker Swarm metadata is not a tab, host role, or -service-surface signal. +`Storage` is backed by host-level Docker/Podman `/system/df` usage buckets, +`Swarm services` is visible only when canonical `docker-service` resources +exist, and `Swarm nodes` is backed by canonical `docker-swarm-node` resources +from Docker manager node inventory. Inactive standalone Docker Swarm metadata +is not a tab, host role, or service-surface signal. The frontend Docker facet contract covers both host runtime telemetry and -Swarm service projection. Host resources may expose runtime/version, OS, -temperature, uptime, container counts, update state, command metadata, and -Swarm local-state/control evidence through `ResourceDockerMeta`; Docker -platform pages must consume those canonical fields rather than keeping -page-local host-runtime shape aliases. +Swarm service/task/node projection. Host resources may expose runtime/version, +OS, temperature, uptime, container/image/volume/network/node counts, update +state, command metadata, engine storage usage buckets, and Swarm +local-state/control evidence through `ResourceDockerMeta`; Docker platform +pages must consume those canonical fields rather than keeping page-local +host-runtime shape aliases. Canonical resources now also carry `platformScopes`, the normalized platform-page membership list consumed by workload filters. `platformType` remains the primary display/source family, while `platformScopes` captures diff --git a/frontend-modern/src/features/docker/DockerInventoryTable.tsx b/frontend-modern/src/features/docker/DockerInventoryTable.tsx index a05771514..ef779ced1 100644 --- a/frontend-modern/src/features/docker/DockerInventoryTable.tsx +++ b/frontend-modern/src/features/docker/DockerInventoryTable.tsx @@ -28,7 +28,7 @@ import { } from '@/features/platformPage/sharedPlatformPage'; import type { Resource } from '@/types/resource'; -type DockerInventoryVariant = 'images' | 'volumes' | 'networks' | 'tasks'; +type DockerInventoryVariant = 'images' | 'volumes' | 'networks' | 'nodes' | 'tasks'; const textValue = (value: string | undefined): string => asTrimmedString(value) || '—'; const numberValue = (value: number | undefined): JSX.Element => ( @@ -36,6 +36,11 @@ const numberValue = (value: number | undefined): JSX.Element => ( ); const byteValue = (value: number | undefined): string => typeof value === 'number' && value > 0 ? formatBytes(value) : '—'; +const cpuValue = (nanoCpus: number | undefined): string => { + if (typeof nanoCpus !== 'number' || nanoCpus <= 0) return '—'; + const cpus = nanoCpus / 1_000_000_000; + return cpus >= 10 ? `${Math.round(cpus)}` : cpus.toFixed(cpus % 1 === 0 ? 0 : 1); +}; const joinValues = (values: readonly (string | undefined)[] | undefined, empty = '—'): string => { const joined = (values ?? []) @@ -54,6 +59,8 @@ const dockerTableTitle = (variant: DockerInventoryVariant, explicit?: string): s return 'Volumes'; case 'networks': return 'Networks'; + case 'nodes': + return 'Swarm Nodes'; case 'tasks': return 'Swarm Tasks'; } @@ -67,6 +74,8 @@ const searchPlaceholder = (variant: DockerInventoryVariant): string => { return 'Search volumes'; case 'networks': return 'Search networks'; + case 'nodes': + return 'Search Swarm nodes'; case 'tasks': return 'Search Swarm tasks'; } @@ -221,6 +230,35 @@ const DockerInventoryHeader: Component<{ variant: DockerInventoryVariant }> = (p ); } + if (props.variant === 'nodes') { + return ( + + Node + Role + + Availability + + + + + + Memory + + + ); + } return ( Task @@ -353,6 +391,36 @@ const DockerInventoryRow: Component<{ resource: Resource; variant: DockerInvento {textValue(props.resource.docker?.nodeName || props.resource.docker?.nodeId)} + + + {textValue(props.resource.docker?.nodeRole)} + + + {textValue(props.resource.docker?.availability)} + + + + + + {byteValue(props.resource.docker?.memoryBytes)} + + ); }; diff --git a/frontend-modern/src/features/docker/DockerPageSurface.tsx b/frontend-modern/src/features/docker/DockerPageSurface.tsx index 94ddfb5d8..8bc55a3e6 100644 --- a/frontend-modern/src/features/docker/DockerPageSurface.tsx +++ b/frontend-modern/src/features/docker/DockerPageSurface.tsx @@ -16,6 +16,7 @@ import { APP_CONTAINER_COLUMN_LABEL_OVERRIDES } from '@/features/platformPage/ap import { DockerHostsTable } from './DockerHostsTable'; import { DockerInventoryTable } from './DockerInventoryTable'; import { DockerServicesTable } from './DockerServicesTable'; +import { DockerStorageUsageTable } from './DockerStorageUsageTable'; import { DOCKER_TAB_SPECS, buildDockerPageModel, @@ -27,7 +28,7 @@ import { } from './dockerPageModel'; const DOCKER_RESOURCE_QUERY = - 'type=agent,docker-host,app-container,docker-service,docker-image,docker-volume,docker-network,docker-task'; + 'type=agent,docker-host,app-container,docker-service,docker-image,docker-volume,docker-network,docker-task,docker-swarm-node'; const DOCKER_PLATFORM_FILTER = 'docker'; const DOCKER_WORKLOAD_FORCED_VIEW_MODE = 'app-container'; const DOCKER_WORKLOAD_DEFAULT_SORT_KEY = 'name'; @@ -180,6 +181,24 @@ export function DockerPageSurface() { emptyDescription="Networks appear here when the container runtime reports network inventory." /> + + + + + + + Boolean( + bucket && + ((bucket.totalCount ?? 0) > 0 || + (bucket.activeCount ?? 0) > 0 || + (bucket.totalSizeBytes ?? 0) > 0 || + (bucket.reclaimableBytes ?? 0) > 0), + ); + +const hasEngineStorageUsage = (host: Resource): boolean => + hasStorageBucket(host.docker?.imagesUsage) || + hasStorageBucket(host.docker?.containersUsage) || + hasStorageBucket(host.docker?.volumesUsage) || + hasStorageBucket(host.docker?.buildCacheUsage); + +const bucketValue = (bucket?: DockerStorageUsageMeta): JSX.Element => { + if (!hasStorageBucket(bucket)) return ; + const totalSize = bucket?.totalSizeBytes ?? 0; + const reclaimable = bucket?.reclaimableBytes ?? 0; + const count = bucket?.totalCount ?? 0; + const active = bucket?.activeCount ?? 0; + return ( + + {formatBytes(totalSize)} + + {count} total, {active} active, {formatBytes(reclaimable)} reclaimable + + + ); +}; + +export const DockerStorageUsageTable: Component<{ + hosts: Resource[]; + sourceCount?: number; + emptyIcon: JSX.Element; + emptyTitle: string; + emptyDescription: string; +}> = (props) => { + const storageHosts = () => props.hosts.filter(hasEngineStorageUsage); + const tableState = createPlatformTableFilterState({ + resources: storageHosts, + initialStatus: 'all' as PlatformResourceStatusFilter, + filter: filterPlatformResources, + }); + const hasFilteredSourceRows = () => (props.sourceCount ?? props.hosts.length) > 0; + + return ( + 0} + fallback={ + + } + > +
+ + + 0} + fallback={ + + } + > + + + + + + + Host + + + Images + + + Containers + + + + + + + + {(host) => { + const name = () => asTrimmedString(host.name) || host.id; + const indicator = () => getSimpleStatusIndicator(host.status); + return ( + + +
+ + + {name()} + +
+
+ + {bucketValue(host.docker?.imagesUsage)} + + + {bucketValue(host.docker?.containersUsage)} + + + +
+ ); + }} +
+
+
+
+
+
+
+ ); +}; + +export default DockerStorageUsageTable; diff --git a/frontend-modern/src/features/docker/__tests__/DockerPageSurface.test.tsx b/frontend-modern/src/features/docker/__tests__/DockerPageSurface.test.tsx index dacc718bf..01e39c981 100644 --- a/frontend-modern/src/features/docker/__tests__/DockerPageSurface.test.tsx +++ b/frontend-modern/src/features/docker/__tests__/DockerPageSurface.test.tsx @@ -13,6 +13,10 @@ vi.mock('@/hooks/useUnifiedResources', () => ({ useUnifiedResources: mocks.useUnifiedResources, })); +vi.mock('@solidjs/router', () => ({ + useLocation: () => ({ pathname: '/docker/overview' }), +})); + vi.mock('@/components/Workloads/useWorkloadsState', () => ({ useWorkloadsState: mocks.useWorkloadsState, })); @@ -125,6 +129,11 @@ describe('DockerPageSurface', () => { it('keeps host drawer ownership on the Docker hosts table instead of workload groups', () => { render(() => ); + expect(mocks.useUnifiedResources).toHaveBeenCalledWith( + expect.objectContaining({ + query: expect.stringContaining('docker-swarm-node'), + }), + ); expect(mocks.useWorkloadsState).toHaveBeenCalledWith( expect.objectContaining({ compactGroupHeaders: true, diff --git a/frontend-modern/src/features/docker/__tests__/dockerPageModel.test.ts b/frontend-modern/src/features/docker/__tests__/dockerPageModel.test.ts index ba8d8cc27..106c64aa1 100644 --- a/frontend-modern/src/features/docker/__tests__/dockerPageModel.test.ts +++ b/frontend-modern/src/features/docker/__tests__/dockerPageModel.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'; import type { Resource } from '@/types/resource'; import { + DOCKER_TAB_SPECS, buildDockerContainerDefaultHiddenColumnIds, buildDockerPageModel, buildDockerWorkloadGroupLabelBadges, @@ -62,7 +63,7 @@ const makeDockerService = (overrides: Partial = {}): Resource => ({ }); describe('dockerPageModel', () => { - it('buckets Docker hosts, containers, images, volumes, networks, tasks, and Swarm services from canonical resources', () => { + it('buckets Docker hosts, containers, images, volumes, networks, nodes, tasks, and Swarm services from canonical resources', () => { const model = buildDockerPageModel([ makeResource({ id: 'docker-host-1', type: 'agent' }), makeResource({ @@ -74,6 +75,7 @@ describe('dockerPageModel', () => { makeResource({ id: 'image-1', type: 'docker-image' }), makeResource({ id: 'volume-1', type: 'docker-volume' }), makeResource({ id: 'network-1', type: 'docker-network' }), + makeResource({ id: 'node-1', type: 'docker-swarm-node' }), makeResource({ id: 'task-1', type: 'docker-task' }), makeResource({ id: 'pve-node-1', @@ -88,12 +90,36 @@ describe('dockerPageModel', () => { expect(model.images.map((resource) => resource.id)).toEqual(['image-1']); expect(model.volumes.map((resource) => resource.id)).toEqual(['volume-1']); expect(model.networks.map((resource) => resource.id)).toEqual(['network-1']); + expect(model.nodes.map((resource) => resource.id)).toEqual(['node-1']); expect(model.tasks.map((resource) => resource.id)).toEqual(['task-1']); expect(model.resources.map((resource) => resource.id).sort()).toEqual( - ['ctr-1', 'docker-host-1', 'image-1', 'network-1', 'svc-1', 'task-1', 'volume-1'].sort(), + [ + 'ctr-1', + 'docker-host-1', + 'image-1', + 'network-1', + 'node-1', + 'svc-1', + 'task-1', + 'volume-1', + ].sort(), ); }); + it('declares bespoke Docker storage and Swarm node tabs', () => { + expect(DOCKER_TAB_SPECS.map((tab) => tab.id)).toEqual([ + 'overview', + 'containers', + 'images', + 'volumes', + 'networks', + 'storage', + 'swarm-nodes', + 'services', + 'tasks', + ]); + }); + it('excludes non-Docker hosts that share the agent type', () => { const model = buildDockerPageModel([ makeResource({ diff --git a/frontend-modern/src/features/docker/dockerPageModel.ts b/frontend-modern/src/features/docker/dockerPageModel.ts index 93ac57763..f7852e1f5 100644 --- a/frontend-modern/src/features/docker/dockerPageModel.ts +++ b/frontend-modern/src/features/docker/dockerPageModel.ts @@ -15,6 +15,7 @@ const DOCKER_IMAGE_TYPES = new Set(['docker-image']); const DOCKER_VOLUME_TYPES = new Set(['docker-volume']); const DOCKER_NETWORK_TYPES = new Set(['docker-network']); const DOCKER_TASK_TYPES = new Set(['docker-task']); +const DOCKER_SWARM_NODE_TYPES = new Set(['docker-swarm-node']); export type DockerPageTabId = | 'overview' @@ -22,6 +23,8 @@ export type DockerPageTabId = | 'images' | 'volumes' | 'networks' + | 'storage' + | 'swarm-nodes' | 'services' | 'tasks'; @@ -35,6 +38,8 @@ export const DOCKER_TAB_SPECS: readonly { { id: 'images', label: 'Images', path: '/docker/images' }, { id: 'volumes', label: 'Volumes', path: '/docker/volumes' }, { id: 'networks', label: 'Networks', path: '/docker/networks' }, + { id: 'storage', label: 'Storage', path: '/docker/storage' }, + { id: 'swarm-nodes', label: 'Swarm Nodes', path: '/docker/swarm-nodes' }, { id: 'services', label: 'Services', path: '/docker/services' }, { id: 'tasks', label: 'Tasks', path: '/docker/tasks' }, ] as const; @@ -76,6 +81,7 @@ export type DockerPageModel = { images: Resource[]; volumes: Resource[]; networks: Resource[]; + nodes: Resource[]; tasks: Resource[]; }; @@ -147,6 +153,7 @@ export function buildDockerPageModel(resources: Resource[]): DockerPageModel { DOCKER_IMAGE_TYPES.has(resource.type) || DOCKER_VOLUME_TYPES.has(resource.type) || DOCKER_NETWORK_TYPES.has(resource.type) || + DOCKER_SWARM_NODE_TYPES.has(resource.type) || DOCKER_TASK_TYPES.has(resource.type), ); @@ -160,6 +167,7 @@ export function buildDockerPageModel(resources: Resource[]): DockerPageModel { const images = dockerResources.filter((resource) => DOCKER_IMAGE_TYPES.has(resource.type)); const volumes = dockerResources.filter((resource) => DOCKER_VOLUME_TYPES.has(resource.type)); const networks = dockerResources.filter((resource) => DOCKER_NETWORK_TYPES.has(resource.type)); + const nodes = dockerResources.filter((resource) => DOCKER_SWARM_NODE_TYPES.has(resource.type)); const tasks = dockerResources.filter((resource) => DOCKER_TASK_TYPES.has(resource.type)); return { @@ -170,6 +178,7 @@ export function buildDockerPageModel(resources: Resource[]): DockerPageModel { images, volumes, networks, + nodes, tasks, }; } @@ -197,6 +206,13 @@ const resourceSearchCandidates = (resource: Resource): Array resource.docker?.scope, resource.docker?.taskId, resource.docker?.serviceName, + resource.docker?.nodeId, + resource.docker?.nodeName, + resource.docker?.nodeRole, + resource.docker?.availability, + resource.docker?.managerReachability, + resource.docker?.engineVersion, + resource.docker?.address, resource.docker?.currentState, ...(resource.docker?.repoTags ?? []), ...(resource.docker?.repoDigests ?? []), diff --git a/frontend-modern/src/hooks/useUnifiedResources.ts b/frontend-modern/src/hooks/useUnifiedResources.ts index 763cbf9af..2653162b7 100644 --- a/frontend-modern/src/hooks/useUnifiedResources.ts +++ b/frontend-modern/src/hooks/useUnifiedResources.ts @@ -525,6 +525,7 @@ const resolveType = (value?: string): ResourceType => { case 'docker-volume': case 'docker-network': case 'docker-task': + case 'docker-swarm-node': case 'k8s-cluster': case 'k8s-node': case 'k8s-deployment': @@ -575,6 +576,8 @@ const resolveType = (value?: string): ResourceType => { return 'docker-network'; case 'docker-task': return 'docker-task'; + case 'docker-swarm-node': + return 'docker-swarm-node'; case 'k8s-deployment': return 'k8s-deployment'; case 'k8s-replicaset': diff --git a/frontend-modern/src/types/__tests__/resource.test.ts b/frontend-modern/src/types/__tests__/resource.test.ts index 52e0b273c..373aa71d8 100644 --- a/frontend-modern/src/types/__tests__/resource.test.ts +++ b/frontend-modern/src/types/__tests__/resource.test.ts @@ -66,6 +66,7 @@ describe('Resource Type Guards', () => { const infrastructureTypes: ResourceType[] = [ 'agent', 'docker-host', + 'docker-swarm-node', 'k8s-node', 'k8s-cluster', 'network-endpoint', @@ -102,6 +103,7 @@ describe('Resource Type Guards', () => { const nonWorkloadTypes: ResourceType[] = [ 'agent', 'docker-host', + 'docker-swarm-node', 'network-endpoint', 'network', 'storage', @@ -558,6 +560,7 @@ describe('Resource Interface', () => { 'docker-volume', 'docker-network', 'docker-task', + 'docker-swarm-node', 'k8s-deployment', 'k8s-replicaset', 'k8s-service', diff --git a/frontend-modern/src/types/resource.ts b/frontend-modern/src/types/resource.ts index 47166fff7..41a1dc4de 100644 --- a/frontend-modern/src/types/resource.ts +++ b/frontend-modern/src/types/resource.ts @@ -42,6 +42,7 @@ export type ResourceType = | 'docker-volume' // Docker/Podman volume | 'docker-network' // Docker/Podman network | 'docker-task' // Docker Swarm task + | 'docker-swarm-node' // Docker Swarm node | 'k8s-deployment' // Kubernetes deployment | 'k8s-replicaset' // Kubernetes replica set | 'k8s-service' // Kubernetes service @@ -584,6 +585,7 @@ export interface ResourceDockerMeta { imageCount?: number; volumeCount?: number; networkCount?: number; + nodeCount?: number; updatesAvailableCount?: number; updatesLastCheckedAt?: string; imagesUsage?: DockerStorageUsageMeta; @@ -649,6 +651,16 @@ export interface ResourceDockerMeta { taskId?: string; nodeId?: string; nodeName?: string; + nodeRole?: string; + availability?: string; + address?: string; + managerReachability?: string; + managerAddress?: string; + leader?: boolean; + engineVersion?: string; + nanoCpus?: number; + memoryBytes?: number; + engineLabels?: Record; slot?: number; desiredState?: string; currentState?: string; @@ -1253,7 +1265,14 @@ export interface Resource { * Helper type guards */ export function isInfrastructure(r: Resource): boolean { - return ['agent', 'docker-host', 'k8s-cluster', 'k8s-node', 'network-endpoint'].includes(r.type); + return [ + 'agent', + 'docker-host', + 'docker-swarm-node', + 'k8s-cluster', + 'k8s-node', + 'network-endpoint', + ].includes(r.type); } export function isWorkload(r: Resource): boolean { diff --git a/frontend-modern/src/utils/__tests__/canonicalResourceTypes.test.ts b/frontend-modern/src/utils/__tests__/canonicalResourceTypes.test.ts index 26c5b5a76..24bb0a337 100644 --- a/frontend-modern/src/utils/__tests__/canonicalResourceTypes.test.ts +++ b/frontend-modern/src/utils/__tests__/canonicalResourceTypes.test.ts @@ -16,6 +16,7 @@ describe('canonicalResourceTypes', () => { 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('docker-swarm-node'); expect(CANONICAL_RESOURCE_TYPES).toContain('k8s-namespace'); expect(CANONICAL_RESOURCE_TYPES).toContain('k8s-service'); expect(CANONICAL_RESOURCE_TYPES).toContain('k8s-replicaset'); @@ -44,6 +45,7 @@ describe('canonicalResourceTypes', () => { expect(isCanonicalResourceType('physical_disk')).toBe(true); expect(isCanonicalResourceType('network-endpoint')).toBe(true); expect(isCanonicalResourceType('docker-image')).toBe(true); + expect(isCanonicalResourceType('docker-swarm-node')).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 fff895caf..ebea2d084 100644 --- a/frontend-modern/src/utils/__tests__/reportableResourceTypes.test.ts +++ b/frontend-modern/src/utils/__tests__/reportableResourceTypes.test.ts @@ -23,6 +23,7 @@ describe('reportableResourceTypes', () => { 'docker-volume', 'docker-network', 'docker-task', + 'docker-swarm-node', 'k8s-namespace', 'k8s-service', 'k8s-replicaset', diff --git a/frontend-modern/src/utils/__tests__/reportingResourceTypes.test.ts b/frontend-modern/src/utils/__tests__/reportingResourceTypes.test.ts index eefb010ee..3f12160d4 100644 --- a/frontend-modern/src/utils/__tests__/reportingResourceTypes.test.ts +++ b/frontend-modern/src/utils/__tests__/reportingResourceTypes.test.ts @@ -13,6 +13,7 @@ describe('toReportingResourceType', () => { expect(toReportingResourceType('docker-volume')).toBe('storage'); expect(toReportingResourceType('docker-network')).toBe('network'); expect(toReportingResourceType('docker-task')).toBe('app-container'); + expect(toReportingResourceType('docker-swarm-node')).toBe('app-container'); expect(toReportingResourceType('network-endpoint')).toBe('network-endpoint'); expect(toReportingResourceType('network-share')).toBe('network-share'); expect(toReportingResourceType('storage')).toBe('storage'); diff --git a/frontend-modern/src/utils/__tests__/resourceTypeCompat.test.ts b/frontend-modern/src/utils/__tests__/resourceTypeCompat.test.ts index 1df8320c5..accbed3f5 100644 --- a/frontend-modern/src/utils/__tests__/resourceTypeCompat.test.ts +++ b/frontend-modern/src/utils/__tests__/resourceTypeCompat.test.ts @@ -20,6 +20,9 @@ describe('resourceTypeCompat', () => { expect(canonicalizeFrontendResourceType('docker-volume')).toBe('docker-volume'); expect(canonicalizeFrontendResourceType('docker-network')).toBe('docker-network'); expect(canonicalizeFrontendResourceType('docker-task')).toBe('docker-task'); + expect(canonicalizeFrontendResourceType('docker-swarm-node')).toBe('docker-swarm-node'); + expect(canonicalizeFrontendResourceType('docker-node')).toBe('docker-swarm-node'); + expect(canonicalizeFrontendResourceType('swarm_node')).toBe('docker-swarm-node'); expect(canonicalizeFrontendResourceType('k8s-namespace')).toBe('k8s-namespace'); expect(canonicalizeFrontendResourceType('k8s-service')).toBe('k8s-service'); expect(canonicalizeFrontendResourceType('k8s-replicaset')).toBe('k8s-replicaset'); diff --git a/frontend-modern/src/utils/__tests__/resourceTypePresentation.test.ts b/frontend-modern/src/utils/__tests__/resourceTypePresentation.test.ts index 8332b6d19..bc98c11e0 100644 --- a/frontend-modern/src/utils/__tests__/resourceTypePresentation.test.ts +++ b/frontend-modern/src/utils/__tests__/resourceTypePresentation.test.ts @@ -15,6 +15,7 @@ describe('resourceTypePresentation', () => { expect(getResourceTypeLabel('docker-volume')).toBe('Volume'); expect(getResourceTypeLabel('docker-network')).toBe('Network'); expect(getResourceTypeLabel('docker-task')).toBe('Swarm Task'); + expect(getResourceTypeLabel('docker-swarm-node')).toBe('Swarm Node'); expect(getResourceTypeLabel('k8s-service')).toBe('K8s Service'); expect(getResourceTypeLabel('k8s-replicaset')).toBe('ReplicaSet'); expect(getResourceTypeLabel('k8s-ingress')).toBe('Ingress'); diff --git a/frontend-modern/src/utils/canonicalResourceTypes.ts b/frontend-modern/src/utils/canonicalResourceTypes.ts index 9521878ea..effb78133 100644 --- a/frontend-modern/src/utils/canonicalResourceTypes.ts +++ b/frontend-modern/src/utils/canonicalResourceTypes.ts @@ -16,6 +16,7 @@ export const CANONICAL_RESOURCE_TYPES = [ 'docker-volume', 'docker-network', 'docker-task', + 'docker-swarm-node', 'k8s-deployment', 'k8s-replicaset', 'k8s-service', diff --git a/frontend-modern/src/utils/platformSupportManifest.generated.ts b/frontend-modern/src/utils/platformSupportManifest.generated.ts index 1738fa89c..af6e1a98d 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: 165f38fd8ec06299a966bba865ec28a29b3e9d4d99790e2e65da73da9ca8f45a +// Source SHA256: af152a63e78b121f52eb33fcdb7fb945ca5054435a4e4fb150973715b5489368 export const PLATFORM_SUPPORT_MANIFEST_SOURCE = { path: 'docs/release-control/v6/internal/PLATFORM_SUPPORT_MANIFEST.json', - sha256: '165f38fd8ec06299a966bba865ec28a29b3e9d4d99790e2e65da73da9ca8f45a', + sha256: 'af152a63e78b121f52eb33fcdb7fb945ca5054435a4e4fb150973715b5489368', } as const; export const PLATFORM_SUPPORT_MANIFEST = { schemaVersion: 2, @@ -82,6 +82,7 @@ export const PLATFORM_SUPPORT_MANIFEST = { 'docker-network', 'docker-service', 'docker-task', + 'docker-swarm-node', ], supportFloor: { setup: 'supported', @@ -662,6 +663,7 @@ export const SOURCE_PLATFORM_CANONICAL_PROJECTIONS = { 'docker-network', 'docker-service', 'docker-task', + 'docker-swarm-node', ], kubernetes: [ 'k8s-cluster', diff --git a/frontend-modern/src/utils/reportingResourceTypes.ts b/frontend-modern/src/utils/reportingResourceTypes.ts index d5004bf68..bdcfc072c 100644 --- a/frontend-modern/src/utils/reportingResourceTypes.ts +++ b/frontend-modern/src/utils/reportingResourceTypes.ts @@ -27,6 +27,7 @@ export function toReportingResourceType(resourceType: ResourceType): ReportingRe case 'docker-service': case 'docker-image': case 'docker-task': + case 'docker-swarm-node': return 'app-container'; case 'docker-volume': return 'storage'; diff --git a/frontend-modern/src/utils/resourceTypeCompat.ts b/frontend-modern/src/utils/resourceTypeCompat.ts index b1f28efba..bc79a24f8 100644 --- a/frontend-modern/src/utils/resourceTypeCompat.ts +++ b/frontend-modern/src/utils/resourceTypeCompat.ts @@ -13,6 +13,7 @@ export type CanonicalFrontendResourceType = | 'docker-volume' | 'docker-network' | 'docker-task' + | 'docker-swarm-node' | 'network' | 'network-share' | 'pbs' @@ -64,6 +65,15 @@ export const canonicalizeFrontendResourceType = ( case 'dockerhost': case 'docker_host': return 'docker-host'; + case 'docker-node': + case 'docker_node': + case 'docker-nodes': + case 'docker_nodes': + case 'swarm-node': + case 'swarm_node': + case 'swarm-nodes': + case 'swarm_nodes': + return 'docker-swarm-node'; case 'k8s': case 'kubernetes': case 'k8s-pod': @@ -91,6 +101,7 @@ export const canonicalizeFrontendResourceType = ( case 'docker-volume': case 'docker-network': case 'docker-task': + case 'docker-swarm-node': case 'network': case 'network-share': case 'pbs': diff --git a/frontend-modern/src/utils/resourceTypePresentation.ts b/frontend-modern/src/utils/resourceTypePresentation.ts index 5ebc80885..9a71cba9e 100644 --- a/frontend-modern/src/utils/resourceTypePresentation.ts +++ b/frontend-modern/src/utils/resourceTypePresentation.ts @@ -70,6 +70,10 @@ const RESOURCE_TYPE_PRESENTATION: Partial 0 { report.Tasks = tasks } + if len(nodes) > 0 { + report.Nodes = nodes + } if storageUsage != nil { report.StorageUsage = storageUsage } diff --git a/internal/dockeragent/docker_client.go b/internal/dockeragent/docker_client.go index 7104a13be..fff85deca 100644 --- a/internal/dockeragent/docker_client.go +++ b/internal/dockeragent/docker_client.go @@ -118,6 +118,10 @@ type dockerTaskListOptions struct { Filters dockerFilters } +type dockerNodeListOptions struct { + Filters dockerFilters +} + type dockerClient interface { Info(ctx context.Context) (systemtypes.Info, error) DaemonHost() string @@ -138,6 +142,7 @@ type dockerClient interface { 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) + NodeList(ctx context.Context, options dockerNodeListOptions) ([]swarmtypes.Node, error) ImageInspectWithRaw(ctx context.Context, imageID string) (image.InspectResponse, []byte, error) Close() error } @@ -310,6 +315,14 @@ func (m *mobyDockerClient) TaskList(ctx context.Context, options dockerTaskListO return result.Items, nil } +func (m *mobyDockerClient) NodeList(ctx context.Context, options dockerNodeListOptions) ([]swarmtypes.Node, error) { + result, err := m.Client.NodeList(ctx, client.NodeListOptions{Filters: options.Filters.toClientFilters()}) + if err != nil { + return nil, err + } + return result.Items, nil +} + func (m *mobyDockerClient) ImageInspectWithRaw(ctx context.Context, imageID string) (image.InspectResponse, []byte, error) { result, err := m.Client.ImageInspect(ctx, imageID) if err != nil { diff --git a/internal/dockeragent/swarm.go b/internal/dockeragent/swarm.go index 7cfbabf36..864c04746 100644 --- a/internal/dockeragent/swarm.go +++ b/internal/dockeragent/swarm.go @@ -66,13 +66,13 @@ func hasReportableSwarmInfo(info systemtypes.Info) bool { strings.TrimSpace(swarm.Cluster.Spec.Annotations.Name) != "")) } -func (a *Agent) collectSwarmData(ctx context.Context, info systemtypes.Info, containers []agentsdocker.Container) ([]agentsdocker.Service, []agentsdocker.Task, *agentsdocker.SwarmInfo) { +func (a *Agent) collectSwarmData(ctx context.Context, info systemtypes.Info, containers []agentsdocker.Container) ([]agentsdocker.Service, []agentsdocker.Task, []agentsdocker.Node, *agentsdocker.SwarmInfo) { if !a.supportsSwarm { - return nil, nil, nil + return nil, nil, nil, nil } if !hasReportableSwarmInfo(info) { - return nil, nil, nil + return nil, nil, nil, nil } scope := a.resolvedSwarmScope(info) @@ -101,11 +101,24 @@ func (a *Agent) collectSwarmData(ctx context.Context, info systemtypes.Info, con includeTasks := a.cfg.IncludeTasks if info.Swarm.LocalNodeState != swarmtypes.LocalNodeStateActive { - return nil, nil, swarmInfo + return nil, nil, nil, swarmInfo } var services []agentsdocker.Service var tasks []agentsdocker.Task + var nodes []agentsdocker.Node + + if info.Swarm.ControlAvailable { + managerNodes, err := a.collectSwarmNodes(ctx) + if err != nil { + a.logger.Warn().Err(err).Msg("failed to collect swarm nodes from manager") + } else { + nodes = managerNodes + } + } + if len(nodes) == 0 { + nodes = deriveLocalSwarmNode(info, firstNonEmptyString(a.hostName, info.Name)) + } containerIndex := buildContainerIndex(containers) @@ -158,6 +171,15 @@ func (a *Agent) collectSwarmData(ctx context.Context, info systemtypes.Info, con }) } + if len(nodes) > 0 { + sort.Slice(nodes, func(i, j int) bool { + if nodes[i].Hostname == nodes[j].Hostname { + return nodes[i].ID < nodes[j].ID + } + return nodes[i].Hostname < nodes[j].Hostname + }) + } + swarmInfo.Scope = effectiveScope if !includeServices { @@ -167,7 +189,30 @@ func (a *Agent) collectSwarmData(ctx context.Context, info systemtypes.Info, con tasks = nil } - return services, tasks, swarmInfo + return services, tasks, nodes, swarmInfo +} + +func (a *Agent) collectSwarmNodes(ctx context.Context) ([]agentsdocker.Node, error) { + if a == nil || a.docker == nil { + return nil, nil + } + + nodeList, err := dockerCallWithRetry(ctx, dockerSwarmListCallTimeout, func(callCtx context.Context) ([]swarmtypes.Node, error) { + return a.docker.NodeList(callCtx, dockerNodeListOptions{}) + }) + if err != nil { + return nil, annotateDockerConnectionError(err) + } + + nodes := make([]agentsdocker.Node, 0, len(nodeList)) + for i := range nodeList { + node := mapSwarmNode(&nodeList[i]) + if strings.TrimSpace(node.ID) == "" && strings.TrimSpace(node.Hostname) == "" { + continue + } + nodes = append(nodes, node) + } + return nodes, nil } func (a *Agent) collectSwarmDataFromManager(ctx context.Context, info systemtypes.Info, scope string, containers map[string]agentsdocker.Container, includeServices, includeTasks bool) ([]agentsdocker.Service, []agentsdocker.Task, error) { @@ -382,6 +427,65 @@ func mapSwarmTask(task *swarmtypes.Task, svc *swarmtypes.Service, containers map return result } +func mapSwarmNode(node *swarmtypes.Node) agentsdocker.Node { + result := agentsdocker.Node{ + ID: strings.TrimSpace(node.ID), + Hostname: strings.TrimSpace(node.Description.Hostname), + Role: strings.TrimSpace(string(node.Spec.Role)), + Availability: strings.TrimSpace(string(node.Spec.Availability)), + State: strings.TrimSpace(string(node.Status.State)), + Message: strings.TrimSpace(node.Status.Message), + Address: strings.TrimSpace(node.Status.Addr), + EngineVersion: strings.TrimSpace(node.Description.Engine.EngineVersion), + OS: strings.TrimSpace(node.Description.Platform.OS), + Architecture: strings.TrimSpace(node.Description.Platform.Architecture), + NanoCPUs: node.Description.Resources.NanoCPUs, + MemoryBytes: node.Description.Resources.MemoryBytes, + Labels: copyStringMap(node.Spec.Annotations.Labels), + EngineLabels: copyStringMap(node.Description.Engine.Labels), + CreatedAt: node.Meta.CreatedAt, + } + + if !node.Meta.UpdatedAt.IsZero() { + updated := node.Meta.UpdatedAt + result.UpdatedAt = &updated + } + + if node.ManagerStatus != nil { + result.ManagerReachability = strings.TrimSpace(string(node.ManagerStatus.Reachability)) + result.ManagerAddress = strings.TrimSpace(node.ManagerStatus.Addr) + result.Leader = node.ManagerStatus.Leader + } + + return result +} + +func deriveLocalSwarmNode(info systemtypes.Info, hostname string) []agentsdocker.Node { + nodeID := strings.TrimSpace(info.Swarm.NodeID) + if nodeID == "" { + return nil + } + + role := "worker" + if info.Swarm.ControlAvailable { + role = "manager" + } + + node := agentsdocker.Node{ + ID: nodeID, + Hostname: strings.TrimSpace(hostname), + Role: role, + State: string(info.Swarm.LocalNodeState), + Message: strings.TrimSpace(info.Swarm.Error), + EngineVersion: strings.TrimSpace(info.ServerVersion), + OS: strings.TrimSpace(info.OSType), + Architecture: strings.TrimSpace(info.Architecture), + NanoCPUs: int64(info.NCPU) * 1_000_000_000, + MemoryBytes: info.MemTotal, + } + return []agentsdocker.Node{node} +} + func serviceMode(mode swarmtypes.ServiceMode) string { switch { case mode.Global != nil: @@ -604,6 +708,15 @@ func copyStringMap(source map[string]string) map[string]string { return result } +func firstNonEmptyString(values ...string) string { + for _, value := range values { + if trimmed := strings.TrimSpace(value); trimmed != "" { + return trimmed + } + } + return "" +} + func isTaskCompletedState(state string) bool { switch strings.ToLower(state) { case "completed", "complete", "shutdown", "failed", "rejected": diff --git a/internal/dockeragent/swarm_coverage_test.go b/internal/dockeragent/swarm_coverage_test.go index dcb1863dc..8d17671f2 100644 --- a/internal/dockeragent/swarm_coverage_test.go +++ b/internal/dockeragent/swarm_coverage_test.go @@ -278,19 +278,83 @@ func TestCollectSwarmDataFromManager(t *testing.T) { }) } +func TestMapSwarmNode(t *testing.T) { + createdAt := time.Date(2024, 3, 4, 5, 6, 7, 0, time.UTC) + updatedAt := createdAt.Add(5 * time.Minute) + node := &swarmtypes.Node{ + ID: "node-1", + Spec: swarmtypes.NodeSpec{ + Annotations: swarmtypes.Annotations{ + Labels: map[string]string{"zone": "rack-a"}, + }, + Role: swarmtypes.NodeRoleManager, + Availability: swarmtypes.NodeAvailabilityActive, + }, + Description: swarmtypes.NodeDescription{ + Hostname: "manager-1", + Platform: swarmtypes.Platform{ + OS: "linux", + Architecture: "amd64", + }, + Resources: swarmtypes.Resources{ + NanoCPUs: 4_000_000_000, + MemoryBytes: 16 * 1024 * 1024 * 1024, + }, + Engine: swarmtypes.EngineDescription{ + EngineVersion: "27.5.1", + Labels: map[string]string{"engine": "primary"}, + }, + }, + Status: swarmtypes.NodeStatus{ + State: swarmtypes.NodeStateReady, + Message: "ready", + Addr: "192.0.2.10", + }, + ManagerStatus: &swarmtypes.ManagerStatus{ + Leader: true, + Reachability: swarmtypes.ReachabilityReachable, + Addr: "192.0.2.10:2377", + }, + Meta: swarmtypes.Meta{ + CreatedAt: createdAt, + UpdatedAt: updatedAt, + }, + } + + got := mapSwarmNode(node) + if got.ID != "node-1" || got.Hostname != "manager-1" || got.Role != string(swarmtypes.NodeRoleManager) { + t.Fatalf("unexpected node identity: %+v", got) + } + if got.Availability != string(swarmtypes.NodeAvailabilityActive) || got.State != string(swarmtypes.NodeStateReady) { + t.Fatalf("unexpected node state: %+v", got) + } + if got.ManagerReachability != string(swarmtypes.ReachabilityReachable) || !got.Leader { + t.Fatalf("expected manager reachability and leader flag, got %+v", got) + } + if got.EngineVersion != "27.5.1" || got.NanoCPUs != 4_000_000_000 || got.MemoryBytes == 0 { + t.Fatalf("expected engine resources, got %+v", got) + } + if got.Labels["zone"] != "rack-a" || got.EngineLabels["engine"] != "primary" { + t.Fatalf("expected node labels to be mapped, got labels=%+v engine=%+v", got.Labels, got.EngineLabels) + } + if got.CreatedAt.IsZero() || got.UpdatedAt == nil || got.UpdatedAt.IsZero() { + t.Fatalf("expected node timestamps, got %+v", got) + } +} + func TestCollectSwarmData(t *testing.T) { t.Run("unsupported swarm returns nils", func(t *testing.T) { agent := &Agent{supportsSwarm: false} - services, tasks, info := agent.collectSwarmData(context.Background(), systemtypes.Info{}, nil) - if services != nil || tasks != nil || info != nil { + services, tasks, nodes, info := agent.collectSwarmData(context.Background(), systemtypes.Info{}, nil) + if services != nil || tasks != nil || nodes != nil || info != nil { t.Fatal("expected nil outputs when swarm unsupported") } }) t.Run("empty swarm info returns nil", func(t *testing.T) { agent := &Agent{supportsSwarm: true} - services, tasks, info := agent.collectSwarmData(context.Background(), systemtypes.Info{}, nil) - if services != nil || tasks != nil || info != nil { + services, tasks, nodes, info := agent.collectSwarmData(context.Background(), systemtypes.Info{}, nil) + if services != nil || tasks != nil || nodes != nil || info != nil { t.Fatal("expected nil outputs for empty swarm info") } }) @@ -303,8 +367,8 @@ func TestCollectSwarmData(t *testing.T) { }, } - services, tasks, swarmInfo := agent.collectSwarmData(context.Background(), info, nil) - if services != nil || tasks != nil || swarmInfo != nil { + services, tasks, nodes, swarmInfo := agent.collectSwarmData(context.Background(), info, nil) + if services != nil || tasks != nil || nodes != nil || swarmInfo != nil { t.Fatal("expected standalone inactive swarm state to be ignored") } }) @@ -318,8 +382,8 @@ func TestCollectSwarmData(t *testing.T) { }, } - services, tasks, swarmInfo := agent.collectSwarmData(context.Background(), info, nil) - if services != nil || tasks != nil { + services, tasks, nodes, swarmInfo := agent.collectSwarmData(context.Background(), info, nil) + if services != nil || tasks != nil || nodes != nil { t.Fatal("expected nil services/tasks for pending swarm") } if swarmInfo == nil || swarmInfo.NodeID != "node1" { @@ -371,10 +435,13 @@ func TestCollectSwarmData(t *testing.T) { }, } - services, tasks, swarmInfo := agent.collectSwarmData(context.Background(), info, containers) + services, tasks, nodes, swarmInfo := agent.collectSwarmData(context.Background(), info, containers) if len(tasks) != 1 || len(services) != 1 { t.Fatalf("expected derived tasks/services, got %d/%d", len(tasks), len(services)) } + if len(nodes) != 1 || nodes[0].ID != "node1" { + t.Fatalf("expected derived local node, got %+v", nodes) + } if swarmInfo == nil || swarmInfo.Scope != swarmScopeNode { t.Fatalf("expected effective scope node, got %+v", swarmInfo) } @@ -389,6 +456,26 @@ func TestCollectSwarmData(t *testing.T) { SwarmScope: swarmScopeCluster, }, docker: &fakeDockerClient{ + nodeListFn: func(context.Context, dockerNodeListOptions) ([]swarmtypes.Node, error) { + return []swarmtypes.Node{{ + ID: "node-manager", + Spec: swarmtypes.NodeSpec{ + Role: swarmtypes.NodeRoleManager, + Availability: swarmtypes.NodeAvailabilityActive, + }, + Description: swarmtypes.NodeDescription{ + Hostname: "manager-1", + Engine: swarmtypes.EngineDescription{ + EngineVersion: "27.5.1", + }, + }, + Status: swarmtypes.NodeStatus{State: swarmtypes.NodeStateReady}, + ManagerStatus: &swarmtypes.ManagerStatus{ + Leader: true, + Reachability: swarmtypes.ReachabilityReachable, + }, + }}, nil + }, serviceListFn: func(context.Context, dockerServiceListOptions) ([]swarmtypes.Service, error) { return []swarmtypes.Service{ {ID: "svc1", Spec: swarmtypes.ServiceSpec{Annotations: swarmtypes.Annotations{Name: "zeta"}}}, @@ -412,10 +499,13 @@ func TestCollectSwarmData(t *testing.T) { }, } - services, tasks, swarmInfo := agent.collectSwarmData(context.Background(), info, nil) + services, tasks, nodes, swarmInfo := agent.collectSwarmData(context.Background(), info, nil) if len(tasks) != 2 || len(services) != 2 { t.Fatalf("expected manager tasks/services, got %d/%d", len(tasks), len(services)) } + if len(nodes) != 1 || nodes[0].ID != "node-manager" || nodes[0].Hostname != "manager-1" { + t.Fatalf("expected manager node inventory, got %+v", nodes) + } if swarmInfo == nil || swarmInfo.Scope != swarmScopeCluster { t.Fatalf("unexpected swarm info: %+v", swarmInfo) } @@ -453,13 +543,16 @@ func TestCollectSwarmData(t *testing.T) { }, } - services, tasks, swarmInfo := agent.collectSwarmData(context.Background(), info, containers) + services, tasks, nodes, swarmInfo := agent.collectSwarmData(context.Background(), info, containers) if services != nil { t.Fatal("expected services to be nil when disabled") } if len(tasks) != 1 { t.Fatalf("expected tasks to be returned") } + if len(nodes) != 1 { + t.Fatalf("expected local swarm node to be returned") + } if swarmInfo == nil { t.Fatalf("expected swarm info") } @@ -489,10 +582,13 @@ func TestCollectSwarmData(t *testing.T) { }, } - services, tasks, swarmInfo := agent.collectSwarmData(context.Background(), info, nil) + services, tasks, nodes, swarmInfo := agent.collectSwarmData(context.Background(), info, nil) if services != nil || tasks != nil { t.Fatal("expected nil services/tasks when disabled") } + if len(nodes) != 1 { + t.Fatalf("expected local swarm node to be returned") + } if swarmInfo == nil || swarmInfo.ClusterID != "cluster1" || swarmInfo.ClusterName != "prod" { t.Fatalf("expected cluster info to be populated") } @@ -533,10 +629,13 @@ func TestCollectSwarmData(t *testing.T) { }, } - services, tasks, swarmInfo := agent.collectSwarmData(context.Background(), info, nil) + services, tasks, nodes, swarmInfo := agent.collectSwarmData(context.Background(), info, nil) if swarmInfo == nil { t.Fatalf("expected swarm info") } + if len(nodes) != 1 { + t.Fatalf("expected local swarm node to be returned") + } if len(tasks) != 4 || len(services) != 3 { t.Fatalf("expected tasks and services") } diff --git a/internal/dockeragent/test_helpers_test.go b/internal/dockeragent/test_helpers_test.go index 75457b01f..0e5f8c647 100644 --- a/internal/dockeragent/test_helpers_test.go +++ b/internal/dockeragent/test_helpers_test.go @@ -38,6 +38,7 @@ type fakeDockerClient struct { 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) + nodeListFn func(ctx context.Context, opts dockerNodeListOptions) ([]swarmtypes.Node, error) imageInspectWithRawFn func(ctx context.Context, imageID string) (image.InspectResponse, []byte, error) closeFn func() error } @@ -172,6 +173,13 @@ func (f *fakeDockerClient) TaskList(ctx context.Context, opts dockerTaskListOpti return f.taskListFn(ctx, opts) } +func (f *fakeDockerClient) NodeList(ctx context.Context, opts dockerNodeListOptions) ([]swarmtypes.Node, error) { + if f.nodeListFn == nil { + return nil, nil + } + return f.nodeListFn(ctx, opts) +} + func (f *fakeDockerClient) ImageInspectWithRaw(ctx context.Context, imageID string) (image.InspectResponse, []byte, error) { if f.imageInspectWithRawFn == nil { // Return empty response with no RepoDigests by default (simulates locally built image) diff --git a/internal/models/converters.go b/internal/models/converters.go index 13f540b6c..9dcb1de96 100644 --- a/internal/models/converters.go +++ b/internal/models/converters.go @@ -267,6 +267,13 @@ func (d DockerHost) ToFrontend() DockerHostFrontend { } } + if len(d.Nodes) > 0 { + h.Nodes = make([]DockerNodeFrontend, len(d.Nodes)) + for i, node := range d.Nodes { + h.Nodes[i] = node.ToFrontend() + } + } + if d.Swarm != nil { sw := d.Swarm.ToFrontend() h.Swarm = &sw @@ -723,6 +730,44 @@ func (t DockerTask) ToFrontend() DockerTaskFrontend { return task } +// ToFrontend converts a DockerNode to DockerNodeFrontend. +func (n DockerNode) ToFrontend() DockerNodeFrontend { + node := DockerNodeFrontend{ + ID: n.ID, + Hostname: n.Hostname, + Role: n.Role, + Availability: n.Availability, + State: n.State, + Message: n.Message, + Address: n.Address, + ManagerReachability: n.ManagerReachability, + ManagerAddress: n.ManagerAddress, + Leader: n.Leader, + EngineVersion: n.EngineVersion, + OS: n.OS, + Architecture: n.Architecture, + NanoCPUs: n.NanoCPUs, + MemoryBytes: n.MemoryBytes, + Labels: make(map[string]string, len(n.Labels)), + EngineLabels: make(map[string]string, len(n.EngineLabels)), + } + for key, value := range n.Labels { + node.Labels[key] = value + } + for key, value := range n.EngineLabels { + node.EngineLabels[key] = value + } + if !n.CreatedAt.IsZero() { + ts := n.CreatedAt.Unix() * 1000 + node.CreatedAt = &ts + } + if n.UpdatedAt != nil && !n.UpdatedAt.IsZero() { + ts := n.UpdatedAt.Unix() * 1000 + node.UpdatedAt = &ts + } + return node +} + // ToFrontend converts DockerSwarmInfo to DockerSwarmFrontend. func (s DockerSwarmInfo) ToFrontend() DockerSwarmFrontend { return DockerSwarmFrontend(s) diff --git a/internal/models/converters_test.go b/internal/models/converters_test.go index 45664b510..8fcc2bf56 100644 --- a/internal/models/converters_test.go +++ b/internal/models/converters_test.go @@ -865,6 +865,44 @@ func TestVMToFrontend_TagsJoinedCorrectly(t *testing.T) { } } +func TestDockerNodeToFrontend(t *testing.T) { + now := time.Now() + updatedAt := now.Add(5 * time.Minute) + node := DockerNode{ + ID: "node-1", + Hostname: "manager-1", + Role: "manager", + Availability: "active", + State: "ready", + ManagerReachability: "reachable", + ManagerAddress: "192.0.2.10:2377", + Leader: true, + EngineVersion: "27.5.1", + OS: "linux", + Architecture: "amd64", + NanoCPUs: 4_000_000_000, + MemoryBytes: 16 * 1024 * 1024 * 1024, + Labels: map[string]string{"zone": "rack-a"}, + EngineLabels: map[string]string{"engine": "primary"}, + CreatedAt: now, + UpdatedAt: &updatedAt, + } + + frontend := node.ToFrontend() + if frontend.ID != node.ID || frontend.Hostname != node.Hostname || frontend.Role != node.Role { + t.Fatalf("unexpected frontend node identity: %+v", frontend) + } + if frontend.ManagerReachability != node.ManagerReachability || !frontend.Leader { + t.Fatalf("unexpected frontend manager metadata: %+v", frontend) + } + if frontend.CreatedAt == nil || *frontend.CreatedAt != now.Unix()*1000 { + t.Fatalf("CreatedAt = %v, want %d", frontend.CreatedAt, now.Unix()*1000) + } + if frontend.UpdatedAt == nil || *frontend.UpdatedAt != updatedAt.Unix()*1000 { + t.Fatalf("UpdatedAt = %v, want %d", frontend.UpdatedAt, updatedAt.Unix()*1000) + } +} + func TestDockerHostToFrontend(t *testing.T) { now := time.Now() tokenLastUsed := now.Add(-1 * time.Hour) @@ -901,6 +939,9 @@ func TestDockerHostToFrontend(t *testing.T) { Containers: []DockerContainer{ {ID: "ct-1", Name: "nginx", State: "running"}, }, + Nodes: []DockerNode{ + {ID: "node-1", Hostname: "manager-1", Role: "manager"}, + }, } frontend := host.ToFrontend() @@ -944,6 +985,9 @@ func TestDockerHostToFrontend(t *testing.T) { if len(frontend.Containers) != 1 { t.Errorf("Containers length = %d, want 1", len(frontend.Containers)) } + if len(frontend.Nodes) != 1 { + t.Errorf("Nodes length = %d, want 1", len(frontend.Nodes)) + } } func TestDockerHostToFrontend_EmptyDisplayName(t *testing.T) { diff --git a/internal/models/deepcopy.go b/internal/models/deepcopy.go index 5beff08bc..3e4a30859 100644 --- a/internal/models/deepcopy.go +++ b/internal/models/deepcopy.go @@ -457,6 +457,21 @@ func cloneDockerTasks(src []DockerTask) []DockerTask { return dest } +func cloneDockerNodes(src []DockerNode) []DockerNode { + if len(src) == 0 { + return nil + } + dest := make([]DockerNode, len(src)) + for i, node := range src { + nodeCopy := node + nodeCopy.Labels = cloneStringMap(node.Labels) + nodeCopy.EngineLabels = cloneStringMap(node.EngineLabels) + nodeCopy.UpdatedAt = cloneTimePtr(node.UpdatedAt) + dest[i] = nodeCopy.NormalizeCollections() + } + return dest +} + func cloneDockerSwarmInfo(src *DockerSwarmInfo) *DockerSwarmInfo { if src == nil { return nil @@ -498,6 +513,7 @@ func cloneDockerHost(src DockerHost) DockerHost { dest.Networks = cloneDockerNetworks(src.Networks) dest.Services = cloneDockerServices(src.Services) dest.Tasks = cloneDockerTasks(src.Tasks) + dest.Nodes = cloneDockerNodes(src.Nodes) dest.StorageUsage = cloneDockerStorageUsage(src.StorageUsage) dest.Swarm = cloneDockerSwarmInfo(src.Swarm) dest.Security = cloneDockerHostSecurity(src.Security) diff --git a/internal/models/models.go b/internal/models/models.go index acc29166f..e803180dc 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -658,6 +658,7 @@ type DockerHost struct { Networks []DockerNetwork `json:"networks,omitempty"` Services []DockerService `json:"services,omitempty"` Tasks []DockerTask `json:"tasks,omitempty"` + Nodes []DockerNode `json:"nodes,omitempty"` StorageUsage *DockerStorageUsage `json:"storageUsage,omitempty"` Swarm *DockerSwarmInfo `json:"swarm,omitempty"` Security *DockerHostSecurity `json:"security,omitempty"` @@ -724,6 +725,12 @@ func (h DockerHost) NormalizeCollections() DockerHost { if h.Tasks == nil { h.Tasks = []DockerTask{} } + if h.Nodes == nil { + h.Nodes = []DockerNode{} + } + for i := range h.Nodes { + h.Nodes[i] = h.Nodes[i].NormalizeCollections() + } if h.Security != nil { security := h.Security.NormalizeCollections() h.Security = &security @@ -1658,6 +1665,39 @@ type DockerTask struct { CompletedAt *time.Time `json:"completedAt,omitempty"` } +// DockerNode summarises a Docker Swarm node. +type DockerNode struct { + ID string `json:"id"` + Hostname string `json:"hostname,omitempty"` + Role string `json:"role,omitempty"` + Availability string `json:"availability,omitempty"` + State string `json:"state,omitempty"` + Message string `json:"message,omitempty"` + Address string `json:"address,omitempty"` + ManagerReachability string `json:"managerReachability,omitempty"` + ManagerAddress string `json:"managerAddress,omitempty"` + Leader bool `json:"leader,omitempty"` + EngineVersion string `json:"engineVersion,omitempty"` + OS string `json:"os,omitempty"` + Architecture string `json:"architecture,omitempty"` + NanoCPUs int64 `json:"nanoCpus,omitempty"` + MemoryBytes int64 `json:"memoryBytes,omitempty"` + Labels map[string]string `json:"labels,omitempty"` + EngineLabels map[string]string `json:"engineLabels,omitempty"` + CreatedAt time.Time `json:"createdAt,omitempty"` + UpdatedAt *time.Time `json:"updatedAt,omitempty"` +} + +func (n DockerNode) NormalizeCollections() DockerNode { + if n.Labels == nil { + n.Labels = map[string]string{} + } + if n.EngineLabels == nil { + n.EngineLabels = map[string]string{} + } + return n +} + // DockerSwarmInfo captures node-level swarm metadata. type DockerSwarmInfo struct { NodeID string `json:"nodeId,omitempty"` diff --git a/internal/models/models_frontend.go b/internal/models/models_frontend.go index 12c9ae705..6883274b3 100644 --- a/internal/models/models_frontend.go +++ b/internal/models/models_frontend.go @@ -168,6 +168,7 @@ type DockerHostFrontend struct { Containers []DockerContainerFrontend `json:"containers"` Services []DockerServiceFrontend `json:"services"` Tasks []DockerTaskFrontend `json:"tasks"` + Nodes []DockerNodeFrontend `json:"nodes"` Swarm *DockerSwarmFrontend `json:"swarm,omitempty"` Security *DockerHostSecurityFrontend `json:"security,omitempty"` TokenID string `json:"tokenId,omitempty"` @@ -197,6 +198,9 @@ func (h DockerHostFrontend) NormalizeCollections() DockerHostFrontend { if h.Tasks == nil { h.Tasks = []DockerTaskFrontend{} } + if h.Nodes == nil { + h.Nodes = []DockerNodeFrontend{} + } for i := range h.Containers { h.Containers[i] = h.Containers[i].NormalizeCollections() } @@ -582,6 +586,29 @@ type DockerTaskFrontend struct { CompletedAt *int64 `json:"completedAt,omitempty"` } +// DockerNodeFrontend represents a Swarm node. +type DockerNodeFrontend struct { + ID string `json:"id"` + Hostname string `json:"hostname,omitempty"` + Role string `json:"role,omitempty"` + Availability string `json:"availability,omitempty"` + State string `json:"state,omitempty"` + Message string `json:"message,omitempty"` + Address string `json:"address,omitempty"` + ManagerReachability string `json:"managerReachability,omitempty"` + ManagerAddress string `json:"managerAddress,omitempty"` + Leader bool `json:"leader,omitempty"` + EngineVersion string `json:"engineVersion,omitempty"` + OS string `json:"os,omitempty"` + Architecture string `json:"architecture,omitempty"` + NanoCPUs int64 `json:"nanoCpus,omitempty"` + MemoryBytes int64 `json:"memoryBytes,omitempty"` + Labels map[string]string `json:"labels,omitempty"` + EngineLabels map[string]string `json:"engineLabels,omitempty"` + CreatedAt *int64 `json:"createdAt,omitempty"` + UpdatedAt *int64 `json:"updatedAt,omitempty"` +} + // DockerSwarmFrontend summarises node-level swarm details. type DockerSwarmFrontend struct { NodeID string `json:"nodeId,omitempty"` diff --git a/internal/monitoring/canonical_guardrails_test.go b/internal/monitoring/canonical_guardrails_test.go index 5e27769aa..fbad01d8b 100644 --- a/internal/monitoring/canonical_guardrails_test.go +++ b/internal/monitoring/canonical_guardrails_test.go @@ -120,6 +120,29 @@ func TestDockerInventoryConvertersPreserveNativeRuntimeFields(t *testing.T) { if usage == nil || usage.Images.TotalCount != 3 || usage.Images.ReclaimableBytes != 512 { t.Fatalf("unexpected storage usage conversion: %+v", usage) } + + nodes := convertDockerNodes([]agentsdocker.Node{{ + ID: " node-manager ", + Hostname: " manager-1 ", + Role: " manager ", + Availability: " active ", + State: " ready ", + Address: " 192.0.2.10 ", + ManagerReachability: " reachable ", + ManagerAddress: " 192.0.2.10:2377 ", + Leader: true, + EngineVersion: " 27.5.1 ", + OS: " linux ", + Architecture: " amd64 ", + NanoCPUs: 8_000_000_000, + MemoryBytes: 32 * 1024 * 1024 * 1024, + Labels: map[string]string{"zone": "rack-a"}, + EngineLabels: map[string]string{"engine": "primary"}, + CreatedAt: createdAt, + }}) + if len(nodes) != 1 || nodes[0].ID != "node-manager" || nodes[0].ManagerReachability != "reachable" || nodes[0].EngineLabels["engine"] != "primary" { + t.Fatalf("unexpected swarm node conversion: %+v", nodes) + } } func TestPVEBackupPermissionWarningsPreserveTokenACLRepair(t *testing.T) { diff --git a/internal/monitoring/monitor_agents.go b/internal/monitoring/monitor_agents.go index 52e03853e..c9c9a96f6 100644 --- a/internal/monitoring/monitor_agents.go +++ b/internal/monitoring/monitor_agents.go @@ -1242,6 +1242,7 @@ func (m *Monitor) ApplyDockerReport(report agentsdocker.Report, tokenRecord *con networks := convertDockerNetworks(report.Networks) services := convertDockerServices(report.Services) tasks := convertDockerTasks(report.Tasks) + nodes := convertDockerNodes(report.Nodes) storageUsage := convertDockerStorageUsage(report.StorageUsage) swarmInfo := convertDockerSwarmInfo(report.Host.Swarm) security := deriveDockerHostSecurity(report.Host.Security, runtime) @@ -1350,6 +1351,7 @@ func (m *Monitor) ApplyDockerReport(report agentsdocker.Report, tokenRecord *con Networks: networks, Services: services, Tasks: tasks, + Nodes: nodes, StorageUsage: storageUsage, Swarm: swarmInfo, Security: security, diff --git a/internal/monitoring/monitor_helpers.go b/internal/monitoring/monitor_helpers.go index 214db544b..007d08671 100644 --- a/internal/monitoring/monitor_helpers.go +++ b/internal/monitoring/monitor_helpers.go @@ -390,6 +390,43 @@ func convertDockerTasks(tasks []agentsdocker.Task) []models.DockerTask { return result } +func convertDockerNodes(nodes []agentsdocker.Node) []models.DockerNode { + if len(nodes) == 0 { + return nil + } + + result := make([]models.DockerNode, 0, len(nodes)) + for _, node := range nodes { + modelNode := models.DockerNode{ + ID: strings.TrimSpace(node.ID), + Hostname: strings.TrimSpace(node.Hostname), + Role: strings.TrimSpace(node.Role), + Availability: strings.TrimSpace(node.Availability), + State: strings.TrimSpace(node.State), + Message: strings.TrimSpace(node.Message), + Address: strings.TrimSpace(node.Address), + ManagerReachability: strings.TrimSpace(node.ManagerReachability), + ManagerAddress: strings.TrimSpace(node.ManagerAddress), + Leader: node.Leader, + EngineVersion: strings.TrimSpace(node.EngineVersion), + OS: strings.TrimSpace(node.OS), + Architecture: strings.TrimSpace(node.Architecture), + NanoCPUs: node.NanoCPUs, + MemoryBytes: node.MemoryBytes, + Labels: cloneStringMap(node.Labels), + EngineLabels: cloneStringMap(node.EngineLabels), + CreatedAt: node.CreatedAt, + } + if node.UpdatedAt != nil && !node.UpdatedAt.IsZero() { + updated := *node.UpdatedAt + modelNode.UpdatedAt = &updated + } + result = append(result, modelNode.NormalizeCollections()) + } + + return result +} + func normalizeAgentVersion(version string) string { version = strings.TrimSpace(version) if version == "" { diff --git a/internal/monitoring/monitor_helpers_test.go b/internal/monitoring/monitor_helpers_test.go index f01f11692..2fdedfd68 100644 --- a/internal/monitoring/monitor_helpers_test.go +++ b/internal/monitoring/monitor_helpers_test.go @@ -403,6 +403,52 @@ func TestConvertDockerServices_LabelIsolation(t *testing.T) { } } +// --- convertDockerNodes --- + +func TestConvertDockerNodes_WithManagerFields(t *testing.T) { + now := time.Now().UTC() + updated := now.Add(5 * time.Minute) + src := []agentsdocker.Node{ + { + ID: " node-1 ", + Hostname: " manager-1 ", + Role: " manager ", + Availability: " active ", + State: " ready ", + Message: " ready ", + Address: " 192.0.2.10 ", + ManagerReachability: " reachable ", + ManagerAddress: " 192.0.2.10:2377 ", + Leader: true, + EngineVersion: " 27.5.1 ", + OS: " linux ", + Architecture: " amd64 ", + NanoCPUs: 4_000_000_000, + MemoryBytes: 16 * 1024 * 1024 * 1024, + Labels: map[string]string{"zone": "rack-a"}, + EngineLabels: map[string]string{"engine": "primary"}, + CreatedAt: now, + UpdatedAt: &updated, + }, + } + + result := convertDockerNodes(src) + if len(result) != 1 { + t.Fatalf("expected one converted node, got %+v", result) + } + node := result[0] + if node.ID != "node-1" || node.Hostname != "manager-1" || node.Role != "manager" { + t.Fatalf("unexpected converted identity: %+v", node) + } + if node.ManagerReachability != "reachable" || node.ManagerAddress != "192.0.2.10:2377" || !node.Leader { + t.Fatalf("unexpected manager metadata: %+v", node) + } + result[0].Labels["mutated"] = "yes" + if _, ok := src[0].Labels["mutated"]; ok { + t.Fatal("node labels should be cloned") + } +} + // --- extractSnapshotName --- func TestExtractSnapshotName_WithAt(t *testing.T) { diff --git a/internal/monitoring/monitor_host_agents_test.go b/internal/monitoring/monitor_host_agents_test.go index 47a889469..f12b983c3 100644 --- a/internal/monitoring/monitor_host_agents_test.go +++ b/internal/monitoring/monitor_host_agents_test.go @@ -168,6 +168,77 @@ func TestApplyDockerReport_RecreatedContainerAgentIDKeepsTokenBinding(t *testing } } +func TestApplyDockerReportPreservesDockerSwarmNodes(t *testing.T) { + monitor := newTestMonitor(t) + now := time.Now().UTC() + report := agentsdocker.Report{ + Agent: agentsdocker.AgentInfo{ + ID: "docker-agent-1", + Version: "1.0.0", + IntervalSeconds: 30, + }, + Host: agentsdocker.HostInfo{ + Hostname: "docker-host", + MachineID: "machine-docker-1", + Runtime: "docker", + RuntimeVersion: "27.5.1", + Swarm: &agentsdocker.SwarmInfo{ + NodeID: "node-manager", + NodeRole: "manager", + LocalState: "active", + ControlAvailable: true, + ClusterID: "cluster-1", + ClusterName: "prod-swarm", + Scope: "cluster", + }, + }, + Nodes: []agentsdocker.Node{{ + ID: " node-manager ", + Hostname: " manager-1 ", + Role: " manager ", + Availability: " active ", + State: " ready ", + Address: " 192.0.2.10 ", + ManagerReachability: " reachable ", + ManagerAddress: " 192.0.2.10:2377 ", + Leader: true, + EngineVersion: " 27.5.1 ", + OS: " linux ", + Architecture: " amd64 ", + NanoCPUs: 8_000_000_000, + MemoryBytes: 32 * 1024 * 1024 * 1024, + Labels: map[string]string{"zone": "rack-a"}, + EngineLabels: map[string]string{"engine": "primary"}, + CreatedAt: now.Add(-time.Hour), + UpdatedAt: &now, + }}, + Timestamp: now, + } + + host, err := monitor.ApplyDockerReport(report, nil) + if err != nil { + t.Fatalf("ApplyDockerReport: %v", err) + } + if len(host.Nodes) != 1 { + t.Fatalf("expected host node inventory, got %+v", host.Nodes) + } + node := host.Nodes[0] + if node.ID != "node-manager" || node.Hostname != "manager-1" || node.Role != "manager" { + t.Fatalf("unexpected host node identity: %+v", node) + } + if !node.Leader || node.ManagerReachability != "reachable" || node.EngineLabels["engine"] != "primary" { + t.Fatalf("expected manager metadata to be preserved, got %+v", node) + } + + snapshot := monitor.state.GetSnapshot() + if len(snapshot.DockerHosts) != 1 || len(snapshot.DockerHosts[0].Nodes) != 1 { + t.Fatalf("expected state snapshot to preserve nodes, got %+v", snapshot.DockerHosts) + } + if got := snapshot.DockerHosts[0].Nodes[0].ID; got != "node-manager" { + t.Fatalf("snapshot node id = %q, want node-manager", got) + } +} + func TestEvaluateHostAgentsTriggersOfflineAlert(t *testing.T) { t.Helper() diff --git a/internal/platformsupport/manifest_generated.go b/internal/platformsupport/manifest_generated.go index 962e9d061..5a72df039 100644 --- a/internal/platformsupport/manifest_generated.go +++ b/internal/platformsupport/manifest_generated.go @@ -1,7 +1,7 @@ // Code generated by scripts/release_control/generate_platform_support_backend_module.py. // DO NOT EDIT. // Source: docs/release-control/v6/internal/PLATFORM_SUPPORT_MANIFEST.json -// Source SHA256: 165f38fd8ec06299a966bba865ec28a29b3e9d4d99790e2e65da73da9ca8f45a +// Source SHA256: af152a63e78b121f52eb33fcdb7fb945ca5054435a4e4fb150973715b5489368 package platformsupport @@ -13,7 +13,7 @@ const ManifestSourcePath = "docs/release-control/v6/internal/PLATFORM_SUPPORT_MA // ManifestSourceSHA256 is the sha256 of the manifest bytes used to produce // this projection. -const ManifestSourceSHA256 = "165f38fd8ec06299a966bba865ec28a29b3e9d4d99790e2e65da73da9ca8f45a" +const ManifestSourceSHA256 = "af152a63e78b121f52eb33fcdb7fb945ca5054435a4e4fb150973715b5489368" // ManifestSchemaVersion is the schema_version field of the manifest. const ManifestSchemaVersion = 2 diff --git a/internal/unifiedresources/adapter_coverage_test.go b/internal/unifiedresources/adapter_coverage_test.go index 2ac4dc0e2..1f618eb53 100644 --- a/internal/unifiedresources/adapter_coverage_test.go +++ b/internal/unifiedresources/adapter_coverage_test.go @@ -403,6 +403,23 @@ func TestDockerNativeInventoryAdapters(t *testing.T) { 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) } + + nodeResource, nodeIdentity := resourceFromDockerSwarmNode(models.DockerNode{ + ID: "node-1", + Hostname: "manager-1", + Role: "manager", + Availability: "active", + State: "ready", + ManagerReachability: "reachable", + Leader: true, + EngineVersion: "27.5.1", + NanoCPUs: 4_000_000_000, + MemoryBytes: 16 * 1024 * 1024 * 1024, + Labels: map[string]string{"zone": "rack-a"}, + }, host) + if nodeResource.Type != ResourceTypeDockerSwarmNode || nodeResource.Docker == nil || nodeResource.Docker.NodeID != "node-1" || nodeResource.Docker.NodeRole != "manager" || nodeIdentity.Hostnames[0] != "manager-1" { + t.Fatalf("unexpected swarm node resource: resource=%+v identity=%+v", nodeResource, nodeIdentity) + } } func TestKubernetesNativeInventoryAdapters(t *testing.T) { diff --git a/internal/unifiedresources/adapters.go b/internal/unifiedresources/adapters.go index 6b96d8028..689253042 100644 --- a/internal/unifiedresources/adapters.go +++ b/internal/unifiedresources/adapters.go @@ -1154,6 +1154,7 @@ func resourceFromDockerHost(host models.DockerHost) (Resource, ResourceIdentity) ImageCount: len(host.Images), VolumeCount: len(host.Volumes), NetworkCount: len(host.Networks), + NodeCount: len(host.Nodes), UpdatesAvailableCount: updatesAvailableCount, UpdatesLastCheckedAt: updatesLastCheckedPtr, TokenID: host.TokenID, @@ -1173,6 +1174,7 @@ func resourceFromDockerHost(host models.DockerHost) (Resource, ResourceIdentity) NetworksRaw: append([]models.DockerNetwork(nil), host.Networks...), Services: append([]models.DockerService(nil), host.Services...), Tasks: append([]models.DockerTask(nil), host.Tasks...), + Nodes: append([]models.DockerNode(nil), host.Nodes...), } if host.StorageUsage != nil { docker.ImagesUsage = dockerStorageUsageMeta(host.StorageUsage.Images) @@ -2186,6 +2188,54 @@ func resourceFromDockerTask(task models.DockerTask, host models.DockerHost) (Res return resource, identity } +func resourceFromDockerSwarmNode(node models.DockerNode, host models.DockerHost) (Resource, ResourceIdentity) { + name := firstNonEmpty(strings.TrimSpace(node.Hostname), shortDigest(node.ID), node.ID) + clusterName := dockerSwarmClusterKeyFromMeta(convertSwarm(host.Swarm)) + labels := cloneLabelMap(node.Labels) + docker := &DockerData{ + HostSourceID: host.ID, + Hostname: strings.TrimSpace(host.Hostname), + NodeID: strings.TrimSpace(node.ID), + NodeName: strings.TrimSpace(node.Hostname), + NodeRole: strings.TrimSpace(node.Role), + Availability: strings.TrimSpace(node.Availability), + CurrentState: strings.TrimSpace(node.State), + Message: strings.TrimSpace(node.Message), + Address: strings.TrimSpace(node.Address), + ManagerReachability: strings.TrimSpace(node.ManagerReachability), + ManagerAddress: strings.TrimSpace(node.ManagerAddress), + Leader: node.Leader, + EngineVersion: strings.TrimSpace(node.EngineVersion), + RuntimeVersion: strings.TrimSpace(node.EngineVersion), + OS: strings.TrimSpace(node.OS), + Architecture: strings.TrimSpace(node.Architecture), + NanoCPUs: node.NanoCPUs, + MemoryBytes: node.MemoryBytes, + Runtime: "docker", + Labels: labels, + EngineLabels: cloneLabelMap(node.EngineLabels), + Swarm: convertSwarm(host.Swarm), + } + resource := Resource{ + Type: ResourceTypeDockerSwarmNode, + Technology: "docker", + Name: name, + Status: statusFromDockerSwarmNode(node), + LastSeen: host.LastSeen, + UpdatedAt: time.Now().UTC(), + Docker: docker, + Tags: labelsToTags(labels), + } + identity := ResourceIdentity{ + Hostnames: uniqueStrings([]string{name, strings.TrimSpace(node.ID)}), + } + if clusterName != "" { + identity.ClusterName = clusterName + identity.Hostnames = uniqueStrings(append(identity.Hostnames, clusterName+":"+name)) + } + return resource, identity +} + func firstDockerImageReference(image models.DockerImage) string { for _, candidate := range image.RepoTags { candidate = strings.TrimSpace(candidate) diff --git a/internal/unifiedresources/canonical_identity.go b/internal/unifiedresources/canonical_identity.go index 3a1c14cdb..1b8d62671 100644 --- a/internal/unifiedresources/canonical_identity.go +++ b/internal/unifiedresources/canonical_identity.go @@ -37,6 +37,9 @@ func canonicalPrimaryID(resource Resource) string { if identity := formatTargetIdentity(resource.DiscoveryTarget); identity != "" { return identity } + if identity := canonicalDockerSwarmNodePrimaryID(resource); identity != "" { + return identity + } if runtimeID := strings.TrimSpace(canonicalDockerRuntimeID(resource)); runtimeID != "" { return "docker-host:" + runtimeID } @@ -81,6 +84,8 @@ func canonicalAliases(resource Resource, primaryID, platformID, hostname string) targetResourceID(resource.MetricsTarget), targetAgentID(resource.DiscoveryTarget), targetResourceID(resource.DiscoveryTarget), + canonicalDockerNodeID(resource), + canonicalDockerNodeName(resource), canonicalDockerRuntimeID(resource), canonicalKubernetesClusterID(resource), canonicalAgentID(resource), @@ -223,6 +228,35 @@ func canonicalDockerRuntimeID(resource Resource) string { return "" } +func canonicalDockerSwarmNodePrimaryID(resource Resource) string { + if CanonicalResourceType(resource.Type) != ResourceTypeDockerSwarmNode || resource.Docker == nil { + return "" + } + nodeID := firstTrimmed(resource.Docker.NodeID, resource.Docker.NodeName, resource.Name) + if nodeID == "" { + return "" + } + scope := firstTrimmed(dockerSwarmClusterKeyFromMeta(resource.Docker.Swarm), resource.Docker.HostSourceID) + if scope == "" { + return "docker-swarm-node:" + nodeID + } + return "docker-swarm-node:" + scope + ":" + nodeID +} + +func canonicalDockerNodeID(resource Resource) string { + if resource.Docker == nil { + return "" + } + return strings.TrimSpace(resource.Docker.NodeID) +} + +func canonicalDockerNodeName(resource Resource) string { + if resource.Docker == nil { + return "" + } + return strings.TrimSpace(resource.Docker.NodeName) +} + func canonicalKubernetesClusterID(resource Resource) string { if resource.Kubernetes == nil { return "" diff --git a/internal/unifiedresources/canonical_identity_test.go b/internal/unifiedresources/canonical_identity_test.go index fbafde3f0..0692bede3 100644 --- a/internal/unifiedresources/canonical_identity_test.go +++ b/internal/unifiedresources/canonical_identity_test.go @@ -175,6 +175,57 @@ func TestRefreshCanonicalIdentityUsesAvailabilityTargetIdentity(t *testing.T) { } } +func TestRefreshCanonicalIdentityUsesDockerSwarmNodeIdentity(t *testing.T) { + resource := Resource{ + ID: "docker-swarm-node-resource-1", + Type: ResourceTypeDockerSwarmNode, + Name: "manager-1", + Identity: ResourceIdentity{ + Hostnames: []string{"manager-1"}, + }, + Docker: &DockerData{ + HostSourceID: "docker-host-1", + Hostname: "docker-host", + NodeID: "node-1", + NodeName: "manager-1", + NodeRole: "manager", + Swarm: &DockerSwarmInfo{ + ClusterID: "cluster-1", + ClusterName: "prod-swarm", + }, + }, + } + + RefreshCanonicalIdentity(&resource) + + if resource.Canonical == nil { + t.Fatalf("expected canonical identity") + } + if got := resource.Canonical.DisplayName; got != "manager-1" { + t.Fatalf("displayName = %q, want manager-1", got) + } + if got := resource.Canonical.PrimaryID; got != "docker-swarm-node:cluster-1:node-1" { + t.Fatalf("primaryId = %q, want docker-swarm-node:cluster-1:node-1", got) + } + + wantAliases := []string{ + "docker-swarm-node:cluster-1:node-1", + "node-1", + "manager-1", + "docker-host-1", + "docker-host", + "docker-swarm-node-resource-1", + } + if len(resource.Canonical.Aliases) != len(wantAliases) { + t.Fatalf("aliases len = %d, want %d (%v)", len(resource.Canonical.Aliases), len(wantAliases), resource.Canonical.Aliases) + } + for i, want := range wantAliases { + if got := resource.Canonical.Aliases[i]; got != want { + t.Fatalf("alias[%d] = %q, want %q", i, got, want) + } + } +} + func TestRefreshCanonicalIdentityPrefersProxmoxNodePrimaryIDForAgentResources(t *testing.T) { resource := Resource{ ID: "agent-1", diff --git a/internal/unifiedresources/clone.go b/internal/unifiedresources/clone.go index 19a3bf1a9..a89b1da9c 100644 --- a/internal/unifiedresources/clone.go +++ b/internal/unifiedresources/clone.go @@ -206,6 +206,7 @@ func cloneDockerData(in *DockerData) *DockerData { out.BuildCacheUsage = cloneDockerStorageUsageMeta(in.BuildCacheUsage) out.Ports = cloneDockerPortMetaSlice(in.Ports) out.Labels = cloneStringMap(in.Labels) + out.EngineLabels = cloneStringMap(in.EngineLabels) out.Networks = cloneDockerNetworkMetaSlice(in.Networks) out.Mounts = cloneDockerMountMetaSlice(in.Mounts) out.UpdateStatus = cloneDockerUpdateStatusMeta(in.UpdateStatus) @@ -225,6 +226,7 @@ func cloneDockerData(in *DockerData) *DockerData { out.NetworksRaw = append([]models.DockerNetwork(nil), in.NetworksRaw...) out.Services = append([]models.DockerService(nil), in.Services...) out.Tasks = append([]models.DockerTask(nil), in.Tasks...) + out.Nodes = append([]models.DockerNode(nil), in.Nodes...) return &out } diff --git a/internal/unifiedresources/policy_metadata.go b/internal/unifiedresources/policy_metadata.go index 7ac8ed8d0..054137c4d 100644 --- a/internal/unifiedresources/policy_metadata.go +++ b/internal/unifiedresources/policy_metadata.go @@ -154,6 +154,7 @@ func classifyResourceSensitivity(resource Resource) ResourceSensitivity { ResourceTypeK8sServiceAccount, ResourceTypeK8sEvent, ResourceTypeDockerService, + ResourceTypeDockerSwarmNode, ResourceTypeStorage, ResourceTypeNetwork, ResourceTypePBS, @@ -279,6 +280,8 @@ func resourceSummaryType(resource Resource) string { return "application container" case ResourceTypeDockerService: return "docker service" + case ResourceTypeDockerSwarmNode: + return "docker swarm node" case ResourceTypeK8sCluster: return "kubernetes cluster" case ResourceTypeK8sNode: diff --git a/internal/unifiedresources/registry.go b/internal/unifiedresources/registry.go index a4126f663..5c372b078 100644 --- a/internal/unifiedresources/registry.go +++ b/internal/unifiedresources/registry.go @@ -230,6 +230,45 @@ func (rr *ResourceRegistry) IngestSnapshot(snapshot models.StateSnapshot) { for _, candidate := range serviceByID { rr.ingestDockerService(candidate.service, candidate.host) } + + type dockerNodeCandidate struct { + host models.DockerHost + node models.DockerNode + } + nodeByID := make(map[string]dockerNodeCandidate) + for _, dh := range snapshot.DockerHosts { + if dh.Swarm == nil { + continue + } + for _, node := range dh.Nodes { + sourceID := dockerSwarmNodeSourceID(dh, node) + if sourceID == "" { + continue + } + existing, ok := nodeByID[sourceID] + if !ok { + nodeByID[sourceID] = dockerNodeCandidate{host: dh, node: node} + continue + } + replace := false + if existing.node.EngineVersion == "" && node.EngineVersion != "" { + replace = true + } + if existing.node.ManagerReachability == "" && node.ManagerReachability != "" { + replace = true + } + if !replace && dh.LastSeen.After(existing.host.LastSeen) { + replace = true + } + if replace { + nodeByID[sourceID] = dockerNodeCandidate{host: dh, node: node} + } + } + } + for _, candidate := range nodeByID { + rr.ingestDockerSwarmNode(candidate.node, candidate.host) + } + for _, dh := range snapshot.DockerHosts { for _, task := range dh.Tasks { rr.ingestDockerTask(task, dh) @@ -562,6 +601,22 @@ func (rr *ResourceRegistry) seedSourceIDForResourceLocked(resource *Resource, so return "" } return hostSourceID + "/task/" + taskID + case ResourceTypeDockerSwarmNode: + nodeID := strings.TrimSpace(resource.Docker.NodeID) + if nodeID == "" { + nodeID = strings.TrimSpace(resource.Name) + } + if nodeID == "" { + return "" + } + if clusterKey := dockerSwarmClusterKeyFromMeta(resource.Docker.Swarm); clusterKey != "" { + return fmt.Sprintf("%s:node:%s", clusterKey, nodeID) + } + hostSourceID := strings.TrimSpace(resource.Docker.HostSourceID) + if hostSourceID == "" { + return "" + } + return hostSourceID + "/swarm-node/" + nodeID default: return strings.TrimSpace(resource.Docker.HostSourceID) } @@ -1304,6 +1359,18 @@ func (rr *ResourceRegistry) ingestDockerTask(task models.DockerTask, host models rr.ingest(SourceDocker, sourceID, resource, identity) } +func (rr *ResourceRegistry) ingestDockerSwarmNode(node models.DockerNode, host models.DockerHost) { + resource, identity := resourceFromDockerSwarmNode(node, host) + if parentID := rr.sourceResourceID(SourceDocker, host.ID); parentID != "" { + resource.ParentID = &parentID + } + sourceID := dockerSwarmNodeSourceID(host, node) + 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) @@ -3043,6 +3110,24 @@ func dockerTaskSourceID(host models.DockerHost, task models.DockerTask) string { return hostID + "/task/" + taskID } +func dockerSwarmNodeSourceID(host models.DockerHost, node models.DockerNode) string { + nodeID := strings.TrimSpace(node.ID) + if nodeID == "" { + nodeID = strings.TrimSpace(node.Hostname) + } + if nodeID == "" { + return "" + } + if cluster := dockerSwarmClusterKey(host); cluster != "" { + return fmt.Sprintf("%s:node:%s", cluster, nodeID) + } + hostID := strings.TrimSpace(host.ID) + if hostID == "" { + return "" + } + return hostID + "/swarm-node/" + nodeID +} + 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 a80525e50..ab0ba959f 100644 --- a/internal/unifiedresources/registry_test.go +++ b/internal/unifiedresources/registry_test.go @@ -3881,6 +3881,14 @@ func TestRegistryIngestSnapshotPublishesDockerNativeInventory(t *testing.T) { Tasks: []models.DockerTask{{ ID: "task-1", ServiceID: "svc-1", ServiceName: "api", Slot: 1, DesiredState: "running", CurrentState: "running", }}, + Swarm: &models.DockerSwarmInfo{ + NodeID: "node-1", + NodeRole: "manager", + LocalState: "active", + }, + Nodes: []models.DockerNode{{ + ID: "node-1", Hostname: "manager-1", Role: "manager", State: "ready", + }}, }}, }) @@ -3893,6 +3901,7 @@ func TestRegistryIngestSnapshotPublishesDockerNativeInventory(t *testing.T) { ResourceTypeDockerVolume, ResourceTypeDockerNetwork, ResourceTypeDockerTask, + ResourceTypeDockerSwarmNode, } { 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 2ada8f5b9..809395acf 100644 --- a/internal/unifiedresources/status.go +++ b/internal/unifiedresources/status.go @@ -123,6 +123,28 @@ func statusFromDockerTask(task models.DockerTask) ResourceStatus { } } +func statusFromDockerSwarmNode(node models.DockerNode) ResourceStatus { + state := strings.ToLower(strings.TrimSpace(node.State)) + reachability := strings.ToLower(strings.TrimSpace(node.ManagerReachability)) + availability := strings.ToLower(strings.TrimSpace(node.Availability)) + + switch { + case state == "ready" || state == "active": + if reachability == "unreachable" || availability == "pause" || availability == "drain" { + return StatusWarning + } + return StatusOnline + case state == "down" || state == "disconnected": + return StatusOffline + case state == "unknown": + return StatusUnknown + case reachability == "unreachable": + return StatusWarning + default: + return StatusUnknown + } +} + func statusFromPBSInstance(instance models.PBSInstance) ResourceStatus { primary := strings.ToLower(strings.TrimSpace(instance.Status)) health := strings.ToLower(strings.TrimSpace(instance.ConnectionHealth)) diff --git a/internal/unifiedresources/types.go b/internal/unifiedresources/types.go index 47f36340e..19b1cf2fc 100644 --- a/internal/unifiedresources/types.go +++ b/internal/unifiedresources/types.go @@ -129,6 +129,7 @@ const ( ResourceTypeDockerVolume ResourceType = "docker-volume" ResourceTypeDockerNetwork ResourceType = "docker-network" ResourceTypeDockerTask ResourceType = "docker-task" + ResourceTypeDockerSwarmNode ResourceType = "docker-swarm-node" ResourceTypeK8sCluster ResourceType = "k8s-cluster" ResourceTypeK8sNode ResourceType = "k8s-node" ResourceTypePod ResourceType = "pod" @@ -836,6 +837,7 @@ type DockerData struct { ImageCount int `json:"imageCount,omitempty"` VolumeCount int `json:"volumeCount,omitempty"` NetworkCount int `json:"networkCount,omitempty"` + NodeCount int `json:"nodeCount,omitempty"` UpdatesAvailableCount int `json:"updatesAvailableCount,omitempty"` UpdatesLastCheckedAt *time.Time `json:"updatesLastCheckedAt,omitempty"` ImagesUsage *DockerStorageUsageMeta `json:"imagesUsage,omitempty"` @@ -912,6 +914,18 @@ type DockerData struct { StartedAt *time.Time `json:"startedAt,omitempty"` CompletedAt *time.Time `json:"completedAt,omitempty"` + // Swarm-node-specific fields (populated when Resource.Type == ResourceTypeDockerSwarmNode) + NodeRole string `json:"nodeRole,omitempty"` + Availability string `json:"availability,omitempty"` + Address string `json:"address,omitempty"` + ManagerReachability string `json:"managerReachability,omitempty"` + ManagerAddress string `json:"managerAddress,omitempty"` + Leader bool `json:"leader,omitempty"` + EngineVersion string `json:"engineVersion,omitempty"` + NanoCPUs int64 `json:"nanoCpus,omitempty"` + MemoryBytes int64 `json:"memoryBytes,omitempty"` + EngineLabels map[string]string `json:"engineLabels,omitempty"` + Swarm *DockerSwarmInfo `json:"swarm,omitempty"` NetworkInterfaces []NetworkInterface `json:"networkInterfaces,omitempty"` Disks []DiskInfo `json:"disks,omitempty"` @@ -923,6 +937,7 @@ type DockerData struct { NetworksRaw []models.DockerNetwork `json:"-"` Services []models.DockerService `json:"-"` Tasks []models.DockerTask `json:"-"` + Nodes []models.DockerNode `json:"-"` } // PBSData contains Proxmox Backup Server data. diff --git a/internal/unifiedresources/views.go b/internal/unifiedresources/views.go index e70aa9fd3..92693cd31 100644 --- a/internal/unifiedresources/views.go +++ b/internal/unifiedresources/views.go @@ -1562,6 +1562,13 @@ func (v DockerHostView) Tasks() []models.DockerTask { return append([]models.DockerTask(nil), v.r.Docker.Tasks...) } +func (v DockerHostView) Nodes() []models.DockerNode { + if v.r == nil || v.r.Docker == nil { + return nil + } + return append([]models.DockerNode(nil), v.r.Docker.Nodes...) +} + func (v DockerHostView) NetworkInterfaces() []NetworkInterface { if v.r == nil || v.r.Docker == nil { return nil diff --git a/pkg/agents/docker/report.go b/pkg/agents/docker/report.go index 3358fac87..46169e3df 100644 --- a/pkg/agents/docker/report.go +++ b/pkg/agents/docker/report.go @@ -20,6 +20,7 @@ type Report struct { Networks []Network `json:"networks,omitempty"` Services []Service `json:"services,omitempty"` Tasks []Task `json:"tasks,omitempty"` + Nodes []Node `json:"nodes,omitempty"` StorageUsage *StorageUsage `json:"storageUsage,omitempty"` Timestamp time.Time `json:"timestamp"` } @@ -289,3 +290,26 @@ type Task struct { StartedAt *time.Time `json:"startedAt,omitempty"` CompletedAt *time.Time `json:"completedAt,omitempty"` } + +// Node summarises a Docker Swarm node returned by the manager-side nodes API. +type Node struct { + ID string `json:"id"` + Hostname string `json:"hostname,omitempty"` + Role string `json:"role,omitempty"` + Availability string `json:"availability,omitempty"` + State string `json:"state,omitempty"` + Message string `json:"message,omitempty"` + Address string `json:"address,omitempty"` + ManagerReachability string `json:"managerReachability,omitempty"` + ManagerAddress string `json:"managerAddress,omitempty"` + Leader bool `json:"leader,omitempty"` + EngineVersion string `json:"engineVersion,omitempty"` + OS string `json:"os,omitempty"` + Architecture string `json:"architecture,omitempty"` + NanoCPUs int64 `json:"nanoCpus,omitempty"` + MemoryBytes int64 `json:"memoryBytes,omitempty"` + Labels map[string]string `json:"labels,omitempty"` + EngineLabels map[string]string `json:"engineLabels,omitempty"` + CreatedAt time.Time `json:"createdAt,omitempty"` + UpdatedAt *time.Time `json:"updatedAt,omitempty"` +} diff --git a/pkg/agents/docker/report_test.go b/pkg/agents/docker/report_test.go index 38c7caeab..7b7484889 100644 --- a/pkg/agents/docker/report_test.go +++ b/pkg/agents/docker/report_test.go @@ -388,6 +388,38 @@ func TestTask_Fields(t *testing.T) { } } +func TestNode_Fields(t *testing.T) { + now := time.Now() + node := Node{ + ID: "node123", + Hostname: "manager-1", + Role: "manager", + Availability: "active", + State: "ready", + ManagerReachability: "reachable", + Leader: true, + EngineVersion: "27.5.1", + NanoCPUs: 4_000_000_000, + MemoryBytes: 16 * 1024 * 1024 * 1024, + Labels: map[string]string{"zone": "rack-a"}, + EngineLabels: map[string]string{"engine": "primary"}, + CreatedAt: now, + } + + if node.ID != "node123" { + t.Errorf("ID = %q, want node123", node.ID) + } + if node.Role != "manager" || node.State != "ready" { + t.Errorf("unexpected node state: %+v", node) + } + if !node.Leader || node.ManagerReachability != "reachable" { + t.Errorf("unexpected manager fields: %+v", node) + } + if node.Labels["zone"] != "rack-a" || node.EngineLabels["engine"] != "primary" { + t.Errorf("unexpected labels: %+v / %+v", node.Labels, node.EngineLabels) + } +} + func TestCommand_Fields(t *testing.T) { cmd := Command{ ID: "cmd-123", diff --git a/scripts/release_control/subsystem_lookup_test.py b/scripts/release_control/subsystem_lookup_test.py index c442dd1f9..38f736f4e 100644 --- a/scripts/release_control/subsystem_lookup_test.py +++ b/scripts/release_control/subsystem_lookup_test.py @@ -3589,7 +3589,7 @@ class SubsystemLookupTest(unittest.TestCase): { "heading": "## Shared Boundaries", "path": "internal/api/access_control_handlers.go", - "line": 241, + "line": 242, "heading_line": 109, } ],