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:
parent
a20760f527
commit
bf43d448cf
14 changed files with 371 additions and 133 deletions
|
|
@ -908,6 +908,7 @@ export function Dashboard(props: DashboardProps) {
|
|||
|
||||
const guestId = guest.id || `${guest.instance}-${guest.vmid}`;
|
||||
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
|
||||
if (aiChatStore.hasContextItem(guestId)) {
|
||||
|
|
@ -918,14 +919,14 @@ export function Dashboard(props: DashboardProps) {
|
|||
const contextData: Record<string, unknown> = {
|
||||
guestName: 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,
|
||||
node: guest.node,
|
||||
status: guest.status,
|
||||
};
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -571,9 +571,15 @@ export function GuestRow(props: GuestRowProps) {
|
|||
const agentVersion = createMemo(() => props.guest.agentVersion?.trim() ?? '');
|
||||
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)
|
||||
const ociImage = createMemo(() => {
|
||||
if (props.guest.type !== 'oci') return null;
|
||||
if (!isOCIContainer()) return null;
|
||||
const template = (props.guest as Container).osTemplate;
|
||||
if (!template) return null;
|
||||
// Strip common prefixes to get clean image reference
|
||||
|
|
@ -903,19 +909,19 @@ export function GuestRow(props: GuestRowProps) {
|
|||
<span
|
||||
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'
|
||||
: props.guest.type === 'oci'
|
||||
: isOCIContainer()
|
||||
? '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'
|
||||
}`}
|
||||
title={
|
||||
isVM(props.guest)
|
||||
? 'Virtual Machine'
|
||||
: props.guest.type === 'oci'
|
||||
: isOCIContainer()
|
||||
? `OCI Container${ociImage() ? ` • ${ociImage()}` : ''}`
|
||||
: 'LXC Container'
|
||||
}
|
||||
>
|
||||
{isVM(props.guest) ? 'VM' : props.guest.type === 'oci' ? 'OCI' : 'LXC'}
|
||||
{isVM(props.guest) ? 'VM' : isOCIContainer() ? 'OCI' : 'LXC'}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
|
|
|
|||
|
|
@ -379,15 +379,15 @@ describe('UnifiedAgents platform commands', () => {
|
|||
const generateButton = screen.getByRole('button', { name: /Generate token/i });
|
||||
fireEvent.click(generateButton);
|
||||
|
||||
await waitFor(() => expect(createTokenMock).toHaveBeenCalled(), { interval: 0 });
|
||||
await waitFor(() => expect(createTokenMock).toHaveBeenCalled(), { interval: 0 });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Enable Docker monitoring')).toBeInTheDocument();
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Docker monitoring')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Docker monitoring is disabled by default
|
||||
const checkbox = screen.getByRole('checkbox', { name: /Enable Docker monitoring/i });
|
||||
expect(checkbox).not.toBeChecked();
|
||||
// Docker monitoring is disabled by default
|
||||
const checkbox = screen.getByRole('checkbox', { name: /Docker monitoring/i });
|
||||
expect(checkbox).not.toBeChecked();
|
||||
|
||||
// Enable it
|
||||
fireEvent.click(checkbox);
|
||||
|
|
|
|||
|
|
@ -240,29 +240,62 @@ describe('useResourcesAsLegacy - Legacy Format Conversion', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('Container conversion', () => {
|
||||
it('converts Resource to legacy Container format', () => {
|
||||
const resource = createMockResource({
|
||||
type: 'container',
|
||||
platformData: {
|
||||
vmid: 200,
|
||||
node: 'pve1',
|
||||
},
|
||||
});
|
||||
describe('Container conversion', () => {
|
||||
it('converts container Resource to legacy Container format', () => {
|
||||
const resource = createMockResource({
|
||||
type: 'container',
|
||||
platformData: {
|
||||
vmid: 200,
|
||||
node: 'pve1',
|
||||
},
|
||||
});
|
||||
|
||||
const platformData = resource.platformData as Record<string, unknown>;
|
||||
const legacyContainer = {
|
||||
id: resource.id,
|
||||
vmid: platformData?.vmid as number,
|
||||
name: resource.name,
|
||||
type: 'lxc',
|
||||
status: resource.status === 'running' ? 'running' : 'stopped',
|
||||
};
|
||||
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(200);
|
||||
expect(legacyContainer.type).toBe('lxc');
|
||||
});
|
||||
});
|
||||
expect(legacyContainer.vmid).toBe(200);
|
||||
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', () => {
|
||||
it('converts Resource to legacy Node format with temperature', () => {
|
||||
|
|
|
|||
|
|
@ -304,8 +304,16 @@ export function useResourcesAsLegacy() {
|
|||
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 isOCI =
|
||||
r.type === 'oci-container' ||
|
||||
platformData?.isOci === true ||
|
||||
platformData?.type === 'oci';
|
||||
const legacyType = isOCI ? 'oci' : ((platformData?.type as string) ?? 'lxc');
|
||||
return {
|
||||
id: r.id,
|
||||
vmid: platformData?.vmid as number ?? parseInt(r.id.split('-').pop() ?? '0', 10),
|
||||
|
|
@ -313,7 +321,9 @@ export function useResourcesAsLegacy() {
|
|||
node: platformData?.node as string ?? '',
|
||||
instance: platformData?.instance as string ?? r.platformId,
|
||||
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
|
||||
cpus: platformData?.cpus as number ?? 1,
|
||||
memory: r.memory ? {
|
||||
|
|
|
|||
|
|
@ -157,6 +157,19 @@ func (c Container) ToFrontend() ContainerFrontend {
|
|||
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
|
||||
if len(c.Tags) > 0 {
|
||||
ct.Tags = strings.Join(c.Tags, ",")
|
||||
|
|
|
|||
|
|
@ -267,42 +267,99 @@ func TestVMToFrontend_NegativeNetworkValues(t *testing.T) {
|
|||
func TestContainerToFrontend(t *testing.T) {
|
||||
now := time.Now()
|
||||
|
||||
container := Container{
|
||||
ID: "ct-101",
|
||||
VMID: 101,
|
||||
Name: "test-ct",
|
||||
Node: "pve1",
|
||||
Status: "running",
|
||||
Type: "lxc",
|
||||
CPU: 0.10,
|
||||
CPUs: 2,
|
||||
Memory: Memory{Total: 4000000000, Used: 2000000000},
|
||||
Disk: Disk{Total: 50000000000, Used: 25000000000},
|
||||
NetworkIn: 500000,
|
||||
NetworkOut: 250000,
|
||||
Uptime: 7200,
|
||||
Tags: []string{"dev", "database"},
|
||||
LastSeen: now,
|
||||
IPAddresses: []string{"192.168.1.51"},
|
||||
}
|
||||
t.Run("lxc", func(t *testing.T) {
|
||||
container := Container{
|
||||
ID: "ct-101",
|
||||
VMID: 101,
|
||||
Name: "test-ct",
|
||||
Node: "pve1",
|
||||
Status: "running",
|
||||
Type: "lxc",
|
||||
CPU: 0.10,
|
||||
CPUs: 2,
|
||||
Memory: Memory{Total: 4000000000, Used: 2000000000},
|
||||
Disk: Disk{Total: 50000000000, Used: 25000000000},
|
||||
NetworkIn: 500000,
|
||||
NetworkOut: 250000,
|
||||
Uptime: 7200,
|
||||
Tags: []string{"dev", "database"},
|
||||
LastSeen: now,
|
||||
IPAddresses: []string{"192.168.1.51"},
|
||||
}
|
||||
|
||||
frontend := container.ToFrontend()
|
||||
frontend := container.ToFrontend()
|
||||
|
||||
if frontend.ID != container.ID {
|
||||
t.Errorf("ID = %q, want %q", frontend.ID, container.ID)
|
||||
}
|
||||
if frontend.VMID != container.VMID {
|
||||
t.Errorf("VMID = %d, want %d", frontend.VMID, container.VMID)
|
||||
}
|
||||
if frontend.Type != container.Type {
|
||||
t.Errorf("Type = %q, want %q", frontend.Type, container.Type)
|
||||
}
|
||||
if frontend.Tags != "dev,database" {
|
||||
t.Errorf("Tags = %q, want 'dev,database'", frontend.Tags)
|
||||
}
|
||||
if frontend.Memory == nil {
|
||||
t.Error("Memory should not be nil when Total > 0")
|
||||
}
|
||||
if frontend.ID != container.ID {
|
||||
t.Errorf("ID = %q, want %q", frontend.ID, container.ID)
|
||||
}
|
||||
if frontend.VMID != container.VMID {
|
||||
t.Errorf("VMID = %d, want %d", frontend.VMID, container.VMID)
|
||||
}
|
||||
if frontend.Type != container.Type {
|
||||
t.Errorf("Type = %q, want %q", frontend.Type, container.Type)
|
||||
}
|
||||
if frontend.IsOCI {
|
||||
t.Errorf("IsOCI = %v, want false", frontend.IsOCI)
|
||||
}
|
||||
if frontend.Tags != "dev,database" {
|
||||
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) {
|
||||
|
|
|
|||
|
|
@ -75,6 +75,9 @@ type ContainerFrontend struct {
|
|||
Instance string `json:"instance"`
|
||||
Status string `json:"status"`
|
||||
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"`
|
||||
CPUs int `json:"cpus"`
|
||||
Memory *Memory `json:"memory,omitempty"` // Full memory object
|
||||
|
|
|
|||
|
|
@ -5517,6 +5517,22 @@ func (m *Monitor) pollVMsAndContainersEfficient(ctx context.Context, instanceNam
|
|||
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 allContainers []models.Container
|
||||
|
||||
|
|
@ -6160,6 +6176,11 @@ func (m *Monitor) pollVMsAndContainersEfficient(ctx context.Context, instanceNam
|
|||
LastSeen: time.Now(),
|
||||
}
|
||||
|
||||
if prevContainerIsOCI[container.VMID] {
|
||||
container.IsOCI = true
|
||||
container.Type = "oci"
|
||||
}
|
||||
|
||||
// Parse tags
|
||||
if 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)
|
||||
|
||||
// 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)
|
||||
|
||||
m.recordGuestSnapshot(instanceName, container.Type, res.Node, res.VMID, GuestMemorySnapshot{
|
||||
|
|
@ -6190,26 +6230,6 @@ func (m *Monitor) pollVMsAndContainersEfficient(ctx context.Context, instanceNam
|
|||
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
|
||||
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
|
||||
// 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
|
||||
prevState := m.GetState()
|
||||
|
||||
// Count previous resources for this instance
|
||||
prevVMCount := 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
|
||||
if existing.LastKnownName != name || existing.LastKnownType != guestType {
|
||||
existing.LastKnownName = name
|
||||
|
|
|
|||
|
|
@ -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().
|
||||
Str("instance", instanceName).
|
||||
Int("totalNodes", len(nodes)).
|
||||
|
|
@ -1026,6 +1042,11 @@ func (m *Monitor) pollContainersWithNodes(ctx context.Context, instanceName stri
|
|||
Tags: tags,
|
||||
}
|
||||
|
||||
if prevContainerIsOCI[modelContainer.VMID] {
|
||||
modelContainer.IsOCI = true
|
||||
modelContainer.Type = "oci"
|
||||
}
|
||||
|
||||
if override, ok := rootUsageOverrides[int(container.VMID)]; ok {
|
||||
overrideUsed := clampToInt64(override.Used)
|
||||
overrideTotal := clampToInt64(override.Total)
|
||||
|
|
|
|||
|
|
@ -219,11 +219,14 @@ func FromContainer(ct models.Container) Resource {
|
|||
VMID: ct.VMID,
|
||||
Node: ct.Node,
|
||||
Instance: ct.Instance,
|
||||
Type: ct.Type,
|
||||
CPUs: ct.CPUs,
|
||||
Template: ct.Template,
|
||||
Lock: ct.Lock,
|
||||
OSName: ct.OSName,
|
||||
IPAddresses: ct.IPAddresses,
|
||||
IsOCI: ct.IsOCI || strings.EqualFold(strings.TrimSpace(ct.Type), "oci"),
|
||||
OSTemplate: ct.OSTemplate,
|
||||
NetworkIn: ct.NetworkIn,
|
||||
NetworkOut: ct.NetworkOut,
|
||||
DiskRead: ct.DiskRead,
|
||||
|
|
@ -235,9 +238,14 @@ func FromContainer(ct models.Container) Resource {
|
|||
// Parent is the node - format matches Node.ID: instance-nodename
|
||||
parentID := fmt.Sprintf("%s-%s", ct.Instance, ct.Node)
|
||||
|
||||
resourceType := ResourceTypeContainer
|
||||
if platformData.IsOCI {
|
||||
resourceType = ResourceTypeOCIContainer
|
||||
}
|
||||
|
||||
return Resource{
|
||||
ID: ct.ID,
|
||||
Type: ResourceTypeContainer,
|
||||
Type: resourceType,
|
||||
Name: ct.Name,
|
||||
PlatformID: ct.Instance,
|
||||
PlatformType: PlatformProxmoxPVE,
|
||||
|
|
|
|||
|
|
@ -199,47 +199,99 @@ func TestFromVM(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestFromContainer(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: time.Now(),
|
||||
}
|
||||
now := 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 {
|
||||
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)
|
||||
}
|
||||
r := FromContainer(ct)
|
||||
|
||||
var pd ContainerPlatformData
|
||||
if err := r.GetPlatformData(&pd); err != nil {
|
||||
t.Fatalf("Failed to get platform data: %v", err)
|
||||
}
|
||||
if len(pd.IPAddresses) != 1 || pd.IPAddresses[0] != "192.168.1.50" {
|
||||
t.Errorf("Expected IP 192.168.1.50, got %v", pd.IPAddresses)
|
||||
}
|
||||
if r.Type != ResourceTypeContainer {
|
||||
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 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) {
|
||||
|
|
|
|||
|
|
@ -53,16 +53,20 @@ type VMPlatformData struct {
|
|||
}
|
||||
|
||||
// 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 {
|
||||
VMID int `json:"vmid"`
|
||||
Node string `json:"node"` // Proxmox node hosting this container
|
||||
Instance string `json:"instance"` // Proxmox instance URL
|
||||
Type string `json:"type,omitempty"` // lxc or oci
|
||||
CPUs int `json:"cpus"` // Number of vCPUs
|
||||
Template bool `json:"template"`
|
||||
Lock string `json:"lock,omitempty"`
|
||||
OSName string `json:"osName,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
|
||||
NetworkIn int64 `json:"networkIn"`
|
||||
|
|
|
|||
|
|
@ -75,6 +75,7 @@ const (
|
|||
// Compute Workloads - individual running instances
|
||||
ResourceTypeVM ResourceType = "vm" // Proxmox VM
|
||||
ResourceTypeContainer ResourceType = "container" // LXC container
|
||||
ResourceTypeOCIContainer ResourceType = "oci-container" // OCI container (Proxmox VE 9.1+)
|
||||
ResourceTypeDockerContainer ResourceType = "docker-container" // Docker container
|
||||
ResourceTypePod ResourceType = "pod" // Kubernetes pod
|
||||
ResourceTypeJail ResourceType = "jail" // BSD jail / TrueNAS jail
|
||||
|
|
@ -201,7 +202,7 @@ func (r *Resource) IsInfrastructure() bool {
|
|||
// rather than infrastructure.
|
||||
func (r *Resource) IsWorkload() bool {
|
||||
switch r.Type {
|
||||
case ResourceTypeVM, ResourceTypeContainer, ResourceTypeDockerContainer, ResourceTypePod, ResourceTypeJail:
|
||||
case ResourceTypeVM, ResourceTypeContainer, ResourceTypeOCIContainer, ResourceTypeDockerContainer, ResourceTypePod, ResourceTypeJail:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
|
|
|
|||
Loading…
Reference in a new issue