diff --git a/internal/monitoring/harness_integration.go b/internal/monitoring/harness_integration.go index 80935a6..488739f 100644 --- a/internal/monitoring/harness_integration.go +++ b/internal/monitoring/harness_integration.go @@ -414,8 +414,8 @@ func (noopPVEClient) GetContainerStatus(ctx context.Context, node string, vmid i func (noopPVEClient) GetContainerConfig(ctx context.Context, node string, vmid int) (map[string]interface{}, error) { return nil, nil } -func (noopPVEClient) ExecContainerCommand(ctx context.Context, node string, vmid int, command []string) (string, error) { - return "", nil +func (noopPVEClient) GetContainerInterfaces(ctx context.Context, node string, vmid int) ([]proxmox.ContainerInterface, 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 ee2363f..78b4f62 100644 --- a/internal/monitoring/monitor.go +++ b/internal/monitoring/monitor.go @@ -56,7 +56,7 @@ type PVEClientInterface interface { 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) - ExecContainerCommand(ctx context.Context, node string, vmid int, command []string) (string, error) + GetContainerInterfaces(ctx context.Context, node string, vmid int) ([]proxmox.ContainerInterface, 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) @@ -468,8 +468,6 @@ type Monitor struct { dockerCommandIndex map[string]string guestMetadataMu sync.RWMutex guestMetadataCache map[string]guestMetadataCacheEntry - containerIPCacheMu sync.RWMutex - containerIPCache map[string]containerIPCacheEntry executor PollExecutor breakerBaseRetry time.Duration breakerMaxDelay time.Duration @@ -486,13 +484,6 @@ type rrdMemCacheEntry struct { fetchedAt time.Time } -const containerIPCacheTTL = 90 * time.Second - -type containerIPCacheEntry struct { - addresses []string - fetchedAt time.Time -} - // safePercentage calculates percentage safely, returning 0 if divisor is 0 func safePercentage(used, total float64) float64 { if total == 0 { @@ -1969,13 +1960,59 @@ func (m *Monitor) enrichContainerMetadata(ctx context.Context, client PVEClientI } if len(addressOrder) == 0 { - dynamicIPs := m.lookupDynamicContainerIPs(ctx, client, instanceName, nodeName, container.VMID) - if len(dynamicIPs) > 0 { - for _, addr := range dynamicIPs { - addAddress(addr) - } - if len(networkIfaces) == 1 && len(networkIfaces[0].Addresses) == 0 { - networkIfaces[0].Addresses = dedupeStringsPreserveOrder(dynamicIPs) + interfacesCtx, cancelInterfaces := context.WithTimeout(ctx, 5*time.Second) + ifaceDetails, ifaceErr := client.GetContainerInterfaces(interfacesCtx, nodeName, container.VMID) + cancelInterfaces() + if ifaceErr != nil { + log.Debug(). + Err(ifaceErr). + Str("instance", instanceName). + Str("node", nodeName). + Str("container", container.Name). + Int("vmid", container.VMID). + Msg("Container interface metadata unavailable") + } else if len(ifaceDetails) > 0 { + for _, detail := range ifaceDetails { + parsed := containerNetworkDetails{} + parsed.Name = strings.TrimSpace(detail.Name) + parsed.MAC = strings.ToUpper(strings.TrimSpace(detail.HWAddr)) + + for _, addr := range detail.IPAddresses { + stripped := strings.TrimSpace(addr.Address) + if stripped == "" { + continue + } + if slash := strings.Index(stripped, "/"); slash > 0 { + stripped = stripped[:slash] + } + parsed.Addresses = append(parsed.Addresses, sanitizeGuestAddressStrings(stripped)...) + } + + if len(parsed.Addresses) == 0 && strings.TrimSpace(detail.Inet) != "" { + parts := strings.Fields(detail.Inet) + for _, part := range parts { + stripped := strings.TrimSpace(part) + if stripped == "" { + continue + } + if slash := strings.Index(stripped, "/"); slash > 0 { + stripped = stripped[:slash] + } + parsed.Addresses = append(parsed.Addresses, sanitizeGuestAddressStrings(stripped)...) + } + } + + parsed.Addresses = dedupeStringsPreserveOrder(parsed.Addresses) + + if len(parsed.Addresses) > 0 { + for _, addr := range parsed.Addresses { + addAddress(addr) + } + } + + if parsed.Name != "" || parsed.MAC != "" || len(parsed.Addresses) > 0 { + mergeContainerNetworkInterface(&networkIfaces, parsed) + } } } } @@ -2353,114 +2390,6 @@ func extractContainerRootDeviceFromConfig(config map[string]interface{}) string return device } -func (m *Monitor) lookupDynamicContainerIPs(ctx context.Context, client PVEClientInterface, instanceName, nodeName string, vmid int) []string { - if m == nil || client == nil { - return nil - } - - cacheKey := fmt.Sprintf("%s|%s|%d", instanceName, nodeName, vmid) - now := time.Now() - - m.containerIPCacheMu.RLock() - if entry, ok := m.containerIPCache[cacheKey]; ok && now.Sub(entry.fetchedAt) < containerIPCacheTTL { - addresses := cloneStringSlice(entry.addresses) - m.containerIPCacheMu.RUnlock() - return addresses - } - m.containerIPCacheMu.RUnlock() - - execCtx, cancel := context.WithTimeout(ctx, 3*time.Second) - output, err := client.ExecContainerCommand(execCtx, nodeName, vmid, []string{"ip", "-j", "addr", "show", "scope", "global"}) - cancel() - if err != nil { - log.Debug(). - Err(err). - Str("instance", instanceName). - Str("node", nodeName). - Int("vmid", vmid). - Msg("Failed to execute container IP command") - } - - addresses := parseContainerIPsFromJSON(output) - if len(addresses) == 0 { - fallbackCtx, fallbackCancel := context.WithTimeout(ctx, 3*time.Second) - fallbackOutput, fallbackErr := client.ExecContainerCommand(fallbackCtx, nodeName, vmid, []string{"hostname", "-I"}) - fallbackCancel() - if fallbackErr != nil { - log.Debug(). - Err(fallbackErr). - Str("instance", instanceName). - Str("node", nodeName). - Int("vmid", vmid). - Msg("Failed to execute hostname -I for container") - } else { - addresses = parseContainerIPsFromWhitespaceList(fallbackOutput) - } - } - - addresses = dedupeStringsPreserveOrder(addresses) - - m.containerIPCacheMu.Lock() - m.containerIPCache[cacheKey] = containerIPCacheEntry{ - addresses: cloneStringSlice(addresses), - fetchedAt: time.Now(), - } - m.containerIPCacheMu.Unlock() - - return addresses -} - -func parseContainerIPsFromJSON(output string) []string { - output = strings.TrimSpace(output) - if output == "" { - return nil - } - - type addrInfo struct { - Local string `json:"local"` - } - type iface struct { - Name string `json:"ifname"` - AddrInfo []addrInfo `json:"addr_info"` - } - - var interfaces []iface - if err := json.Unmarshal([]byte(output), &interfaces); err != nil { - return nil - } - - var addresses []string - for _, network := range interfaces { - for _, info := range network.AddrInfo { - sanitized := sanitizeGuestAddressStrings(info.Local) - if len(sanitized) > 0 { - addresses = append(addresses, sanitized...) - } - } - } - - return addresses -} - -func parseContainerIPsFromWhitespaceList(output string) []string { - output = strings.TrimSpace(output) - if output == "" { - return nil - } - - fields := strings.Fields(output) - if len(fields) == 0 { - return nil - } - - var addresses []string - for _, field := range fields { - addresses = append(addresses, sanitizeGuestAddressStrings(field)...) - } - - return addresses -} - // GetConnectionStatuses returns the current connection status for all nodes func (m *Monitor) GetConnectionStatuses() map[string]bool { if mock.IsMockEnabled() { @@ -2661,7 +2590,6 @@ func New(cfg *config.Config) (*Monitor, error) { dockerCommands: make(map[string]*dockerHostCommand), dockerCommandIndex: make(map[string]string), guestMetadataCache: make(map[string]guestMetadataCacheEntry), - containerIPCache: make(map[string]containerIPCacheEntry), instanceInfoCache: make(map[string]*instanceInfo), pollStatusMap: make(map[string]*pollStatus), dlqInsightMap: make(map[string]*dlqInsight), diff --git a/internal/monitoring/monitor_memory_test.go b/internal/monitoring/monitor_memory_test.go index 0e0b08a..bcf5741 100644 --- a/internal/monitoring/monitor_memory_test.go +++ b/internal/monitoring/monitor_memory_test.go @@ -80,8 +80,8 @@ func (s *stubPVEClient) GetContainerStatus(ctx context.Context, node string, vmi func (s *stubPVEClient) GetContainerConfig(ctx context.Context, node string, vmid int) (map[string]interface{}, error) { return nil, nil } -func (s *stubPVEClient) ExecContainerCommand(ctx context.Context, node string, vmid int, command []string) (string, error) { - return "", nil +func (s *stubPVEClient) GetContainerInterfaces(ctx context.Context, node string, vmid int) ([]proxmox.ContainerInterface, error) { + return nil, nil } func (s *stubPVEClient) GetClusterResources(ctx context.Context, resourceType string) ([]proxmox.ClusterResource, error) { diff --git a/internal/monitoring/monitor_snapshots_test.go b/internal/monitoring/monitor_snapshots_test.go index 07542bd..d82473e 100644 --- a/internal/monitoring/monitor_snapshots_test.go +++ b/internal/monitoring/monitor_snapshots_test.go @@ -60,8 +60,8 @@ func (f fakeSnapshotClient) GetContainerStatus(ctx context.Context, node string, func (f fakeSnapshotClient) GetContainerConfig(ctx context.Context, node string, vmid int) (map[string]interface{}, error) { return nil, nil } -func (f fakeSnapshotClient) ExecContainerCommand(ctx context.Context, node string, vmid int, command []string) (string, error) { - return "", nil +func (f fakeSnapshotClient) GetContainerInterfaces(ctx context.Context, node string, vmid int) ([]proxmox.ContainerInterface, 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 94d088f..ba7c84c 100644 --- a/internal/monitoring/monitor_storage_test.go +++ b/internal/monitoring/monitor_storage_test.go @@ -80,8 +80,8 @@ func (f *fakeStorageClient) GetContainerStatus(ctx context.Context, node string, func (f *fakeStorageClient) GetContainerConfig(ctx context.Context, node string, vmid int) (map[string]interface{}, error) { return nil, nil } -func (f *fakeStorageClient) ExecContainerCommand(ctx context.Context, node string, vmid int, command []string) (string, error) { - return "", nil +func (f *fakeStorageClient) GetContainerInterfaces(ctx context.Context, node string, vmid int) ([]proxmox.ContainerInterface, error) { + return nil, nil } func (f *fakeStorageClient) GetClusterResources(ctx context.Context, resourceType string) ([]proxmox.ClusterResource, error) { diff --git a/pkg/proxmox/client.go b/pkg/proxmox/client.go index 3cd2d1b..0dfcb70 100644 --- a/pkg/proxmox/client.go +++ b/pkg/proxmox/client.go @@ -785,6 +785,21 @@ type ContainerDiskUsage struct { Used uint64 `json:"used,omitempty"` } +// ContainerInterfaceAddress describes an IP entry associated with a container interface. +type ContainerInterfaceAddress struct { + Address string `json:"ip-address"` + Type string `json:"ip-address-type"` + Prefix string `json:"prefix"` +} + +// ContainerInterface describes a container network interface returned by Proxmox. +type ContainerInterface struct { + Name string `json:"name"` + HWAddr string `json:"hwaddr"` + Inet string `json:"inet,omitempty"` + IPAddresses []ContainerInterfaceAddress `json:"ip-addresses,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)) @@ -1095,59 +1110,28 @@ func (c *Client) getTaskLog(ctx context.Context, node, upid string) ([]string, e return lines, nil } -// ExecContainerCommand executes a command within an LXC container and returns the stdout output. -func (c *Client) ExecContainerCommand(ctx context.Context, node string, vmid int, command []string) (string, error) { - if len(command) == 0 { - return "", fmt.Errorf("command is required") - } - - params := url.Values{} - params.Set("command", command[0]) - for _, arg := range command[1:] { - params.Add("extra-args", arg) - } - - resp, err := c.post(ctx, fmt.Sprintf("/nodes/%s/lxc/%d/exec", node, vmid), params) +// GetContainerInterfaces returns the network interfaces (with IPs) for a container. +func (c *Client) GetContainerInterfaces(ctx context.Context, node string, vmid int) ([]ContainerInterface, error) { + resp, err := c.get(ctx, fmt.Sprintf("/nodes/%s/lxc/%d/interfaces", node, vmid)) if err != nil { - return "", err + return nil, err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(resp.Body) - return "", fmt.Errorf("failed to execute container command (status %d): %s", resp.StatusCode, strings.TrimSpace(string(body))) + return nil, fmt.Errorf("failed to get container interfaces (status %d): %s", resp.StatusCode, strings.TrimSpace(string(body))) } var result struct { - Data struct { - UPID string `json:"upid"` - } `json:"data"` + Data []ContainerInterface `json:"data"` } if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { - return "", err + return nil, err } - if result.Data.UPID == "" { - return "", fmt.Errorf("exec call did not return a UPID") - } - - status, err := c.waitForTaskCompletion(ctx, node, result.Data.UPID) - if err != nil { - return "", err - } - - if status != nil && status.ExitStatus != "" && !strings.EqualFold(status.ExitStatus, "ok") { - return "", fmt.Errorf("container command failed: %s", status.ExitStatus) - } - - lines, err := c.getTaskLog(ctx, node, result.Data.UPID) - if err != nil { - return "", err - } - - output := strings.Join(lines, "\n") - return strings.TrimSpace(output), nil + return result.Data, nil } // GetStorageContent returns the content of a specific storage diff --git a/pkg/proxmox/cluster_client.go b/pkg/proxmox/cluster_client.go index d873281..588b967 100644 --- a/pkg/proxmox/cluster_client.go +++ b/pkg/proxmox/cluster_client.go @@ -1017,18 +1017,18 @@ func (cc *ClusterClient) GetContainerConfig(ctx context.Context, node string, vm return result, err } -// ExecContainerCommand executes a command within an LXC container using cluster failover -func (cc *ClusterClient) ExecContainerCommand(ctx context.Context, node string, vmid int, command []string) (string, error) { - var output string +// GetContainerInterfaces returns interface details for a container +func (cc *ClusterClient) GetContainerInterfaces(ctx context.Context, node string, vmid int) ([]ContainerInterface, error) { + var result []ContainerInterface err := cc.executeWithFailover(ctx, func(client *Client) error { - result, err := client.ExecContainerCommand(ctx, node, vmid, command) + interfaces, err := client.GetContainerInterfaces(ctx, node, vmid) if err != nil { return err } - output = result + result = interfaces return nil }) - return output, err + return result, err } // IsClusterMember checks if this node is part of a cluster