From dfb5d50f734e513d598424d555c8a2eb0d1e99a1 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Fri, 12 Dec 2025 17:51:43 +0000 Subject: [PATCH] feat: Add Proxmox 9.1+ OCI container support - Backend: Add IsOCI and OSTemplate fields to Container model - Backend: Add extractContainerOSTemplate() and isOCITemplate() detection functions - Backend: Detect OCI containers via ostemplate config and set type to 'oci' - Frontend: Add isOci and osTemplate to Container interface - Frontend: Add 'oci-container' to ResourceType with distinct purple badge - Frontend: Update Dashboard filters to include OCI containers with LXC - Tests: Add comprehensive unit tests for OCI detection logic OCI containers are detected by checking the ostemplate for patterns like: - oci: prefix (e.g., oci:docker.io/library/alpine:latest) - docker: prefix (e.g., docker:nginx:latest) - Known registry URLs (docker.io, ghcr.io, gcr.io, quay.io, etc.) - Local templates with oci- or oci_ filename patterns --- .../src/components/Dashboard/Dashboard.tsx | 9 +- .../src/components/Dashboard/GuestRow.tsx | 16 +- frontend-modern/src/types/api.ts | 3 + frontend-modern/src/types/resource.ts | 3 +- internal/models/models.go | 3 + internal/monitoring/container_parsing.go | 68 ++++++ internal/monitoring/container_parsing_test.go | 205 ++++++++++++++++++ internal/monitoring/monitor.go | 15 ++ 8 files changed, 313 insertions(+), 9 deletions(-) diff --git a/frontend-modern/src/components/Dashboard/Dashboard.tsx b/frontend-modern/src/components/Dashboard/Dashboard.tsx index 6d9e07e..e675836 100644 --- a/frontend-modern/src/components/Dashboard/Dashboard.tsx +++ b/frontend-modern/src/components/Dashboard/Dashboard.tsx @@ -549,7 +549,8 @@ export function Dashboard(props: DashboardProps) { if (viewMode() === 'vm') { guests = guests.filter((g) => g.type === 'qemu'); } else if (viewMode() === 'lxc') { - guests = guests.filter((g) => g.type === 'lxc'); + // Include both traditional LXC and OCI containers (Proxmox 9.1+) + guests = guests.filter((g) => g.type === 'lxc' || g.type === 'oci'); } // Filter by status @@ -807,7 +808,7 @@ export function Dashboard(props: DashboardProps) { }).length; const stopped = guests.length - running - degraded; const vms = guests.filter((g) => g.type === 'qemu').length; - const containers = guests.filter((g) => g.type === 'lxc').length; + const containers = guests.filter((g) => g.type === 'lxc' || g.type === 'oci').length; return { total: guests.length, running, @@ -916,7 +917,7 @@ export function Dashboard(props: DashboardProps) { aiChatStore.addContextItem(guestType, guestId, guest.name, { guestName: guest.name, name: guest.name, - type: guest.type === 'qemu' ? 'Virtual Machine' : 'LXC Container', + type: guest.type === 'qemu' ? 'Virtual Machine' : (guest.type === 'oci' ? 'OCI Container' : 'LXC Container'), vmid: guest.vmid, node: guest.node, status: guest.status, @@ -939,7 +940,7 @@ export function Dashboard(props: DashboardProps) { onNodeSelect={handleNodeSelect} nodes={props.nodes} filteredVms={filteredGuests().filter((g) => g.type === 'qemu')} - filteredContainers={filteredGuests().filter((g) => g.type === 'lxc')} + filteredContainers={filteredGuests().filter((g) => g.type === 'lxc' || g.type === 'oci')} searchTerm={search()} /> diff --git a/frontend-modern/src/components/Dashboard/GuestRow.tsx b/frontend-modern/src/components/Dashboard/GuestRow.tsx index 73fc590..3fc5a9b 100644 --- a/frontend-modern/src/components/Dashboard/GuestRow.tsx +++ b/frontend-modern/src/components/Dashboard/GuestRow.tsx @@ -890,12 +890,20 @@ export function GuestRow(props: GuestRowProps) {
- {isVM(props.guest) ? 'VM' : 'LXC'} + {isVM(props.guest) ? 'VM' : props.guest.type === 'oci' ? 'OCI' : 'LXC'}
diff --git a/frontend-modern/src/types/api.ts b/frontend-modern/src/types/api.ts index bb8685b..f8d81d4 100644 --- a/frontend-modern/src/types/api.ts +++ b/frontend-modern/src/types/api.ts @@ -130,6 +130,9 @@ export interface Container { tags: string[] | string | null; lock: string; lastSeen: string; + // OCI container support (Proxmox VE 9.1+) + isOci?: boolean; // True if this is an OCI container + osTemplate?: string; // Template or OCI image used (e.g., "oci:docker.io/library/alpine:latest") } export interface DockerHost { diff --git a/frontend-modern/src/types/resource.ts b/frontend-modern/src/types/resource.ts index 59dbf8a..24df59c 100644 --- a/frontend-modern/src/types/resource.ts +++ b/frontend-modern/src/types/resource.ts @@ -16,6 +16,7 @@ export type ResourceType = | 'truenas' // TrueNAS system | 'vm' // Proxmox VM | 'container' // LXC container + | 'oci-container' // OCI container (Proxmox VE 9.1+) | 'docker-container' // Docker container | 'pod' // Kubernetes pod | 'jail' // BSD jail @@ -137,7 +138,7 @@ export function isInfrastructure(r: Resource): boolean { } export function isWorkload(r: Resource): boolean { - return ['vm', 'container', 'docker-container', 'pod', 'jail'].includes(r.type); + return ['vm', 'container', 'oci-container', 'docker-container', 'pod', 'jail'].includes(r.type); } export function isStorage(r: Resource): boolean { diff --git a/internal/models/models.go b/internal/models/models.go index f12431c..75a026a 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -146,6 +146,9 @@ type Container struct { IPAddresses []string `json:"ipAddresses,omitempty"` NetworkInterfaces []GuestNetworkInterface `json:"networkInterfaces,omitempty"` OSName string `json:"osName,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 used (e.g., "docker:alpine:latest") } // Host represents a generic infrastructure host reporting via external agents. diff --git a/internal/monitoring/container_parsing.go b/internal/monitoring/container_parsing.go index c977de7..d904db2 100644 --- a/internal/monitoring/container_parsing.go +++ b/internal/monitoring/container_parsing.go @@ -490,3 +490,71 @@ func extractContainerOSType(config map[string]interface{}) string { } return ostype } + +// extractContainerOSTemplate extracts the ostemplate value from container config. +// This is the template used to create the container, which may be an LXC template +// or an OCI image reference (Proxmox VE 9.1+). +func extractContainerOSTemplate(config map[string]interface{}) string { + if len(config) == 0 { + return "" + } + + // Try common field names for the template + for _, key := range []string{"ostemplate", "template"} { + if raw, ok := config[key]; ok { + if value := strings.TrimSpace(fmt.Sprint(raw)); value != "" { + return value + } + } + } + + return "" +} + +// isOCITemplate returns true if the ostemplate string indicates an OCI container image. +// Proxmox VE 9.1+ supports pulling OCI images from registries like Docker Hub. +// OCI templates typically have formats like: +// - "oci:docker.io/library/alpine:latest" +// - "docker:alpine:latest" +// - "local:vztmpl/oci-alpine-latest.tar.gz" (pulled OCI image stored locally) +func isOCITemplate(template string) bool { + if template == "" { + return false + } + + template = strings.ToLower(strings.TrimSpace(template)) + + // Explicit OCI prefix + if strings.HasPrefix(template, "oci:") { + return true + } + + // Docker Hub shorthand (docker:image:tag) + if strings.HasPrefix(template, "docker:") { + return true + } + + // Check for common OCI registry URLs + ociRegistries := []string{ + "docker.io/", + "ghcr.io/", + "gcr.io/", + "quay.io/", + "registry.hub.docker.com/", + "mcr.microsoft.com/", + "public.ecr.aws/", + } + for _, registry := range ociRegistries { + if strings.Contains(template, registry) { + return true + } + } + + // Check for locally stored OCI images (typically have "oci" in the filename) + // e.g., "local:vztmpl/oci-alpine-3.18.tar.xz" + if strings.Contains(template, "/oci-") || strings.Contains(template, "/oci_") { + return true + } + + return false +} diff --git a/internal/monitoring/container_parsing_test.go b/internal/monitoring/container_parsing_test.go index fef1d18..a621ef0 100644 --- a/internal/monitoring/container_parsing_test.go +++ b/internal/monitoring/container_parsing_test.go @@ -1748,3 +1748,208 @@ func containersEqual(a, b *models.Container) bool { } return diskSlicesEqual(a.Disks, b.Disks) } + +// OCI Container Detection Tests (Proxmox VE 9.1+) + +func TestExtractContainerOSTemplate(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + config map[string]interface{} + expected string + }{ + { + name: "empty config", + config: map[string]interface{}{}, + expected: "", + }, + { + name: "standard LXC template", + config: map[string]interface{}{ + "ostemplate": "local:vztmpl/ubuntu-22.04-standard_22.04-1_amd64.tar.zst", + }, + expected: "local:vztmpl/ubuntu-22.04-standard_22.04-1_amd64.tar.zst", + }, + { + name: "OCI image with oci prefix", + config: map[string]interface{}{ + "ostemplate": "oci:docker.io/library/alpine:latest", + }, + expected: "oci:docker.io/library/alpine:latest", + }, + { + name: "Docker Hub shorthand", + config: map[string]interface{}{ + "ostemplate": "docker:nginx:latest", + }, + expected: "docker:nginx:latest", + }, + { + name: "template field fallback", + config: map[string]interface{}{ + "template": "oci:ghcr.io/myorg/myimage:v1.0", + }, + expected: "oci:ghcr.io/myorg/myimage:v1.0", + }, + { + name: "ostemplate takes precedence over template", + config: map[string]interface{}{ + "ostemplate": "oci:docker.io/library/alpine:latest", + "template": "local:vztmpl/something-else.tar.gz", + }, + expected: "oci:docker.io/library/alpine:latest", + }, + { + name: "whitespace trimmed", + config: map[string]interface{}{ + "ostemplate": " oci:docker.io/library/alpine:latest ", + }, + expected: "oci:docker.io/library/alpine:latest", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + result := extractContainerOSTemplate(tt.config) + if result != tt.expected { + t.Errorf("extractContainerOSTemplate() = %q, want %q", result, tt.expected) + } + }) + } +} + +func TestIsOCITemplate(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + template string + expected bool + }{ + // Empty/nil cases + { + name: "empty string", + template: "", + expected: false, + }, + + // Standard LXC templates (should NOT be detected as OCI) + { + name: "standard LXC template", + template: "local:vztmpl/ubuntu-22.04-standard_22.04-1_amd64.tar.zst", + expected: false, + }, + { + name: "Proxmox template store", + template: "local:vztmpl/debian-12-standard_12.0-1_amd64.tar.zst", + expected: false, + }, + + // Explicit OCI prefix + { + name: "oci prefix - Docker Hub", + template: "oci:docker.io/library/alpine:latest", + expected: true, + }, + { + name: "oci prefix - GHCR", + template: "oci:ghcr.io/myorg/myimage:v1.0", + expected: true, + }, + { + name: "oci prefix uppercase", + template: "OCI:docker.io/library/nginx:latest", + expected: true, + }, + + // Docker Hub shorthand + { + name: "docker prefix simple", + template: "docker:alpine:latest", + expected: true, + }, + { + name: "docker prefix with path", + template: "docker:library/nginx:1.25", + expected: true, + }, + { + name: "docker prefix uppercase", + template: "DOCKER:redis:7", + expected: true, + }, + + // Registry URLs embedded (with slashes as the detection logic expects) + { + name: "Docker Hub URL with slashes", + template: "docker.io/library/alpine:latest", + expected: true, + }, + { + name: "GHCR URL with slashes", + template: "ghcr.io/myorg/myapp:v2", + expected: true, + }, + { + name: "GCR URL", + template: "gcr.io/myproject/myimage:latest", + expected: true, + }, + { + name: "Quay.io URL", + template: "quay.io/coreos/etcd:v3.5", + expected: true, + }, + { + name: "Microsoft Container Registry", + template: "mcr.microsoft.com/dotnet/runtime:7.0", + expected: true, + }, + { + name: "AWS ECR Public", + template: "public.ecr.aws/amazonlinux/amazonlinux:latest", + expected: true, + }, + + // Locally stored OCI images + { + name: "local OCI image with oci- prefix", + template: "local:vztmpl/oci-alpine-3.18.tar.xz", + expected: true, + }, + { + name: "local OCI image with oci_ prefix", + template: "local:vztmpl/oci_nginx_latest.tar.gz", + expected: true, + }, + + // Edge cases + { + name: "case insensitive oci", + template: "OcI:docker.io/library/alpine:latest", + expected: true, + }, + { + name: "whitespace handling", + template: " oci:docker.io/library/alpine:latest ", + expected: true, + }, + { + name: "similar but not OCI - social.io", + template: "local:vztmpl/social.io-app.tar.gz", + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + result := isOCITemplate(tt.template) + if result != tt.expected { + t.Errorf("isOCITemplate(%q) = %v, want %v", tt.template, result, tt.expected) + } + }) + } +} diff --git a/internal/monitoring/monitor.go b/internal/monitoring/monitor.go index ccf9c12..1baaa96 100644 --- a/internal/monitoring/monitor.go +++ b/internal/monitoring/monitor.go @@ -2483,6 +2483,21 @@ func (m *Monitor) enrichContainerMetadata(ctx context.Context, client PVEClientI if osName := extractContainerOSType(configData); osName != "" { container.OSName = osName } + // Detect OCI containers (Proxmox VE 9.1+) + // OCI containers have ostemplate pointing to an OCI registry (e.g., "oci:docker.io/library/alpine:latest") + if osTemplate := extractContainerOSTemplate(configData); osTemplate != "" { + container.OSTemplate = osTemplate + // Check if this is an OCI container based on template format + if isOCITemplate(osTemplate) { + container.IsOCI = true + container.Type = "oci" // Override type from "lxc" to "oci" + log.Debug(). + Str("container", container.Name). + Int("vmid", container.VMID). + Str("osTemplate", osTemplate). + Msg("Detected OCI container") + } + } } if len(addressOrder) == 0 {