mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-08-25 17:02:10 +00:00
Expand Docker runtime inventory coverage
This commit is contained in:
parent
f18502fc24
commit
89abed099c
64 changed files with 1511 additions and 66 deletions
|
|
@ -86,7 +86,8 @@
|
|||
"docker-volume",
|
||||
"docker-network",
|
||||
"docker-service",
|
||||
"docker-task"
|
||||
"docker-task",
|
||||
"docker-swarm-node"
|
||||
],
|
||||
"support_floor": {
|
||||
"setup": "supported",
|
||||
|
|
|
|||
|
|
@ -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` |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
|||
</TableRow>
|
||||
);
|
||||
}
|
||||
if (props.variant === 'nodes') {
|
||||
return (
|
||||
<TableRow class={PLATFORM_TABLE_HEADER_ROW_CLASS}>
|
||||
<TableHead class={`${getPlatformTableHeadClassForKind('name')} md:w-[22%]`}>Node</TableHead>
|
||||
<TableHead class={`${getPlatformTableHeadClassForKind('text')} md:w-[10%]`}>Role</TableHead>
|
||||
<TableHead class={`${getPlatformTableHeadClassForKind('text')} md:w-[12%]`}>
|
||||
Availability
|
||||
</TableHead>
|
||||
<TableHead
|
||||
class={`${getPlatformTableHeadClassForKind('text')} hidden md:table-cell md:w-[14%]`}
|
||||
>
|
||||
Reachability
|
||||
</TableHead>
|
||||
<TableHead
|
||||
class={`${getPlatformTableHeadClassForKind('text')} hidden md:table-cell md:w-[18%]`}
|
||||
>
|
||||
Engine
|
||||
</TableHead>
|
||||
<TableHead
|
||||
class={`${getPlatformTableHeadClassForKind('numeric-value')} hidden md:table-cell md:w-[8%]`}
|
||||
>
|
||||
CPUs
|
||||
</TableHead>
|
||||
<TableHead class={`${getPlatformTableHeadClassForKind('numeric-value')} md:w-[16%]`}>
|
||||
Memory
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<TableRow class={PLATFORM_TABLE_HEADER_ROW_CLASS}>
|
||||
<TableHead class={`${getPlatformTableHeadClassForKind('name')} md:w-[22%]`}>Task</TableHead>
|
||||
|
|
@ -353,6 +391,36 @@ const DockerInventoryRow: Component<{ resource: Resource; variant: DockerInvento
|
|||
{textValue(props.resource.docker?.nodeName || props.resource.docker?.nodeId)}
|
||||
</TableCell>
|
||||
</Show>
|
||||
<Show when={props.variant === 'nodes'}>
|
||||
<TableCell class={`${getPlatformTableCellClassForKind('text')} text-base-content`}>
|
||||
{textValue(props.resource.docker?.nodeRole)}
|
||||
</TableCell>
|
||||
<TableCell class={`${getPlatformTableCellClassForKind('text')} text-base-content`}>
|
||||
{textValue(props.resource.docker?.availability)}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
class={`${getPlatformTableCellClassForKind('text')} hidden text-base-content md:table-cell`}
|
||||
>
|
||||
{textValue(
|
||||
props.resource.docker?.leader
|
||||
? 'leader'
|
||||
: props.resource.docker?.managerReachability,
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
class={`${getPlatformTableCellClassForKind('text')} hidden text-base-content md:table-cell`}
|
||||
>
|
||||
{textValue(props.resource.docker?.engineVersion || props.resource.docker?.runtimeVersion)}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
class={`${getPlatformTableCellClassForKind('numeric-value')} hidden text-base-content md:table-cell`}
|
||||
>
|
||||
{cpuValue(props.resource.docker?.nanoCpus)}
|
||||
</TableCell>
|
||||
<TableCell class={`${getPlatformTableCellClassForKind('numeric-value')} text-base-content`}>
|
||||
{byteValue(props.resource.docker?.memoryBytes)}
|
||||
</TableCell>
|
||||
</Show>
|
||||
</TableRow>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
/>
|
||||
</Show>
|
||||
<Show when={activeTab() === 'storage'}>
|
||||
<DockerStorageUsageTable
|
||||
hosts={filteredHosts()}
|
||||
sourceCount={model().hosts.length}
|
||||
emptyIcon={dockerIcon()}
|
||||
emptyTitle="No engine storage usage"
|
||||
emptyDescription="Docker / Podman storage usage appears here when hosts report the engine disk-usage snapshot."
|
||||
/>
|
||||
</Show>
|
||||
<Show when={activeTab() === 'swarm-nodes'}>
|
||||
<DockerInventoryTable
|
||||
resources={model().nodes}
|
||||
variant="nodes"
|
||||
emptyIcon={dockerIcon()}
|
||||
emptyTitle="No Swarm nodes"
|
||||
emptyDescription="Swarm nodes appear here when a Docker manager reports the cluster node inventory."
|
||||
/>
|
||||
</Show>
|
||||
<Show when={activeTab() === 'services'}>
|
||||
<DockerServicesTable
|
||||
resources={model().services}
|
||||
|
|
|
|||
200
frontend-modern/src/features/docker/DockerStorageUsageTable.tsx
Normal file
200
frontend-modern/src/features/docker/DockerStorageUsageTable.tsx
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
import { For, Show, type Component, type JSX } from 'solid-js';
|
||||
import { StatusDot } from '@/components/shared/StatusDot';
|
||||
import { TableCard } from '@/components/shared/TableCard';
|
||||
import { TableCardHeader } from '@/components/shared/TableCardHeader';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/shared/Table';
|
||||
import { formatBytes } from '@/utils/format';
|
||||
import { getSimpleStatusIndicator } from '@/utils/status';
|
||||
import { asTrimmedString } from '@/utils/stringUtils';
|
||||
import {
|
||||
PLATFORM_HEALTH_FILTER_OPTIONS,
|
||||
PLATFORM_TABLE_BODY_CLASS,
|
||||
PLATFORM_TABLE_CARD_CLASS,
|
||||
PLATFORM_TABLE_HEADER_ROW_CLASS,
|
||||
PlatformTableEmptyState,
|
||||
PlatformTableToolbar,
|
||||
createPlatformTableFilterState,
|
||||
filterPlatformResources,
|
||||
getPlatformTableCellClassForKind,
|
||||
getPlatformTableHeadClassForKind,
|
||||
type PlatformResourceStatusFilter,
|
||||
} from '@/features/platformPage/sharedPlatformPage';
|
||||
import type { DockerStorageUsageMeta, Resource } from '@/types/resource';
|
||||
|
||||
const hasStorageBucket = (bucket?: DockerStorageUsageMeta): boolean =>
|
||||
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 <span class="text-muted">—</span>;
|
||||
const totalSize = bucket?.totalSizeBytes ?? 0;
|
||||
const reclaimable = bucket?.reclaimableBytes ?? 0;
|
||||
const count = bucket?.totalCount ?? 0;
|
||||
const active = bucket?.activeCount ?? 0;
|
||||
return (
|
||||
<span class="inline-flex min-w-0 flex-col items-end leading-tight">
|
||||
<span class="tabular-nums text-base-content">{formatBytes(totalSize)}</span>
|
||||
<span class="truncate text-[10px] text-muted">
|
||||
{count} total, {active} active, {formatBytes(reclaimable)} reclaimable
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
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 (
|
||||
<Show
|
||||
when={storageHosts().length > 0}
|
||||
fallback={
|
||||
<PlatformTableEmptyState
|
||||
icon={props.emptyIcon}
|
||||
title={
|
||||
hasFilteredSourceRows() ? 'No engine storage usage reported' : props.emptyTitle
|
||||
}
|
||||
description={
|
||||
hasFilteredSourceRows()
|
||||
? 'Hosts are present, but none have reported the Docker / Podman disk-usage snapshot yet.'
|
||||
: props.emptyDescription
|
||||
}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div class="space-y-3">
|
||||
<PlatformTableToolbar
|
||||
search={tableState.search}
|
||||
onSearchChange={tableState.setSearch}
|
||||
searchPlaceholder="Search storage usage"
|
||||
status={tableState.status()}
|
||||
onStatusChange={tableState.setStatus}
|
||||
statusOptions={PLATFORM_HEALTH_FILTER_OPTIONS}
|
||||
visible={tableState.visible()}
|
||||
total={tableState.total()}
|
||||
rowNoun="hosts"
|
||||
/>
|
||||
|
||||
<Show
|
||||
when={tableState.filtered().length > 0}
|
||||
fallback={
|
||||
<PlatformTableEmptyState
|
||||
icon={props.emptyIcon}
|
||||
title="No storage rows match current filters"
|
||||
description="Adjust the search or status filter to see more rows."
|
||||
/>
|
||||
}
|
||||
>
|
||||
<TableCard class={PLATFORM_TABLE_CARD_CLASS}>
|
||||
<TableCardHeader title="Engine Storage Usage" />
|
||||
<Table class="min-w-full table-fixed text-xs md:min-w-[1080px]">
|
||||
<TableHeader>
|
||||
<TableRow class={PLATFORM_TABLE_HEADER_ROW_CLASS}>
|
||||
<TableHead class={`${getPlatformTableHeadClassForKind('name')} md:w-[22%]`}>
|
||||
Host
|
||||
</TableHead>
|
||||
<TableHead
|
||||
class={`${getPlatformTableHeadClassForKind('numeric-value')} md:w-[19%]`}
|
||||
>
|
||||
Images
|
||||
</TableHead>
|
||||
<TableHead
|
||||
class={`${getPlatformTableHeadClassForKind('numeric-value')} md:w-[19%]`}
|
||||
>
|
||||
Containers
|
||||
</TableHead>
|
||||
<TableHead
|
||||
class={`${getPlatformTableHeadClassForKind('numeric-value')} hidden md:table-cell md:w-[20%]`}
|
||||
>
|
||||
Volumes
|
||||
</TableHead>
|
||||
<TableHead
|
||||
class={`${getPlatformTableHeadClassForKind('numeric-value')} hidden md:table-cell md:w-[20%]`}
|
||||
>
|
||||
Build Cache
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody class={PLATFORM_TABLE_BODY_CLASS}>
|
||||
<For each={tableState.filtered()}>
|
||||
{(host) => {
|
||||
const name = () => asTrimmedString(host.name) || host.id;
|
||||
const indicator = () => getSimpleStatusIndicator(host.status);
|
||||
return (
|
||||
<TableRow class="text-[11px] sm:text-xs">
|
||||
<TableCell class={getPlatformTableCellClassForKind('name')}>
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<StatusDot
|
||||
size="sm"
|
||||
variant={indicator().variant}
|
||||
title={host.status || 'unknown'}
|
||||
ariaHidden
|
||||
/>
|
||||
<span class="truncate font-semibold text-base-content" title={name()}>
|
||||
{name()}
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell
|
||||
class={`${getPlatformTableCellClassForKind('numeric-value')} text-base-content`}
|
||||
>
|
||||
{bucketValue(host.docker?.imagesUsage)}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
class={`${getPlatformTableCellClassForKind('numeric-value')} text-base-content`}
|
||||
>
|
||||
{bucketValue(host.docker?.containersUsage)}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
class={`${getPlatformTableCellClassForKind('numeric-value')} hidden text-base-content md:table-cell`}
|
||||
>
|
||||
{bucketValue(host.docker?.volumesUsage)}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
class={`${getPlatformTableCellClassForKind('numeric-value')} hidden text-base-content md:table-cell`}
|
||||
>
|
||||
{bucketValue(host.docker?.buildCacheUsage)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableCard>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
);
|
||||
};
|
||||
|
||||
export default DockerStorageUsageTable;
|
||||
|
|
@ -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(() => <DockerPageSurface />);
|
||||
|
||||
expect(mocks.useUnifiedResources).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
query: expect.stringContaining('docker-swarm-node'),
|
||||
}),
|
||||
);
|
||||
expect(mocks.useWorkloadsState).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
compactGroupHeaders: true,
|
||||
|
|
|
|||
|
|
@ -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> = {}): 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({
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ const DOCKER_IMAGE_TYPES = new Set<ResourceType>(['docker-image']);
|
|||
const DOCKER_VOLUME_TYPES = new Set<ResourceType>(['docker-volume']);
|
||||
const DOCKER_NETWORK_TYPES = new Set<ResourceType>(['docker-network']);
|
||||
const DOCKER_TASK_TYPES = new Set<ResourceType>(['docker-task']);
|
||||
const DOCKER_SWARM_NODE_TYPES = new Set<ResourceType>(['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<string | undefined>
|
|||
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 ?? []),
|
||||
|
|
|
|||
|
|
@ -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':
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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<string, string>;
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ describe('reportableResourceTypes', () => {
|
|||
'docker-volume',
|
||||
'docker-network',
|
||||
'docker-task',
|
||||
'docker-swarm-node',
|
||||
'k8s-namespace',
|
||||
'k8s-service',
|
||||
'k8s-replicaset',
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ export const CANONICAL_RESOURCE_TYPES = [
|
|||
'docker-volume',
|
||||
'docker-network',
|
||||
'docker-task',
|
||||
'docker-swarm-node',
|
||||
'k8s-deployment',
|
||||
'k8s-replicaset',
|
||||
'k8s-service',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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':
|
||||
|
|
|
|||
|
|
@ -70,6 +70,10 @@ const RESOURCE_TYPE_PRESENTATION: Partial<Record<ResourceType | string, Resource
|
|||
label: 'Swarm Task',
|
||||
badgeClasses: DEFAULT_BADGE_CLASSES,
|
||||
},
|
||||
'docker-swarm-node': {
|
||||
label: 'Swarm Node',
|
||||
badgeClasses: DEFAULT_BADGE_CLASSES,
|
||||
},
|
||||
'k8s-deployment': {
|
||||
label: 'K8s Deployment',
|
||||
badgeClasses: DEFAULT_BADGE_CLASSES,
|
||||
|
|
|
|||
|
|
@ -11075,6 +11075,7 @@ func TestContractResourceFiltersAcceptNativeDockerAndKubernetesInventory(t *test
|
|||
"docker-volume",
|
||||
"docker-network",
|
||||
"docker-task",
|
||||
"docker-swarm-node",
|
||||
"k8s-namespace",
|
||||
"k8s-service",
|
||||
"k8s-replicaset",
|
||||
|
|
@ -11103,6 +11104,7 @@ func TestContractResourceFiltersAcceptNativeDockerAndKubernetesInventory(t *test
|
|||
unifiedresources.ResourceTypeDockerVolume,
|
||||
unifiedresources.ResourceTypeDockerNetwork,
|
||||
unifiedresources.ResourceTypeDockerTask,
|
||||
unifiedresources.ResourceTypeDockerSwarmNode,
|
||||
unifiedresources.ResourceTypeK8sNamespace,
|
||||
unifiedresources.ResourceTypeK8sService,
|
||||
unifiedresources.ResourceTypeK8sReplicaSet,
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ var supportedOrganizationShareResourceTypes = map[string]struct{}{
|
|||
"docker-volume": {},
|
||||
"docker-network": {},
|
||||
"docker-task": {},
|
||||
"docker-swarm-node": {},
|
||||
"k8s-deployment": {},
|
||||
"k8s-replicaset": {},
|
||||
"k8s-service": {},
|
||||
|
|
|
|||
|
|
@ -1240,6 +1240,7 @@ func TestIsUnsupportedOrganizationShareResourceType(t *testing.T) {
|
|||
{name: "docker volume supported", in: "docker-volume", want: false},
|
||||
{name: "docker network supported", in: "docker-network", want: false},
|
||||
{name: "docker task supported", in: "docker-task", want: false},
|
||||
{name: "docker swarm node supported", in: "docker-swarm-node", want: false},
|
||||
{name: "kubernetes namespace supported", in: "k8s-namespace", want: false},
|
||||
{name: "kubernetes service supported", in: "k8s-service", want: false},
|
||||
{name: "kubernetes replicaset supported", in: "k8s-replicaset", want: false},
|
||||
|
|
|
|||
|
|
@ -1865,6 +1865,8 @@ func resourceTypeFilterAdapter(token string) []unified.ResourceType {
|
|||
return []unified.ResourceType{unified.ResourceTypeDockerNetwork}
|
||||
case "docker-task", "docker-tasks":
|
||||
return []unified.ResourceType{unified.ResourceTypeDockerTask}
|
||||
case "docker-swarm-node", "docker-swarm-nodes", "docker-node", "docker-nodes", "swarm-node", "swarm-nodes":
|
||||
return []unified.ResourceType{unified.ResourceTypeDockerSwarmNode}
|
||||
case "vm", "vms":
|
||||
return []unified.ResourceType{unified.ResourceTypeVM}
|
||||
case "system-container", "system-containers", "oci-container":
|
||||
|
|
|
|||
|
|
@ -191,6 +191,8 @@ func TestParseResourceTypesNodeAlias(t *testing.T) {
|
|||
{name: "docker volume", input: "docker-volume", want: map[unified.ResourceType]struct{}{unified.ResourceTypeDockerVolume: {}}},
|
||||
{name: "docker network", input: "docker-network", want: map[unified.ResourceType]struct{}{unified.ResourceTypeDockerNetwork: {}}},
|
||||
{name: "docker task", input: "docker-task", want: map[unified.ResourceType]struct{}{unified.ResourceTypeDockerTask: {}}},
|
||||
{name: "docker swarm node", input: "docker-swarm-node", want: map[unified.ResourceType]struct{}{unified.ResourceTypeDockerSwarmNode: {}}},
|
||||
{name: "docker node alias", input: "docker-node", want: map[unified.ResourceType]struct{}{unified.ResourceTypeDockerSwarmNode: {}}},
|
||||
{name: "k8s namespace", input: "k8s-namespace", want: map[unified.ResourceType]struct{}{unified.ResourceTypeK8sNamespace: {}}},
|
||||
{name: "k8s service", input: "k8s-service", want: map[unified.ResourceType]struct{}{unified.ResourceTypeK8sService: {}}},
|
||||
{name: "k8s statefulset", input: "k8s-statefulset", want: map[unified.ResourceType]struct{}{unified.ResourceTypeK8sStatefulSet: {}}},
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import (
|
|||
containertypes "github.com/moby/moby/api/types/container"
|
||||
imagetypes "github.com/moby/moby/api/types/image"
|
||||
networktypes "github.com/moby/moby/api/types/network"
|
||||
swarmtypes "github.com/moby/moby/api/types/swarm"
|
||||
systemtypes "github.com/moby/moby/api/types/system"
|
||||
volumetypes "github.com/moby/moby/api/types/volume"
|
||||
dockerclient "github.com/moby/moby/client"
|
||||
|
|
@ -134,6 +135,81 @@ func TestCollectDockerNativeInventory(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestCollectSwarmNodesMapsManagerInventory(t *testing.T) {
|
||||
createdAt := time.Date(2026, 5, 24, 8, 15, 0, 0, time.UTC)
|
||||
updatedAt := createdAt.Add(2 * time.Minute)
|
||||
agent := &Agent{
|
||||
logger: zerolog.Nop(),
|
||||
docker: &fakeDockerClient{
|
||||
nodeListFn: func(context.Context, dockerNodeListOptions) ([]swarmtypes.Node, error) {
|
||||
return []swarmtypes.Node{{
|
||||
ID: " node-1 ",
|
||||
Spec: swarmtypes.NodeSpec{
|
||||
Role: swarmtypes.NodeRoleManager,
|
||||
Availability: swarmtypes.NodeAvailabilityActive,
|
||||
Annotations: swarmtypes.Annotations{
|
||||
Labels: map[string]string{"zone": "rack-a"},
|
||||
},
|
||||
},
|
||||
Description: swarmtypes.NodeDescription{
|
||||
Hostname: " manager-1 ",
|
||||
Platform: swarmtypes.Platform{
|
||||
OS: "linux",
|
||||
Architecture: "amd64",
|
||||
},
|
||||
Resources: swarmtypes.Resources{
|
||||
NanoCPUs: 8_000_000_000,
|
||||
MemoryBytes: 32 * 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,
|
||||
},
|
||||
}}, nil
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
nodes, err := agent.collectSwarmNodes(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("collectSwarmNodes: %v", err)
|
||||
}
|
||||
if len(nodes) != 1 {
|
||||
t.Fatalf("expected one node, got %+v", nodes)
|
||||
}
|
||||
node := nodes[0]
|
||||
if node.ID != "node-1" || node.Hostname != "manager-1" || node.Role != string(swarmtypes.NodeRoleManager) {
|
||||
t.Fatalf("unexpected node identity: %+v", node)
|
||||
}
|
||||
if node.ManagerReachability != string(swarmtypes.ReachabilityReachable) || node.ManagerAddress != "192.0.2.10:2377" || !node.Leader {
|
||||
t.Fatalf("expected manager status to be preserved, got %+v", node)
|
||||
}
|
||||
if node.EngineVersion != "27.5.1" || node.NanoCPUs != 8_000_000_000 || node.MemoryBytes == 0 {
|
||||
t.Fatalf("expected engine capacity to be preserved, got %+v", node)
|
||||
}
|
||||
if node.Labels["zone"] != "rack-a" || node.EngineLabels["engine"] != "primary" {
|
||||
t.Fatalf("expected node labels to be preserved, got labels=%+v engine=%+v", node.Labels, node.EngineLabels)
|
||||
}
|
||||
if !node.CreatedAt.Equal(createdAt) || node.UpdatedAt == nil || !node.UpdatedAt.Equal(updatedAt) {
|
||||
t.Fatalf("expected timestamps to be preserved, got %+v", node)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectContainer(t *testing.T) {
|
||||
logger := zerolog.Nop()
|
||||
|
||||
|
|
|
|||
|
|
@ -119,7 +119,7 @@ func (a *Agent) buildReport(ctx context.Context) (agentsdocker.Report, error) {
|
|||
}
|
||||
}
|
||||
|
||||
services, tasks, swarmInfo := a.collectSwarmData(ctx, info, containers)
|
||||
services, tasks, nodes, swarmInfo := a.collectSwarmData(ctx, info, containers)
|
||||
diskUsageResult, storageUsage, err := a.collectStorageUsage(ctx)
|
||||
if err != nil {
|
||||
a.logger.Warn().Err(err).Msg("failed to collect Docker storage usage")
|
||||
|
|
@ -196,6 +196,9 @@ func (a *Agent) buildReport(ctx context.Context) (agentsdocker.Report, error) {
|
|||
if a.cfg.IncludeTasks && len(tasks) > 0 {
|
||||
report.Tasks = tasks
|
||||
}
|
||||
if len(nodes) > 0 {
|
||||
report.Nodes = nodes
|
||||
}
|
||||
if storageUsage != nil {
|
||||
report.StorageUsage = storageUsage
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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":
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 == "" {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 ""
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
],
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue