Expand Docker and Kubernetes platform projections

This commit is contained in:
rcourtman 2026-05-24 08:58:02 +01:00
parent d3934e19e3
commit 6346929328
63 changed files with 5858 additions and 258 deletions

View file

@ -11,7 +11,11 @@ import (
"time"
containertypes "github.com/moby/moby/api/types/container"
imagetypes "github.com/moby/moby/api/types/image"
networktypes "github.com/moby/moby/api/types/network"
systemtypes "github.com/moby/moby/api/types/system"
volumetypes "github.com/moby/moby/api/types/volume"
dockerclient "github.com/moby/moby/client"
"github.com/rs/zerolog"
)
@ -30,6 +34,106 @@ func TestBuildHostSecurityInfoAuthorizationPlugins(t *testing.T) {
}
}
func TestCollectDockerNativeInventory(t *testing.T) {
createdAt := time.Date(2026, 5, 24, 8, 0, 0, 0, time.UTC)
agent := &Agent{
logger: zerolog.Nop(),
docker: &fakeDockerClient{
imageListFn: func(context.Context, dockerImageListOptions) ([]imagetypes.Summary, error) {
return []imagetypes.Summary{{
ID: " sha256:image1 ",
RepoTags: []string{"repo/app:latest", " "},
RepoDigests: []string{"repo/app@sha256:abc"},
Size: 1024,
SharedSize: 256,
Containers: 2,
Created: createdAt.Unix(),
Labels: map[string]string{"tier": "web"},
}}, nil
},
volumeListFn: func(context.Context, dockerVolumeListOptions) ([]volumetypes.Volume, error) {
return []volumetypes.Volume{{
Name: " app-data ",
Driver: " local ",
Mountpoint: "/var/lib/docker/volumes/app-data",
Scope: " local ",
Labels: map[string]string{"backup": "true"},
}}, nil
},
networkListFn: func(context.Context, dockerNetworkListOptions) ([]networktypes.Summary, error) {
return []networktypes.Summary{{
Network: networktypes.Network{
ID: " net1 ",
Name: " app-net ",
Driver: " bridge ",
Scope: " local ",
EnableIPv4: true,
Attachable: true,
IPAM: networktypes.IPAM{Config: []networktypes.IPAMConfig{{
Subnet: netip.MustParsePrefix("10.88.0.0/24"),
Gateway: netip.MustParseAddr("10.88.0.1"),
}}},
Labels: map[string]string{"env": "prod"},
Options: map[string]string{"mtu": "1500"},
},
}}, nil
},
diskUsageFn: func(context.Context, dockerDiskUsageOptions) (dockerclient.DiskUsageResult, error) {
return dockerclient.DiskUsageResult{
Images: dockerclient.ImagesDiskUsage{
TotalCount: 3, ActiveCount: 2, TotalSize: 4096, Reclaimable: 512,
},
Volumes: dockerclient.VolumesDiskUsage{
TotalCount: 1,
Items: []volumetypes.Volume{{
Name: "app-data",
UsageData: &volumetypes.UsageData{Size: 2048, RefCount: 4},
}},
},
}, nil
},
},
}
images, err := agent.collectImages(context.Background())
if err != nil {
t.Fatalf("collectImages: %v", err)
}
if len(images) != 1 || images[0].ID != "sha256:image1" || images[0].CreatedAt != createdAt {
t.Fatalf("unexpected images: %+v", images)
}
if len(images[0].RepoTags) != 1 || images[0].RepoTags[0] != "repo/app:latest" {
t.Fatalf("expected normalized repo tags, got %#v", images[0].RepoTags)
}
usageResult, storageUsage, err := agent.collectStorageUsage(context.Background())
if err != nil {
t.Fatalf("collectStorageUsage: %v", err)
}
if storageUsage.Images.TotalCount != 3 || storageUsage.Images.ReclaimableBytes != 512 {
t.Fatalf("unexpected storage usage: %+v", storageUsage)
}
volumes, err := agent.collectVolumes(context.Background(), usageResult.Volumes.Items)
if err != nil {
t.Fatalf("collectVolumes: %v", err)
}
if len(volumes) != 1 || volumes[0].Name != "app-data" || volumes[0].SizeBytes != 2048 || volumes[0].RefCount != 4 {
t.Fatalf("unexpected volumes: %+v", volumes)
}
networks, err := agent.collectNetworks(context.Background())
if err != nil {
t.Fatalf("collectNetworks: %v", err)
}
if len(networks) != 1 || networks[0].Name != "app-net" || !networks[0].EnableIPv4 || !networks[0].Attachable {
t.Fatalf("unexpected networks: %+v", networks)
}
if len(networks[0].Subnets) != 1 || networks[0].Subnets[0].Subnet != "10.88.0.0/24" || networks[0].Subnets[0].Gateway != "10.88.0.1" {
t.Fatalf("unexpected network subnets: %+v", networks[0].Subnets)
}
}
func TestCollectContainer(t *testing.T) {
logger := zerolog.Nop()

View file

@ -14,7 +14,11 @@ import (
"time"
containertypes "github.com/moby/moby/api/types/container"
"github.com/moby/moby/api/types/image"
networktypes "github.com/moby/moby/api/types/network"
systemtypes "github.com/moby/moby/api/types/system"
"github.com/moby/moby/api/types/volume"
"github.com/moby/moby/client"
agentsdocker "github.com/rcourtman/pulse-go-rewrite/pkg/agents/docker"
)
@ -116,6 +120,22 @@ func (a *Agent) buildReport(ctx context.Context) (agentsdocker.Report, error) {
}
services, tasks, swarmInfo := a.collectSwarmData(ctx, info, containers)
diskUsageResult, storageUsage, err := a.collectStorageUsage(ctx)
if err != nil {
a.logger.Warn().Err(err).Msg("failed to collect Docker storage usage")
}
images, err := a.collectImages(ctx)
if err != nil {
a.logger.Warn().Err(err).Msg("failed to collect Docker images")
}
volumes, err := a.collectVolumes(ctx, diskUsageResult.Volumes.Items)
if err != nil {
a.logger.Warn().Err(err).Msg("failed to collect Docker volumes")
}
networks, err := a.collectNetworks(ctx)
if err != nil {
a.logger.Warn().Err(err).Msg("failed to collect Docker networks")
}
// Use Docker's MemTotal, but fall back to gopsutil's reading if Docker returns 0.
// This can happen in Docker-in-LXC setups where Docker daemon can't read host memory.
@ -161,12 +181,24 @@ func (a *Agent) buildReport(ctx context.Context) (agentsdocker.Report, error) {
if a.cfg.IncludeContainers {
report.Containers = containers
}
if len(images) > 0 {
report.Images = images
}
if len(volumes) > 0 {
report.Volumes = volumes
}
if len(networks) > 0 {
report.Networks = networks
}
if a.cfg.IncludeServices && len(services) > 0 {
report.Services = services
}
if a.cfg.IncludeTasks && len(tasks) > 0 {
report.Tasks = tasks
}
if storageUsage != nil {
report.StorageUsage = storageUsage
}
if report.Agent.IntervalSeconds <= 0 {
report.Agent.IntervalSeconds = int(30 * time.Second / time.Second)
@ -209,6 +241,17 @@ func normalizedNonEmptyStrings(values []string) []string {
return normalized
}
func cloneStringMap(src map[string]string) map[string]string {
if len(src) == 0 {
return nil
}
out := make(map[string]string, len(src))
for k, v := range src {
out[k] = v
}
return out
}
func (a *Agent) collectContainers(ctx context.Context) ([]agentsdocker.Container, error) {
options := dockerContainerListOptions{All: true}
if len(a.stateFilters) > 0 {
@ -253,6 +296,167 @@ func (a *Agent) collectContainers(ctx context.Context) ([]agentsdocker.Container
return containers, nil
}
func (a *Agent) collectImages(ctx context.Context) ([]agentsdocker.Image, error) {
list, err := dockerCallWithRetry(ctx, dockerInventoryCallTimeout, func(callCtx context.Context) ([]image.Summary, error) {
return a.docker.ImageList(callCtx, dockerImageListOptions{All: true, SharedSize: true})
})
if err != nil {
return nil, annotateDockerConnectionError(err)
}
images := make([]agentsdocker.Image, 0, len(list))
for _, summary := range list {
images = append(images, agentsdocker.Image{
ID: strings.TrimSpace(summary.ID),
RepoTags: normalizedNonEmptyStrings(summary.RepoTags),
RepoDigests: normalizedNonEmptyStrings(summary.RepoDigests),
SizeBytes: summary.Size,
SharedSizeBytes: summary.SharedSize,
Containers: summary.Containers,
CreatedAt: dockerUnixTimestamp(summary.Created),
Labels: cloneStringMap(summary.Labels),
})
}
return images, nil
}
func (a *Agent) collectVolumes(ctx context.Context, usageVolumes []volume.Volume) ([]agentsdocker.Volume, error) {
usageByName := make(map[string]volume.UsageData, len(usageVolumes))
for _, volume := range usageVolumes {
name := strings.TrimSpace(volume.Name)
if name == "" || volume.UsageData == nil {
continue
}
usageByName[name] = *volume.UsageData
}
list, err := dockerCallWithRetry(ctx, dockerInventoryCallTimeout, func(callCtx context.Context) ([]volume.Volume, error) {
return a.docker.VolumeList(callCtx, dockerVolumeListOptions{})
})
if err != nil {
return nil, annotateDockerConnectionError(err)
}
volumes := make([]agentsdocker.Volume, 0, len(list))
for _, v := range list {
sizeBytes := int64(0)
refCount := int64(0)
if v.UsageData != nil {
sizeBytes = v.UsageData.Size
refCount = v.UsageData.RefCount
} else if usage, ok := usageByName[strings.TrimSpace(v.Name)]; ok {
sizeBytes = usage.Size
refCount = usage.RefCount
}
volumes = append(volumes, agentsdocker.Volume{
Name: strings.TrimSpace(v.Name),
Driver: strings.TrimSpace(v.Driver),
Mountpoint: strings.TrimSpace(v.Mountpoint),
Scope: strings.TrimSpace(v.Scope),
CreatedAt: strings.TrimSpace(v.CreatedAt),
SizeBytes: sizeBytes,
RefCount: refCount,
Labels: cloneStringMap(v.Labels),
Options: cloneStringMap(v.Options),
})
}
return volumes, nil
}
func (a *Agent) collectNetworks(ctx context.Context) ([]agentsdocker.Network, error) {
list, err := dockerCallWithRetry(ctx, dockerInventoryCallTimeout, func(callCtx context.Context) ([]networktypes.Summary, error) {
return a.docker.NetworkList(callCtx, dockerNetworkListOptions{})
})
if err != nil {
return nil, annotateDockerConnectionError(err)
}
networks := make([]agentsdocker.Network, 0, len(list))
for _, n := range list {
subnets := make([]agentsdocker.NetworkSubnet, 0, len(n.IPAM.Config))
for _, config := range n.IPAM.Config {
subnet := ""
if config.Subnet.IsValid() {
subnet = config.Subnet.String()
}
gateway := ""
if config.Gateway.IsValid() {
gateway = config.Gateway.String()
}
subnets = append(subnets, agentsdocker.NetworkSubnet{
Subnet: subnet,
Gateway: gateway,
})
}
networks = append(networks, agentsdocker.Network{
ID: strings.TrimSpace(n.ID),
Name: strings.TrimSpace(n.Name),
Driver: strings.TrimSpace(n.Driver),
Scope: strings.TrimSpace(n.Scope),
CreatedAt: n.Created,
EnableIPv4: n.EnableIPv4,
EnableIPv6: n.EnableIPv6,
Internal: n.Internal,
Attachable: n.Attachable,
Ingress: n.Ingress,
ConfigOnly: n.ConfigOnly,
Subnets: subnets,
Labels: cloneStringMap(n.Labels),
Options: cloneStringMap(n.Options),
})
}
return networks, nil
}
func (a *Agent) collectStorageUsage(ctx context.Context) (client.DiskUsageResult, *agentsdocker.StorageUsage, error) {
result, err := dockerCallWithRetry(ctx, dockerInventoryCallTimeout, func(callCtx context.Context) (client.DiskUsageResult, error) {
return a.docker.DiskUsage(callCtx, dockerDiskUsageOptions{
Containers: true,
Images: true,
Volumes: true,
BuildCache: true,
Verbose: true,
})
})
if err != nil {
return client.DiskUsageResult{}, nil, annotateDockerConnectionError(err)
}
return result, &agentsdocker.StorageUsage{
Images: agentsdocker.StorageUsageBucket{
TotalCount: result.Images.TotalCount,
ActiveCount: result.Images.ActiveCount,
TotalSizeBytes: result.Images.TotalSize,
ReclaimableBytes: result.Images.Reclaimable,
},
Containers: agentsdocker.StorageUsageBucket{
TotalCount: result.Containers.TotalCount,
ActiveCount: result.Containers.ActiveCount,
TotalSizeBytes: result.Containers.TotalSize,
ReclaimableBytes: result.Containers.Reclaimable,
},
Volumes: agentsdocker.StorageUsageBucket{
TotalCount: result.Volumes.TotalCount,
ActiveCount: result.Volumes.ActiveCount,
TotalSizeBytes: result.Volumes.TotalSize,
ReclaimableBytes: result.Volumes.Reclaimable,
},
BuildCache: agentsdocker.StorageUsageBucket{
TotalCount: result.BuildCache.TotalCount,
ActiveCount: result.BuildCache.ActiveCount,
TotalSizeBytes: result.BuildCache.TotalSize,
ReclaimableBytes: result.BuildCache.Reclaimable,
},
}, nil
}
func dockerUnixTimestamp(seconds int64) time.Time {
if seconds <= 0 {
return time.Time{}
}
return time.Unix(seconds, 0).UTC()
}
func (a *Agent) pruneStaleCPUSamples(active map[string]struct{}) {
a.cpuMu.Lock()
defer a.cpuMu.Unlock()

View file

@ -9,6 +9,7 @@ import (
"github.com/moby/moby/api/types/network"
swarmtypes "github.com/moby/moby/api/types/swarm"
systemtypes "github.com/moby/moby/api/types/system"
"github.com/moby/moby/api/types/volume"
"github.com/moby/moby/client"
v1 "github.com/opencontainers/image-spec/specs-go/v1"
)
@ -91,6 +92,28 @@ type dockerServiceListOptions struct {
Status bool
}
type dockerImageListOptions struct {
All bool
SharedSize bool
Filters dockerFilters
}
type dockerVolumeListOptions struct {
Filters dockerFilters
}
type dockerNetworkListOptions struct {
Filters dockerFilters
}
type dockerDiskUsageOptions struct {
Containers bool
Images bool
BuildCache bool
Volumes bool
Verbose bool
}
type dockerTaskListOptions struct {
Filters dockerFilters
}
@ -109,6 +132,10 @@ type dockerClient interface {
NetworkConnect(ctx context.Context, networkID, containerID string, config *network.EndpointSettings) error
ContainerStart(ctx context.Context, containerID string, options dockerContainerStartOptions) error
ContainerRemove(ctx context.Context, containerID string, options dockerContainerRemoveOptions) error
ImageList(ctx context.Context, options dockerImageListOptions) ([]image.Summary, error)
VolumeList(ctx context.Context, options dockerVolumeListOptions) ([]volume.Volume, error)
NetworkList(ctx context.Context, options dockerNetworkListOptions) ([]network.Summary, error)
DiskUsage(ctx context.Context, options dockerDiskUsageOptions) (client.DiskUsageResult, error)
ServiceList(ctx context.Context, options dockerServiceListOptions) ([]swarmtypes.Service, error)
TaskList(ctx context.Context, options dockerTaskListOptions) ([]swarmtypes.Task, error)
ImageInspectWithRaw(ctx context.Context, imageID string) (image.InspectResponse, []byte, error)
@ -229,6 +256,44 @@ func (m *mobyDockerClient) ContainerRemove(ctx context.Context, containerID stri
return err
}
func (m *mobyDockerClient) ImageList(ctx context.Context, options dockerImageListOptions) ([]image.Summary, error) {
result, err := m.Client.ImageList(ctx, client.ImageListOptions{
All: options.All,
SharedSize: options.SharedSize,
Filters: options.Filters.toClientFilters(),
})
if err != nil {
return nil, err
}
return result.Items, nil
}
func (m *mobyDockerClient) VolumeList(ctx context.Context, options dockerVolumeListOptions) ([]volume.Volume, error) {
result, err := m.Client.VolumeList(ctx, client.VolumeListOptions{Filters: options.Filters.toClientFilters()})
if err != nil {
return nil, err
}
return result.Items, nil
}
func (m *mobyDockerClient) NetworkList(ctx context.Context, options dockerNetworkListOptions) ([]network.Summary, error) {
result, err := m.Client.NetworkList(ctx, client.NetworkListOptions{Filters: options.Filters.toClientFilters()})
if err != nil {
return nil, err
}
return result.Items, nil
}
func (m *mobyDockerClient) DiskUsage(ctx context.Context, options dockerDiskUsageOptions) (client.DiskUsageResult, error) {
return m.Client.DiskUsage(ctx, client.DiskUsageOptions{
Containers: options.Containers,
Images: options.Images,
BuildCache: options.BuildCache,
Volumes: options.Volumes,
Verbose: options.Verbose,
})
}
func (m *mobyDockerClient) ServiceList(ctx context.Context, options dockerServiceListOptions) ([]swarmtypes.Service, error) {
result, err := m.Client.ServiceList(ctx, client.ServiceListOptions{Status: options.Status})
if err != nil {

View file

@ -13,6 +13,7 @@ import (
const (
dockerInfoCallTimeout = 8 * time.Second
dockerContainerListCallTimeout = 20 * time.Second
dockerInventoryCallTimeout = 20 * time.Second
dockerSwarmListCallTimeout = 20 * time.Second
dockerCleanupCallTimeout = 15 * time.Second
dockerUpdateCallTimeout = 2 * time.Minute

View file

@ -13,6 +13,8 @@ import (
"github.com/moby/moby/api/types/network"
swarmtypes "github.com/moby/moby/api/types/swarm"
systemtypes "github.com/moby/moby/api/types/system"
"github.com/moby/moby/api/types/volume"
"github.com/moby/moby/client"
v1 "github.com/opencontainers/image-spec/specs-go/v1"
)
@ -30,6 +32,10 @@ type fakeDockerClient struct {
networkConnectFn func(ctx context.Context, netName, containerID string, endpoint *network.EndpointSettings) error
containerStartFn func(ctx context.Context, id string, opts dockerContainerStartOptions) error
containerRemoveFn func(ctx context.Context, id string, opts dockerContainerRemoveOptions) error
imageListFn func(ctx context.Context, opts dockerImageListOptions) ([]image.Summary, error)
volumeListFn func(ctx context.Context, opts dockerVolumeListOptions) ([]volume.Volume, error)
networkListFn func(ctx context.Context, opts dockerNetworkListOptions) ([]network.Summary, error)
diskUsageFn func(ctx context.Context, opts dockerDiskUsageOptions) (client.DiskUsageResult, error)
serviceListFn func(ctx context.Context, opts dockerServiceListOptions) ([]swarmtypes.Service, error)
taskListFn func(ctx context.Context, opts dockerTaskListOptions) ([]swarmtypes.Task, error)
imageInspectWithRawFn func(ctx context.Context, imageID string) (image.InspectResponse, []byte, error)
@ -124,6 +130,34 @@ func (f *fakeDockerClient) ContainerRemove(ctx context.Context, id string, opts
return f.containerRemoveFn(ctx, id, opts)
}
func (f *fakeDockerClient) ImageList(ctx context.Context, opts dockerImageListOptions) ([]image.Summary, error) {
if f.imageListFn == nil {
return nil, nil
}
return f.imageListFn(ctx, opts)
}
func (f *fakeDockerClient) VolumeList(ctx context.Context, opts dockerVolumeListOptions) ([]volume.Volume, error) {
if f.volumeListFn == nil {
return nil, nil
}
return f.volumeListFn(ctx, opts)
}
func (f *fakeDockerClient) NetworkList(ctx context.Context, opts dockerNetworkListOptions) ([]network.Summary, error) {
if f.networkListFn == nil {
return nil, nil
}
return f.networkListFn(ctx, opts)
}
func (f *fakeDockerClient) DiskUsage(ctx context.Context, opts dockerDiskUsageOptions) (client.DiskUsageResult, error) {
if f.diskUsageFn == nil {
return client.DiskUsageResult{}, nil
}
return f.diskUsageFn(ctx, opts)
}
func (f *fakeDockerClient) ServiceList(ctx context.Context, opts dockerServiceListOptions) ([]swarmtypes.Service, error) {
if f.serviceListFn == nil {
return nil, errors.New("unexpected ServiceList call")