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
This commit is contained in:
parent
7f057432ed
commit
dfb5d50f73
8 changed files with 313 additions and 9 deletions
|
|
@ -549,7 +549,8 @@ export function Dashboard(props: DashboardProps) {
|
||||||
if (viewMode() === 'vm') {
|
if (viewMode() === 'vm') {
|
||||||
guests = guests.filter((g) => g.type === 'qemu');
|
guests = guests.filter((g) => g.type === 'qemu');
|
||||||
} else if (viewMode() === 'lxc') {
|
} 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
|
// Filter by status
|
||||||
|
|
@ -807,7 +808,7 @@ export function Dashboard(props: DashboardProps) {
|
||||||
}).length;
|
}).length;
|
||||||
const stopped = guests.length - running - degraded;
|
const stopped = guests.length - running - degraded;
|
||||||
const vms = guests.filter((g) => g.type === 'qemu').length;
|
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 {
|
return {
|
||||||
total: guests.length,
|
total: guests.length,
|
||||||
running,
|
running,
|
||||||
|
|
@ -916,7 +917,7 @@ export function Dashboard(props: DashboardProps) {
|
||||||
aiChatStore.addContextItem(guestType, guestId, guest.name, {
|
aiChatStore.addContextItem(guestType, guestId, guest.name, {
|
||||||
guestName: guest.name,
|
guestName: guest.name,
|
||||||
name: 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,
|
vmid: guest.vmid,
|
||||||
node: guest.node,
|
node: guest.node,
|
||||||
status: guest.status,
|
status: guest.status,
|
||||||
|
|
@ -939,7 +940,7 @@ export function Dashboard(props: DashboardProps) {
|
||||||
onNodeSelect={handleNodeSelect}
|
onNodeSelect={handleNodeSelect}
|
||||||
nodes={props.nodes}
|
nodes={props.nodes}
|
||||||
filteredVms={filteredGuests().filter((g) => g.type === 'qemu')}
|
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()}
|
searchTerm={search()}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -890,12 +890,20 @@ export function GuestRow(props: GuestRowProps) {
|
||||||
<div class="flex justify-center">
|
<div class="flex justify-center">
|
||||||
<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'
|
||||||
: 'bg-green-100 text-green-700 dark:bg-green-900/50 dark:text-green-300'
|
: props.guest.type === 'oci'
|
||||||
|
? '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' : 'LXC Container'}
|
title={
|
||||||
|
isVM(props.guest)
|
||||||
|
? 'Virtual Machine'
|
||||||
|
: props.guest.type === 'oci'
|
||||||
|
? 'OCI Container (Docker image)'
|
||||||
|
: 'LXC Container'
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{isVM(props.guest) ? 'VM' : 'LXC'}
|
{isVM(props.guest) ? 'VM' : props.guest.type === 'oci' ? 'OCI' : 'LXC'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
|
|
||||||
|
|
@ -130,6 +130,9 @@ export interface Container {
|
||||||
tags: string[] | string | null;
|
tags: string[] | string | null;
|
||||||
lock: string;
|
lock: string;
|
||||||
lastSeen: 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 {
|
export interface DockerHost {
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ export type ResourceType =
|
||||||
| 'truenas' // TrueNAS system
|
| 'truenas' // TrueNAS system
|
||||||
| 'vm' // Proxmox VM
|
| 'vm' // Proxmox VM
|
||||||
| 'container' // LXC container
|
| 'container' // LXC container
|
||||||
|
| 'oci-container' // OCI container (Proxmox VE 9.1+)
|
||||||
| 'docker-container' // Docker container
|
| 'docker-container' // Docker container
|
||||||
| 'pod' // Kubernetes pod
|
| 'pod' // Kubernetes pod
|
||||||
| 'jail' // BSD jail
|
| 'jail' // BSD jail
|
||||||
|
|
@ -137,7 +138,7 @@ export function isInfrastructure(r: Resource): boolean {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isWorkload(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 {
|
export function isStorage(r: Resource): boolean {
|
||||||
|
|
|
||||||
|
|
@ -146,6 +146,9 @@ type Container struct {
|
||||||
IPAddresses []string `json:"ipAddresses,omitempty"`
|
IPAddresses []string `json:"ipAddresses,omitempty"`
|
||||||
NetworkInterfaces []GuestNetworkInterface `json:"networkInterfaces,omitempty"`
|
NetworkInterfaces []GuestNetworkInterface `json:"networkInterfaces,omitempty"`
|
||||||
OSName string `json:"osName,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.
|
// Host represents a generic infrastructure host reporting via external agents.
|
||||||
|
|
|
||||||
|
|
@ -490,3 +490,71 @@ func extractContainerOSType(config map[string]interface{}) string {
|
||||||
}
|
}
|
||||||
return ostype
|
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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1748,3 +1748,208 @@ func containersEqual(a, b *models.Container) bool {
|
||||||
}
|
}
|
||||||
return diskSlicesEqual(a.Disks, b.Disks)
|
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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -2483,6 +2483,21 @@ func (m *Monitor) enrichContainerMetadata(ctx context.Context, client PVEClientI
|
||||||
if osName := extractContainerOSType(configData); osName != "" {
|
if osName := extractContainerOSType(configData); osName != "" {
|
||||||
container.OSName = 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 {
|
if len(addressOrder) == 0 {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue