diff --git a/internal/monitoring/harness_integration.go b/internal/monitoring/harness_integration.go index acb860f..4cb4dc1 100644 --- a/internal/monitoring/harness_integration.go +++ b/internal/monitoring/harness_integration.go @@ -411,6 +411,9 @@ func (noopPVEClient) GetVMStatus(ctx context.Context, node string, vmid int) (*p func (noopPVEClient) GetContainerStatus(ctx context.Context, node string, vmid int) (*proxmox.Container, error) { return nil, nil } +func (noopPVEClient) GetContainerConfig(ctx context.Context, node string, vmid int) (map[string]interface{}, error) { + return nil, nil +} func (noopPVEClient) GetClusterResources(ctx context.Context, resourceType string) ([]proxmox.ClusterResource, error) { return nil, nil } diff --git a/internal/monitoring/monitor.go b/internal/monitoring/monitor.go index 49c19f2..97a9a55 100644 --- a/internal/monitoring/monitor.go +++ b/internal/monitoring/monitor.go @@ -55,6 +55,7 @@ type PVEClientInterface interface { GetContainerSnapshots(ctx context.Context, node string, vmid int) ([]proxmox.Snapshot, error) GetVMStatus(ctx context.Context, node string, vmid int) (*proxmox.VMStatus, error) GetContainerStatus(ctx context.Context, node string, vmid int) (*proxmox.Container, error) + GetContainerConfig(ctx context.Context, node string, vmid int) (map[string]interface{}, error) GetClusterResources(ctx context.Context, resourceType string) ([]proxmox.ClusterResource, error) IsClusterMember(ctx context.Context) (bool, error) GetVMFSInfo(ctx context.Context, node string, vmid int) ([]proxmox.VMFileSystem, error) @@ -1864,6 +1865,7 @@ func (m *Monitor) enrichContainerMetadata(ctx context.Context, client PVEClientI return } + rootDeviceHint := "" addressSet := make(map[string]struct{}) addressOrder := make([]string, 0, 4) @@ -1931,6 +1933,31 @@ func (m *Monitor) enrichContainerMetadata(ctx context.Context, client PVEClientI } } + configCtx, cancelConfig := context.WithTimeout(ctx, 5*time.Second) + configData, configErr := client.GetContainerConfig(configCtx, nodeName, container.VMID) + cancelConfig() + if configErr != nil { + log.Debug(). + Err(configErr). + Str("instance", instanceName). + Str("node", nodeName). + Str("container", container.Name). + Int("vmid", container.VMID). + Msg("Container config metadata unavailable") + } else if len(configData) > 0 { + if hint := extractContainerRootDeviceFromConfig(configData); hint != "" { + rootDeviceHint = hint + } + for _, detail := range parseContainerConfigNetworks(configData) { + if len(detail.Addresses) > 0 { + for _, addr := range detail.Addresses { + addAddress(addr) + } + } + mergeContainerNetworkInterface(&networkIfaces, detail) + } + } + if len(networkIfaces) > 1 { sort.SliceStable(networkIfaces, func(i, j int) bool { left := strings.TrimSpace(networkIfaces[i].Name) @@ -1956,6 +1983,14 @@ func (m *Monitor) enrichContainerMetadata(ctx context.Context, client PVEClientI } ensureContainerRootDiskEntry(container) + + if rootDeviceHint != "" && len(container.Disks) > 0 { + for i := range container.Disks { + if container.Disks[i].Mountpoint == "/" && container.Disks[i].Device == "" { + container.Disks[i].Device = rootDeviceHint + } + } + } } func ensureContainerRootDiskEntry(container *models.Container) { @@ -2165,6 +2200,137 @@ func dedupeStringsPreserveOrder(values []string) []string { return result } +type containerNetworkDetails struct { + Name string + MAC string + Addresses []string +} + +func parseContainerConfigNetworks(config map[string]interface{}) []containerNetworkDetails { + if len(config) == 0 { + return nil + } + + keys := make([]string, 0, len(config)) + for key := range config { + if strings.HasPrefix(strings.ToLower(strings.TrimSpace(key)), "net") { + keys = append(keys, key) + } + } + if len(keys) == 0 { + return nil + } + sort.Strings(keys) + + results := make([]containerNetworkDetails, 0, len(keys)) + for _, key := range keys { + raw := fmt.Sprint(config[key]) + raw = strings.TrimSpace(raw) + if raw == "" { + continue + } + + detail := containerNetworkDetails{} + parts := strings.Split(raw, ",") + for _, part := range parts { + kv := strings.SplitN(strings.TrimSpace(part), "=", 2) + if len(kv) != 2 { + continue + } + k := strings.ToLower(strings.TrimSpace(kv[0])) + value := strings.TrimSpace(kv[1]) + switch k { + case "name": + detail.Name = value + case "hwaddr", "mac", "macaddr": + detail.MAC = strings.ToUpper(value) + case "ip", "ip6", "ips", "ip6addr", "ip6prefix": + detail.Addresses = append(detail.Addresses, sanitizeGuestAddressStrings(value)...) + } + } + + if detail.Name == "" { + detail.Name = strings.TrimSpace(key) + } + if len(detail.Addresses) > 0 { + detail.Addresses = dedupeStringsPreserveOrder(detail.Addresses) + } + + if detail.Name != "" || detail.MAC != "" || len(detail.Addresses) > 0 { + results = append(results, detail) + } + } + + if len(results) == 0 { + return nil + } + + return results +} + +func mergeContainerNetworkInterface(target *[]models.GuestNetworkInterface, detail containerNetworkDetails) { + if target == nil { + return + } + if len(detail.Addresses) > 0 { + detail.Addresses = dedupeStringsPreserveOrder(detail.Addresses) + } + + findMatch := func() int { + for i := range *target { + if detail.Name != "" && (*target)[i].Name != "" && strings.EqualFold((*target)[i].Name, detail.Name) { + return i + } + if detail.MAC != "" && (*target)[i].MAC != "" && strings.EqualFold((*target)[i].MAC, detail.MAC) { + return i + } + } + return -1 + } + + if idx := findMatch(); idx >= 0 { + if detail.Name != "" && (*target)[idx].Name == "" { + (*target)[idx].Name = detail.Name + } + if detail.MAC != "" && (*target)[idx].MAC == "" { + (*target)[idx].MAC = detail.MAC + } + if len(detail.Addresses) > 0 { + combined := append((*target)[idx].Addresses, detail.Addresses...) + (*target)[idx].Addresses = dedupeStringsPreserveOrder(combined) + } + return + } + + newIface := models.GuestNetworkInterface{ + Name: detail.Name, + MAC: detail.MAC, + } + if len(detail.Addresses) > 0 { + newIface.Addresses = dedupeStringsPreserveOrder(detail.Addresses) + } + *target = append(*target, newIface) +} + +func extractContainerRootDeviceFromConfig(config map[string]interface{}) string { + if len(config) == 0 { + return "" + } + raw, ok := config["rootfs"] + if !ok { + return "" + } + + value := strings.TrimSpace(fmt.Sprint(raw)) + if value == "" { + return "" + } + + parts := strings.Split(value, ",") + device := strings.TrimSpace(parts[0]) + return device +} + // GetConnectionStatuses returns the current connection status for all nodes func (m *Monitor) GetConnectionStatuses() map[string]bool { if mock.IsMockEnabled() { diff --git a/internal/monitoring/monitor_memory_test.go b/internal/monitoring/monitor_memory_test.go index c408418..e7d8d33 100644 --- a/internal/monitoring/monitor_memory_test.go +++ b/internal/monitoring/monitor_memory_test.go @@ -77,6 +77,10 @@ func (s *stubPVEClient) GetContainerStatus(ctx context.Context, node string, vmi return nil, nil } +func (s *stubPVEClient) GetContainerConfig(ctx context.Context, node string, vmid int) (map[string]interface{}, error) { + return nil, nil +} + func (s *stubPVEClient) GetClusterResources(ctx context.Context, resourceType string) ([]proxmox.ClusterResource, error) { return nil, nil } diff --git a/internal/monitoring/monitor_snapshots_test.go b/internal/monitoring/monitor_snapshots_test.go index 78fe69d..3949cd4 100644 --- a/internal/monitoring/monitor_snapshots_test.go +++ b/internal/monitoring/monitor_snapshots_test.go @@ -56,6 +56,10 @@ func (f fakeSnapshotClient) GetVMStatus(ctx context.Context, node string, vmid i func (f fakeSnapshotClient) GetContainerStatus(ctx context.Context, node string, vmid int) (*proxmox.Container, error) { return nil, nil } + +func (f fakeSnapshotClient) GetContainerConfig(ctx context.Context, node string, vmid int) (map[string]interface{}, error) { + return nil, nil +} func (f fakeSnapshotClient) GetClusterResources(ctx context.Context, resourceType string) ([]proxmox.ClusterResource, error) { return nil, nil } diff --git a/internal/monitoring/monitor_storage_test.go b/internal/monitoring/monitor_storage_test.go index 7b4d701..4ea1c8d 100644 --- a/internal/monitoring/monitor_storage_test.go +++ b/internal/monitoring/monitor_storage_test.go @@ -77,6 +77,10 @@ func (f *fakeStorageClient) GetContainerStatus(ctx context.Context, node string, return nil, nil } +func (f *fakeStorageClient) GetContainerConfig(ctx context.Context, node string, vmid int) (map[string]interface{}, error) { + return nil, nil +} + func (f *fakeStorageClient) GetClusterResources(ctx context.Context, resourceType string) ([]proxmox.ClusterResource, error) { return nil, nil } diff --git a/pkg/proxmox/client.go b/pkg/proxmox/client.go index 5267d2c..8e06d89 100644 --- a/pkg/proxmox/client.go +++ b/pkg/proxmox/client.go @@ -785,6 +785,29 @@ type ContainerDiskUsage struct { Used uint64 `json:"used,omitempty"` } +// GetContainerConfig returns the configuration of a specific container +func (c *Client) GetContainerConfig(ctx context.Context, node string, vmid int) (map[string]interface{}, error) { + resp, err := c.get(ctx, fmt.Sprintf("/nodes/%s/lxc/%d/config", node, vmid)) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + var result struct { + Data map[string]interface{} `json:"data"` + } + + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, err + } + + if result.Data == nil { + result.Data = make(map[string]interface{}) + } + + return result.Data, nil +} + // Storage represents a Proxmox VE storage type Storage struct { Storage string `json:"storage"` diff --git a/pkg/proxmox/cluster_client.go b/pkg/proxmox/cluster_client.go index 0d6dae0..983f0b0 100644 --- a/pkg/proxmox/cluster_client.go +++ b/pkg/proxmox/cluster_client.go @@ -1003,6 +1003,20 @@ func (cc *ClusterClient) GetContainerStatus(ctx context.Context, node string, vm return result, err } +// GetContainerConfig returns the configuration of a specific container +func (cc *ClusterClient) GetContainerConfig(ctx context.Context, node string, vmid int) (map[string]interface{}, error) { + var result map[string]interface{} + err := cc.executeWithFailover(ctx, func(client *Client) error { + config, err := client.GetContainerConfig(ctx, node, vmid) + if err != nil { + return err + } + result = config + return nil + }) + return result, err +} + // IsClusterMember checks if this node is part of a cluster func (cc *ClusterClient) IsClusterMember(ctx context.Context) (bool, error) { var result bool