Surface LXC interface IPs via PVE interfaces API (#596)

This commit is contained in:
rcourtman 2025-10-23 08:07:32 +00:00
parent b95c01066e
commit a885fb5472
7 changed files with 91 additions and 179 deletions

View file

@ -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

View file

@ -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),

View file

@ -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) {

View file

@ -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

View file

@ -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) {

View file

@ -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

View file

@ -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