fix: Robust OCI container detection with state persistence

Backend:
- Seed OCI classification from previous state so containers never
  'downgrade' to LXC if config fetching intermittently fails
- Prevent type regression in recordGuestSnapshot when OCI was previously detected
- Move metrics zeroing before snapshot recording for cleaner flow

Frontend:
- Add isOCIContainer() memo that checks both type and isOci flag
- Use isOCI helper in Dashboard.tsx for AI context building
- Include oci-container type in useResources container conversion
- Preserve isOci and osTemplate fields through legacy conversion

This ensures OCI containers retain their classification even when
Proxmox API permissions or transient errors prevent config reads.
This commit is contained in:
rcourtman 2025-12-12 20:06:39 +00:00
parent a20760f527
commit bf43d448cf
14 changed files with 371 additions and 133 deletions

View file

@ -908,6 +908,7 @@ export function Dashboard(props: DashboardProps) {
const guestId = guest.id || `${guest.instance}-${guest.vmid}`; const guestId = guest.id || `${guest.instance}-${guest.vmid}`;
const guestType = guest.type === 'qemu' ? 'vm' : 'container'; const guestType = guest.type === 'qemu' ? 'vm' : 'container';
const isOCI = guest.type === 'oci' || ('isOci' in guest && guest.isOci === true);
// Toggle: remove if already in context, add if not // Toggle: remove if already in context, add if not
if (aiChatStore.hasContextItem(guestId)) { if (aiChatStore.hasContextItem(guestId)) {
@ -918,14 +919,14 @@ export function Dashboard(props: DashboardProps) {
const contextData: Record<string, unknown> = { const contextData: Record<string, unknown> = {
guestName: guest.name, guestName: guest.name,
name: guest.name, name: guest.name,
type: guest.type === 'qemu' ? 'Virtual Machine' : (guest.type === 'oci' ? 'OCI Container' : 'LXC Container'), type: guest.type === 'qemu' ? 'Virtual Machine' : (isOCI ? 'OCI Container' : 'LXC Container'),
vmid: guest.vmid, vmid: guest.vmid,
node: guest.node, node: guest.node,
status: guest.status, status: guest.status,
}; };
// Add OCI image info if available // Add OCI image info if available
if (guest.type === 'oci' && 'osTemplate' in guest && guest.osTemplate) { if (isOCI && 'osTemplate' in guest && guest.osTemplate) {
contextData.ociImage = guest.osTemplate; contextData.ociImage = guest.osTemplate;
} }

View file

@ -571,9 +571,15 @@ export function GuestRow(props: GuestRowProps) {
const agentVersion = createMemo(() => props.guest.agentVersion?.trim() ?? ''); const agentVersion = createMemo(() => props.guest.agentVersion?.trim() ?? '');
const hasOsInfo = createMemo(() => osName().length > 0 || osVersion().length > 0); const hasOsInfo = createMemo(() => osName().length > 0 || osVersion().length > 0);
const isOCIContainer = createMemo(() => {
if (isVM(props.guest)) return false;
const container = props.guest as Container;
return props.guest.type === 'oci' || container.isOci === true;
});
// OCI image info - extract clean image name from osTemplate (similar to Docker container image display) // OCI image info - extract clean image name from osTemplate (similar to Docker container image display)
const ociImage = createMemo(() => { const ociImage = createMemo(() => {
if (props.guest.type !== 'oci') return null; if (!isOCIContainer()) return null;
const template = (props.guest as Container).osTemplate; const template = (props.guest as Container).osTemplate;
if (!template) return null; if (!template) return null;
// Strip common prefixes to get clean image reference // Strip common prefixes to get clean image reference
@ -903,19 +909,19 @@ export function GuestRow(props: GuestRowProps) {
<span <span
class={`inline-block px-1 py-0.5 text-[10px] font-medium rounded whitespace-nowrap ${props.guest.type === 'qemu' class={`inline-block px-1 py-0.5 text-[10px] font-medium rounded whitespace-nowrap ${props.guest.type === 'qemu'
? 'bg-blue-100 text-blue-700 dark:bg-blue-900/50 dark:text-blue-300' ? 'bg-blue-100 text-blue-700 dark:bg-blue-900/50 dark:text-blue-300'
: props.guest.type === 'oci' : isOCIContainer()
? 'bg-purple-100 text-purple-700 dark:bg-purple-900/50 dark:text-purple-300' ? 'bg-purple-100 text-purple-700 dark:bg-purple-900/50 dark:text-purple-300'
: 'bg-green-100 text-green-700 dark:bg-green-900/50 dark:text-green-300' : 'bg-green-100 text-green-700 dark:bg-green-900/50 dark:text-green-300'
}`} }`}
title={ title={
isVM(props.guest) isVM(props.guest)
? 'Virtual Machine' ? 'Virtual Machine'
: props.guest.type === 'oci' : isOCIContainer()
? `OCI Container${ociImage() ? `${ociImage()}` : ''}` ? `OCI Container${ociImage() ? `${ociImage()}` : ''}`
: 'LXC Container' : 'LXC Container'
} }
> >
{isVM(props.guest) ? 'VM' : props.guest.type === 'oci' ? 'OCI' : 'LXC'} {isVM(props.guest) ? 'VM' : isOCIContainer() ? 'OCI' : 'LXC'}
</span> </span>
</div> </div>
</td> </td>

View file

@ -379,15 +379,15 @@ describe('UnifiedAgents platform commands', () => {
const generateButton = screen.getByRole('button', { name: /Generate token/i }); const generateButton = screen.getByRole('button', { name: /Generate token/i });
fireEvent.click(generateButton); fireEvent.click(generateButton);
await waitFor(() => expect(createTokenMock).toHaveBeenCalled(), { interval: 0 }); await waitFor(() => expect(createTokenMock).toHaveBeenCalled(), { interval: 0 });
await waitFor(() => { await waitFor(() => {
expect(screen.getByText('Enable Docker monitoring')).toBeInTheDocument(); expect(screen.getByText('Docker monitoring')).toBeInTheDocument();
}); });
// Docker monitoring is disabled by default // Docker monitoring is disabled by default
const checkbox = screen.getByRole('checkbox', { name: /Enable Docker monitoring/i }); const checkbox = screen.getByRole('checkbox', { name: /Docker monitoring/i });
expect(checkbox).not.toBeChecked(); expect(checkbox).not.toBeChecked();
// Enable it // Enable it
fireEvent.click(checkbox); fireEvent.click(checkbox);

View file

@ -240,29 +240,62 @@ describe('useResourcesAsLegacy - Legacy Format Conversion', () => {
}); });
}); });
describe('Container conversion', () => { describe('Container conversion', () => {
it('converts Resource to legacy Container format', () => { it('converts container Resource to legacy Container format', () => {
const resource = createMockResource({ const resource = createMockResource({
type: 'container', type: 'container',
platformData: { platformData: {
vmid: 200, vmid: 200,
node: 'pve1', node: 'pve1',
}, },
}); });
const platformData = resource.platformData as Record<string, unknown>; const platformData = resource.platformData as Record<string, unknown>;
const legacyContainer = { const isOCI = resource.type === 'oci-container' || platformData?.isOci === true || platformData?.type === 'oci';
id: resource.id, const legacyContainer = {
vmid: platformData?.vmid as number, id: resource.id,
name: resource.name, vmid: platformData?.vmid as number,
type: 'lxc', name: resource.name,
status: resource.status === 'running' ? 'running' : 'stopped', type: isOCI ? 'oci' : ((platformData?.type as string) ?? 'lxc'),
}; isOci: isOCI,
osTemplate: platformData?.osTemplate as string | undefined,
status: resource.status === 'running' ? 'running' : 'stopped',
};
expect(legacyContainer.vmid).toBe(200); expect(legacyContainer.vmid).toBe(200);
expect(legacyContainer.type).toBe('lxc'); expect(legacyContainer.type).toBe('lxc');
}); });
});
it('converts oci-container Resource to legacy Container format', () => {
const resource = createMockResource({
type: 'oci-container',
platformData: {
vmid: 300,
node: 'pve1',
type: 'oci',
isOci: true,
osTemplate: 'oci:docker.io/library/alpine:latest',
},
});
const platformData = resource.platformData as Record<string, unknown>;
const isOCI = resource.type === 'oci-container' || platformData?.isOci === true || platformData?.type === 'oci';
const legacyContainer = {
id: resource.id,
vmid: platformData?.vmid as number,
name: resource.name,
type: isOCI ? 'oci' : ((platformData?.type as string) ?? 'lxc'),
isOci: isOCI,
osTemplate: platformData?.osTemplate as string | undefined,
status: resource.status === 'running' ? 'running' : 'stopped',
};
expect(legacyContainer.vmid).toBe(300);
expect(legacyContainer.type).toBe('oci');
expect(legacyContainer.isOci).toBe(true);
expect(legacyContainer.osTemplate).toBe('oci:docker.io/library/alpine:latest');
});
});
describe('Node conversion', () => { describe('Node conversion', () => {
it('converts Resource to legacy Node format with temperature', () => { it('converts Resource to legacy Node format with temperature', () => {

View file

@ -304,8 +304,16 @@ export function useResourcesAsLegacy() {
return wsStore.state.containers ?? []; return wsStore.state.containers ?? [];
} }
return byType('container').map(r => { // Include both traditional LXC containers and OCI containers (Proxmox VE 9.1+).
const containerResources = [...byType('container'), ...byType('oci-container')];
return containerResources.map(r => {
const platformData = r.platformData as Record<string, unknown> | undefined; const platformData = r.platformData as Record<string, unknown> | undefined;
const isOCI =
r.type === 'oci-container' ||
platformData?.isOci === true ||
platformData?.type === 'oci';
const legacyType = isOCI ? 'oci' : ((platformData?.type as string) ?? 'lxc');
return { return {
id: r.id, id: r.id,
vmid: platformData?.vmid as number ?? parseInt(r.id.split('-').pop() ?? '0', 10), vmid: platformData?.vmid as number ?? parseInt(r.id.split('-').pop() ?? '0', 10),
@ -313,7 +321,9 @@ export function useResourcesAsLegacy() {
node: platformData?.node as string ?? '', node: platformData?.node as string ?? '',
instance: platformData?.instance as string ?? r.platformId, instance: platformData?.instance as string ?? r.platformId,
status: r.status === 'running' ? 'running' : 'stopped', status: r.status === 'running' ? 'running' : 'stopped',
type: (platformData?.type as string) ?? 'lxc', // Preserve oci/lxc type from backend type: legacyType,
isOci: isOCI,
osTemplate: platformData?.osTemplate as string | undefined,
cpu: (r.cpu?.current ?? 0) / 100, // Convert from percentage to ratio for Dashboard cpu: (r.cpu?.current ?? 0) / 100, // Convert from percentage to ratio for Dashboard
cpus: platformData?.cpus as number ?? 1, cpus: platformData?.cpus as number ?? 1,
memory: r.memory ? { memory: r.memory ? {

View file

@ -157,6 +157,19 @@ func (c Container) ToFrontend() ContainerFrontend {
LastSeen: c.LastSeen.Unix() * 1000, LastSeen: c.LastSeen.Unix() * 1000,
} }
// OCI containers are classified separately from traditional LXC containers in the UI.
// Keep the frontend payload stable even if some internal sources only persist Type.
isOCI := c.IsOCI || strings.EqualFold(strings.TrimSpace(c.Type), "oci")
if isOCI {
ct.IsOCI = true
ct.Type = "oci"
}
// Preserve template metadata (LXC template or OCI image reference).
if c.OSTemplate != "" {
ct.OSTemplate = c.OSTemplate
}
// Convert tags array to string // Convert tags array to string
if len(c.Tags) > 0 { if len(c.Tags) > 0 {
ct.Tags = strings.Join(c.Tags, ",") ct.Tags = strings.Join(c.Tags, ",")

View file

@ -267,42 +267,99 @@ func TestVMToFrontend_NegativeNetworkValues(t *testing.T) {
func TestContainerToFrontend(t *testing.T) { func TestContainerToFrontend(t *testing.T) {
now := time.Now() now := time.Now()
container := Container{ t.Run("lxc", func(t *testing.T) {
ID: "ct-101", container := Container{
VMID: 101, ID: "ct-101",
Name: "test-ct", VMID: 101,
Node: "pve1", Name: "test-ct",
Status: "running", Node: "pve1",
Type: "lxc", Status: "running",
CPU: 0.10, Type: "lxc",
CPUs: 2, CPU: 0.10,
Memory: Memory{Total: 4000000000, Used: 2000000000}, CPUs: 2,
Disk: Disk{Total: 50000000000, Used: 25000000000}, Memory: Memory{Total: 4000000000, Used: 2000000000},
NetworkIn: 500000, Disk: Disk{Total: 50000000000, Used: 25000000000},
NetworkOut: 250000, NetworkIn: 500000,
Uptime: 7200, NetworkOut: 250000,
Tags: []string{"dev", "database"}, Uptime: 7200,
LastSeen: now, Tags: []string{"dev", "database"},
IPAddresses: []string{"192.168.1.51"}, LastSeen: now,
} IPAddresses: []string{"192.168.1.51"},
}
frontend := container.ToFrontend() frontend := container.ToFrontend()
if frontend.ID != container.ID { if frontend.ID != container.ID {
t.Errorf("ID = %q, want %q", frontend.ID, container.ID) t.Errorf("ID = %q, want %q", frontend.ID, container.ID)
} }
if frontend.VMID != container.VMID { if frontend.VMID != container.VMID {
t.Errorf("VMID = %d, want %d", frontend.VMID, container.VMID) t.Errorf("VMID = %d, want %d", frontend.VMID, container.VMID)
} }
if frontend.Type != container.Type { if frontend.Type != container.Type {
t.Errorf("Type = %q, want %q", frontend.Type, container.Type) t.Errorf("Type = %q, want %q", frontend.Type, container.Type)
} }
if frontend.Tags != "dev,database" { if frontend.IsOCI {
t.Errorf("Tags = %q, want 'dev,database'", frontend.Tags) t.Errorf("IsOCI = %v, want false", frontend.IsOCI)
} }
if frontend.Memory == nil { if frontend.Tags != "dev,database" {
t.Error("Memory should not be nil when Total > 0") t.Errorf("Tags = %q, want 'dev,database'", frontend.Tags)
} }
if frontend.Memory == nil {
t.Error("Memory should not be nil when Total > 0")
}
})
t.Run("oci", func(t *testing.T) {
container := Container{
ID: "ct-300",
VMID: 300,
Name: "oci-alpine",
Node: "pve1",
Status: "running",
Type: "lxc",
IsOCI: true,
OSTemplate: "oci:docker.io/library/alpine:latest",
CPU: 0.05,
CPUs: 2,
Memory: Memory{Total: 1000000000, Used: 100000000},
Disk: Disk{Total: 10000000000, Used: 1000000000},
LastSeen: now,
}
frontend := container.ToFrontend()
if frontend.Type != "oci" {
t.Errorf("Type = %q, want %q", frontend.Type, "oci")
}
if !frontend.IsOCI {
t.Errorf("IsOCI = %v, want true", frontend.IsOCI)
}
if frontend.OSTemplate != container.OSTemplate {
t.Errorf("OSTemplate = %q, want %q", frontend.OSTemplate, container.OSTemplate)
}
})
t.Run("oci-derived-from-type", func(t *testing.T) {
container := Container{
ID: "ct-301",
VMID: 301,
Name: "oci-from-type",
Node: "pve1",
Status: "running",
Type: "oci",
IsOCI: false,
LastSeen: now,
}
frontend := container.ToFrontend()
if frontend.Type != "oci" {
t.Errorf("Type = %q, want %q", frontend.Type, "oci")
}
if !frontend.IsOCI {
t.Errorf("IsOCI = %v, want true", frontend.IsOCI)
}
})
} }
func TestDockerContainerToFrontend(t *testing.T) { func TestDockerContainerToFrontend(t *testing.T) {

View file

@ -75,6 +75,9 @@ type ContainerFrontend struct {
Instance string `json:"instance"` Instance string `json:"instance"`
Status string `json:"status"` Status string `json:"status"`
Type string `json:"type"` Type string `json:"type"`
// OCI container support (Proxmox VE 9.1+)
IsOCI bool `json:"isOci,omitempty"` // True if this is an OCI container
OSTemplate string `json:"osTemplate,omitempty"` // Template or OCI image used (e.g., "oci:docker.io/library/alpine:latest")
CPU float64 `json:"cpu"` CPU float64 `json:"cpu"`
CPUs int `json:"cpus"` CPUs int `json:"cpus"`
Memory *Memory `json:"memory,omitempty"` // Full memory object Memory *Memory `json:"memory,omitempty"` // Full memory object

View file

@ -5517,6 +5517,22 @@ func (m *Monitor) pollVMsAndContainersEfficient(ctx context.Context, instanceNam
return false return false
} }
// Seed OCI classification from previous state so we never "downgrade" to LXC
// if container config fetching intermittently fails (permissions or transient API errors).
prevState := m.GetState()
prevContainerIsOCI := make(map[int]bool)
for _, ct := range prevState.Containers {
if ct.Instance != instanceName {
continue
}
if ct.VMID <= 0 {
continue
}
if ct.Type == "oci" || ct.IsOCI {
prevContainerIsOCI[ct.VMID] = true
}
}
var allVMs []models.VM var allVMs []models.VM
var allContainers []models.Container var allContainers []models.Container
@ -6160,6 +6176,11 @@ func (m *Monitor) pollVMsAndContainersEfficient(ctx context.Context, instanceNam
LastSeen: time.Now(), LastSeen: time.Now(),
} }
if prevContainerIsOCI[container.VMID] {
container.IsOCI = true
container.Type = "oci"
}
// Parse tags // Parse tags
if res.Tags != "" { if res.Tags != "" {
container.Tags = strings.Split(res.Tags, ";") container.Tags = strings.Split(res.Tags, ";")
@ -6179,6 +6200,25 @@ func (m *Monitor) pollVMsAndContainersEfficient(ctx context.Context, instanceNam
m.enrichContainerMetadata(ctx, client, instanceName, res.Node, &container) m.enrichContainerMetadata(ctx, client, instanceName, res.Node, &container)
// For non-running containers, zero out resource usage metrics to prevent false alerts.
// Proxmox may report stale or residual metrics for stopped containers.
if container.Status != "running" {
log.Debug().
Str("container", container.Name).
Str("status", container.Status).
Float64("originalCpu", container.CPU).
Float64("originalMemUsage", container.Memory.Usage).
Msg("Non-running container detected - zeroing metrics")
container.CPU = 0
container.Memory.Usage = 0
container.Disk.Usage = 0
container.NetworkIn = 0
container.NetworkOut = 0
container.DiskRead = 0
container.DiskWrite = 0
}
allContainers = append(allContainers, container) allContainers = append(allContainers, container)
m.recordGuestSnapshot(instanceName, container.Type, res.Node, res.VMID, GuestMemorySnapshot{ m.recordGuestSnapshot(instanceName, container.Type, res.Node, res.VMID, GuestMemorySnapshot{
@ -6190,26 +6230,6 @@ func (m *Monitor) pollVMsAndContainersEfficient(ctx context.Context, instanceNam
Raw: guestRaw, Raw: guestRaw,
}) })
// For non-running containers, zero out resource usage metrics to prevent false alerts
// Proxmox may report stale or residual metrics for stopped containers
if container.Status != "running" {
log.Debug().
Str("container", container.Name).
Str("status", container.Status).
Float64("originalCpu", container.CPU).
Float64("originalMemUsage", container.Memory.Usage).
Msg("Non-running container detected - zeroing metrics")
// Zero out all usage metrics for stopped/paused containers
container.CPU = 0
container.Memory.Usage = 0
container.Disk.Usage = 0
container.NetworkIn = 0
container.NetworkOut = 0
container.DiskRead = 0
container.DiskWrite = 0
}
// Check thresholds for alerts // Check thresholds for alerts
m.alertManager.CheckGuest(container, instanceName) m.alertManager.CheckGuest(container, instanceName)
} }
@ -6218,8 +6238,6 @@ func (m *Monitor) pollVMsAndContainersEfficient(ctx context.Context, instanceNam
// Preserve VMs and containers from nodes within grace period // Preserve VMs and containers from nodes within grace period
// The cluster/resources endpoint doesn't return VMs/containers from nodes Proxmox considers offline, // The cluster/resources endpoint doesn't return VMs/containers from nodes Proxmox considers offline,
// but we want to keep showing them if the node is within grace period // but we want to keep showing them if the node is within grace period
prevState := m.GetState()
// Count previous resources for this instance // Count previous resources for this instance
prevVMCount := 0 prevVMCount := 0
prevContainerCount := 0 prevContainerCount := 0
@ -8017,6 +8035,17 @@ func persistGuestIdentity(metadataStore *config.GuestMetadataStore, guestKey, na
} }
} }
guestType = strings.TrimSpace(guestType)
if guestType == "" {
return
}
// Never "downgrade" OCI containers back to LXC. OCI classification can be transiently
// unavailable if Proxmox config reads fail due to permissions or transient API errors.
if existing.LastKnownType == "oci" && guestType != "oci" {
guestType = existing.LastKnownType
}
// Only update if the name or type has changed // Only update if the name or type has changed
if existing.LastKnownName != name || existing.LastKnownType != guestType { if existing.LastKnownName != name || existing.LastKnownType != guestType {
existing.LastKnownName = name existing.LastKnownName = name

View file

@ -894,6 +894,22 @@ func (m *Monitor) pollContainersWithNodes(ctx context.Context, instanceName stri
} }
} }
// Seed OCI classification from previous state so we never "downgrade" to LXC
// if container config fetching intermittently fails (permissions or transient API errors).
prevState := m.GetState()
prevContainerIsOCI := make(map[int]bool)
for _, ct := range prevState.Containers {
if ct.Instance != instanceName {
continue
}
if ct.VMID <= 0 {
continue
}
if ct.Type == "oci" || ct.IsOCI {
prevContainerIsOCI[ct.VMID] = true
}
}
log.Info(). log.Info().
Str("instance", instanceName). Str("instance", instanceName).
Int("totalNodes", len(nodes)). Int("totalNodes", len(nodes)).
@ -1026,6 +1042,11 @@ func (m *Monitor) pollContainersWithNodes(ctx context.Context, instanceName stri
Tags: tags, Tags: tags,
} }
if prevContainerIsOCI[modelContainer.VMID] {
modelContainer.IsOCI = true
modelContainer.Type = "oci"
}
if override, ok := rootUsageOverrides[int(container.VMID)]; ok { if override, ok := rootUsageOverrides[int(container.VMID)]; ok {
overrideUsed := clampToInt64(override.Used) overrideUsed := clampToInt64(override.Used)
overrideTotal := clampToInt64(override.Total) overrideTotal := clampToInt64(override.Total)

View file

@ -219,11 +219,14 @@ func FromContainer(ct models.Container) Resource {
VMID: ct.VMID, VMID: ct.VMID,
Node: ct.Node, Node: ct.Node,
Instance: ct.Instance, Instance: ct.Instance,
Type: ct.Type,
CPUs: ct.CPUs, CPUs: ct.CPUs,
Template: ct.Template, Template: ct.Template,
Lock: ct.Lock, Lock: ct.Lock,
OSName: ct.OSName, OSName: ct.OSName,
IPAddresses: ct.IPAddresses, IPAddresses: ct.IPAddresses,
IsOCI: ct.IsOCI || strings.EqualFold(strings.TrimSpace(ct.Type), "oci"),
OSTemplate: ct.OSTemplate,
NetworkIn: ct.NetworkIn, NetworkIn: ct.NetworkIn,
NetworkOut: ct.NetworkOut, NetworkOut: ct.NetworkOut,
DiskRead: ct.DiskRead, DiskRead: ct.DiskRead,
@ -235,9 +238,14 @@ func FromContainer(ct models.Container) Resource {
// Parent is the node - format matches Node.ID: instance-nodename // Parent is the node - format matches Node.ID: instance-nodename
parentID := fmt.Sprintf("%s-%s", ct.Instance, ct.Node) parentID := fmt.Sprintf("%s-%s", ct.Instance, ct.Node)
resourceType := ResourceTypeContainer
if platformData.IsOCI {
resourceType = ResourceTypeOCIContainer
}
return Resource{ return Resource{
ID: ct.ID, ID: ct.ID,
Type: ResourceTypeContainer, Type: resourceType,
Name: ct.Name, Name: ct.Name,
PlatformID: ct.Instance, PlatformID: ct.Instance,
PlatformType: PlatformProxmoxPVE, PlatformType: PlatformProxmoxPVE,

View file

@ -199,47 +199,99 @@ func TestFromVM(t *testing.T) {
} }
func TestFromContainer(t *testing.T) { func TestFromContainer(t *testing.T) {
ct := models.Container{ now := time.Now()
ID: "pve1/lxc/101",
VMID: 101,
Name: "database",
Node: "node1",
Instance: "pve1",
Status: "stopped",
Type: "lxc",
CPU: 0.0,
CPUs: 2,
Memory: models.Memory{
Total: 4 * 1024 * 1024 * 1024,
Used: 0,
Free: 4 * 1024 * 1024 * 1024,
Usage: 0.0,
},
Uptime: 0,
Tags: []string{"database"},
IPAddresses: []string{"192.168.1.50"},
LastSeen: time.Now(),
}
r := FromContainer(ct) t.Run("lxc", func(t *testing.T) {
ct := models.Container{
ID: "pve1/lxc/101",
VMID: 101,
Name: "database",
Node: "node1",
Instance: "pve1",
Status: "stopped",
Type: "lxc",
CPU: 0.0,
CPUs: 2,
Memory: models.Memory{
Total: 4 * 1024 * 1024 * 1024,
Used: 0,
Free: 4 * 1024 * 1024 * 1024,
Usage: 0.0,
},
Uptime: 0,
Tags: []string{"database"},
IPAddresses: []string{"192.168.1.50"},
LastSeen: now,
}
if r.Type != ResourceTypeContainer { r := FromContainer(ct)
t.Errorf("Expected type %s, got %s", ResourceTypeContainer, r.Type)
}
if r.Status != StatusStopped {
t.Errorf("Expected status %s, got %s", StatusStopped, r.Status)
}
if r.ParentID != "pve1-node1" {
t.Errorf("Expected parent pve1-node1, got %s", r.ParentID)
}
var pd ContainerPlatformData if r.Type != ResourceTypeContainer {
if err := r.GetPlatformData(&pd); err != nil { t.Errorf("Expected type %s, got %s", ResourceTypeContainer, r.Type)
t.Fatalf("Failed to get platform data: %v", err) }
} if r.Status != StatusStopped {
if len(pd.IPAddresses) != 1 || pd.IPAddresses[0] != "192.168.1.50" { t.Errorf("Expected status %s, got %s", StatusStopped, r.Status)
t.Errorf("Expected IP 192.168.1.50, got %v", pd.IPAddresses) }
} if r.ParentID != "pve1-node1" {
t.Errorf("Expected parent pve1-node1, got %s", r.ParentID)
}
var pd ContainerPlatformData
if err := r.GetPlatformData(&pd); err != nil {
t.Fatalf("Failed to get platform data: %v", err)
}
if pd.Type != "lxc" {
t.Errorf("Expected platformData type lxc, got %q", pd.Type)
}
if pd.IsOCI {
t.Errorf("Expected IsOCI false, got %v", pd.IsOCI)
}
if len(pd.IPAddresses) != 1 || pd.IPAddresses[0] != "192.168.1.50" {
t.Errorf("Expected IP 192.168.1.50, got %v", pd.IPAddresses)
}
})
t.Run("oci", func(t *testing.T) {
ct := models.Container{
ID: "pve1/lxc/300",
VMID: 300,
Name: "oci-alpine",
Node: "node1",
Instance: "pve1",
Status: "running",
Type: "oci",
IsOCI: true,
OSTemplate: "oci:docker.io/library/alpine:latest",
CPU: 0.01,
CPUs: 1,
Memory: models.Memory{
Total: 1024 * 1024 * 1024,
Used: 128 * 1024 * 1024,
Free: 896 * 1024 * 1024,
Usage: 12.5,
},
LastSeen: now,
}
r := FromContainer(ct)
if r.Type != ResourceTypeOCIContainer {
t.Errorf("Expected type %s, got %s", ResourceTypeOCIContainer, r.Type)
}
var pd ContainerPlatformData
if err := r.GetPlatformData(&pd); err != nil {
t.Fatalf("Failed to get platform data: %v", err)
}
if !pd.IsOCI {
t.Errorf("Expected IsOCI true, got %v", pd.IsOCI)
}
if pd.Type != "oci" {
t.Errorf("Expected platformData type oci, got %q", pd.Type)
}
if pd.OSTemplate != ct.OSTemplate {
t.Errorf("Expected OSTemplate %q, got %q", ct.OSTemplate, pd.OSTemplate)
}
})
} }
func TestFromHost(t *testing.T) { func TestFromHost(t *testing.T) {

View file

@ -53,16 +53,20 @@ type VMPlatformData struct {
} }
// ContainerPlatformData contains Proxmox LXC container-specific fields. // ContainerPlatformData contains Proxmox LXC container-specific fields.
// Stored in Resource.PlatformData when Type is ResourceTypeContainer. // Stored in Resource.PlatformData when Type is ResourceTypeContainer or ResourceTypeOCIContainer.
type ContainerPlatformData struct { type ContainerPlatformData struct {
VMID int `json:"vmid"` VMID int `json:"vmid"`
Node string `json:"node"` // Proxmox node hosting this container Node string `json:"node"` // Proxmox node hosting this container
Instance string `json:"instance"` // Proxmox instance URL Instance string `json:"instance"` // Proxmox instance URL
Type string `json:"type,omitempty"` // lxc or oci
CPUs int `json:"cpus"` // Number of vCPUs CPUs int `json:"cpus"` // Number of vCPUs
Template bool `json:"template"` Template bool `json:"template"`
Lock string `json:"lock,omitempty"` Lock string `json:"lock,omitempty"`
OSName string `json:"osName,omitempty"` OSName string `json:"osName,omitempty"`
IPAddresses []string `json:"ipAddresses,omitempty"` IPAddresses []string `json:"ipAddresses,omitempty"`
// OCI container support (Proxmox VE 9.1+)
IsOCI bool `json:"isOci,omitempty"` // True if this is an OCI container
OSTemplate string `json:"osTemplate,omitempty"` // Template or OCI image reference if available
// I/O stats // I/O stats
NetworkIn int64 `json:"networkIn"` NetworkIn int64 `json:"networkIn"`

View file

@ -75,6 +75,7 @@ const (
// Compute Workloads - individual running instances // Compute Workloads - individual running instances
ResourceTypeVM ResourceType = "vm" // Proxmox VM ResourceTypeVM ResourceType = "vm" // Proxmox VM
ResourceTypeContainer ResourceType = "container" // LXC container ResourceTypeContainer ResourceType = "container" // LXC container
ResourceTypeOCIContainer ResourceType = "oci-container" // OCI container (Proxmox VE 9.1+)
ResourceTypeDockerContainer ResourceType = "docker-container" // Docker container ResourceTypeDockerContainer ResourceType = "docker-container" // Docker container
ResourceTypePod ResourceType = "pod" // Kubernetes pod ResourceTypePod ResourceType = "pod" // Kubernetes pod
ResourceTypeJail ResourceType = "jail" // BSD jail / TrueNAS jail ResourceTypeJail ResourceType = "jail" // BSD jail / TrueNAS jail
@ -201,7 +202,7 @@ func (r *Resource) IsInfrastructure() bool {
// rather than infrastructure. // rather than infrastructure.
func (r *Resource) IsWorkload() bool { func (r *Resource) IsWorkload() bool {
switch r.Type { switch r.Type {
case ResourceTypeVM, ResourceTypeContainer, ResourceTypeDockerContainer, ResourceTypePod, ResourceTypeJail: case ResourceTypeVM, ResourceTypeContainer, ResourceTypeOCIContainer, ResourceTypeDockerContainer, ResourceTypePod, ResourceTypeJail:
return true return true
default: default:
return false return false