Fix Unraid agent host profile detection

This commit is contained in:
rcourtman 2026-05-08 11:03:41 +01:00
parent 326196cbec
commit ac82a28521
15 changed files with 154 additions and 26 deletions

View file

@ -16,7 +16,9 @@
"governance_state": "supported",
"readiness_stage": "supported",
"host_identity_tokens": [
"unraid"
"unraid",
"unraid-os",
"unraid os"
],
"runtime_platform": "linux",
"support_floor": {

View file

@ -194,7 +194,10 @@ system instead of falling back to an all-platform generic agent screen.
Agent runtime normalization must use that same governed host-profile manifest:
profile identity tokens and runtime platform fallback values such as
`unraid` -> `linux` are generated into the runtime resolver instead of being
redeclared in host-agent or settings-table branches.
redeclared in host-agent or settings-table branches. Host-profile detection is
an identity fact and must not depend on optional storage probes succeeding; an
Unraid host still reports the governed `unraid` profile and `linux` runtime
platform when `mdcmd` or array-topology collection is unavailable.
The lifecycle-owned infrastructure source manager also owns platform/system
grouping as source-management content, but not its table band presentation:
`frontend-modern/src/components/Settings/InfrastructureSourceManager.tsx` must

View file

@ -834,7 +834,10 @@ the canonical monitored-system blocked payload.
`agentIdentity.platform` remains the canonical runtime platform such as
`linux`, while `agentIdentity.hostProfile` carries the governed profile id
such as `unraid`; frontend clients must not re-promote those profile ids
into first-class platform types.
into first-class platform types. Once a governed host profile is resolved,
the runtime platform is the profile's manifest runtime platform even when
the host's base distro token is something implementation-specific such as
Slackware on Unraid.
Unified-resource agent payloads use the same split: `agent.platform` is
the normalized runtime platform and `agent.hostProfile` is the optional
governed profile id for host/appliance presentation.

View file

@ -652,7 +652,9 @@ prompt explain the same operator-facing priority.
frontend primitives must still render host-profile labels through
explicit backend profile fields such as `agentIdentity.hostProfile` and
unified-resource `platformData.agent.hostProfile` rather than prettifying
presentation-only ids as platform values.
presentation-only ids as platform values. Raw appliance identity aliases
such as `unraid-os` belong only in the generated host-profile token list so
shared helpers resolve them to `unraid` before presentation.
9. Keep top-of-page summary interaction on shared primitives. Infrastructure, workloads, and storage summary cards must route sticky-shell behavior through `frontend-modern/src/components/shared/StickySummarySection.tsx` and route row-hover or focused-series rendering through shared chart primitives such as `frontend-modern/src/components/shared/InteractiveSparkline.tsx` and `frontend-modern/src/components/shared/DensityMap.tsx`, rather than page-local sticky wrappers or metric-card-specific hover logic. When a page keeps summary charts visible below the desktop breakpoint, it must use the shared `stickyDesktopOnly` mode instead of adding page-local media queries, so wrapped two-column summaries scroll as normal content and only become sticky once the large-screen layout is active. The shared summary-card contract must also own stable summary-card geometry for chart-backed cards so row hover, focus, synchronized readouts, or idle header metadata cannot ratchet the sticky summary taller across rerenders.
10. Keep summary chart interaction identity on one shared helper. Summary surfaces that expose row-hover, group-hover, chart-hover, or route-focus-driven chart emphasis must derive page/group/entity scope through `frontend-modern/src/components/shared/summaryCardInteraction.ts` and pass that same resolved scope into card-state, sparkline, and density-map primitives, rather than letting cards read `hovered || focused` while charts listen to a different page-local ID source. Hovering one summary chart must promote that series into the shared active entity so sibling cards highlight the same object instead of keeping chart-local hover islands, and hovering or pinning a workload group header, infrastructure cluster header, or storage pool-group header must scope the matching summary cards through that same shared contract instead of forking a page-local summary filter path. Sibling cards should surface that synchronized hover as one compact header readout through the shared summary-card contract, while the chart under the pointer keeps the only floating tooltip. `frontend-modern/src/components/Recovery/RecoverySummary.tsx` is explicitly outside this interaction dialect: recovery posture cards may share summary framing, but they must not silently grow row/group/chart hover behavior without a separate governed product decision.
11. Keep page summaries page-scoped when table rows enter contextual focus. Route-backed row selection may add a focused label and shared series emphasis, but infrastructure, workloads, and storage summary cards must continue to render the page-level series set instead of collapsing the summary down to the selected row or replacing the global trend view with row-local empty states.

View file

@ -622,7 +622,10 @@ such as `linux` without making the appliance profile a unified-resource
platform. Unified-resource `AgentData` must carry that distinction explicitly:
`agent.platform` is the normalized runtime platform, while
`agent.hostProfile` carries a governed profile id such as `unraid` for
presentation and host/appliance support-floor copy.
presentation and host/appliance support-floor copy. Raw appliance identity
aliases such as `unraid-os` may be accepted only through the generated
host-profile token projection and must resolve to a governed profile id before
they reach platform filters, source IDs, or top-level resource identity.
That same shared source boundary also applies when unified seeds and
supplemental providers coexist. If a canonical unified-resource seed omits an
owned supplemental source such as TrueNAS or VMware, the shared resource API

View file

@ -12,6 +12,8 @@ import {
AGENT_HOST_PROFILE_IDS,
PLATFORM_TYPE_KEYS,
PRESENTATION_ONLY_PLATFORM_IDS,
SOURCE_AGENT_HOST_PROFILE_HOST_IDENTITY_TOKENS,
getAgentHostProfileManifestEntry,
getAgentHostProfileFamily,
getAgentHostProfileRuntimePlatform,
getSourcePlatformCanonicalProjections,
@ -154,6 +156,13 @@ describe('sourcePlatforms', () => {
expect(AGENT_HOST_PROFILE_IDS).toEqual(['unraid']);
expect(getAgentHostProfileFamily('unraid')).toBe('Unraid');
expect(getAgentHostProfileRuntimePlatform('unraid')).toBe('linux');
expect(SOURCE_AGENT_HOST_PROFILE_HOST_IDENTITY_TOKENS.unraid).toEqual([
'unraid',
'unraid-os',
'unraid os',
]);
expect(getAgentHostProfileManifestEntry('unraid-os')?.id).toBe('unraid');
expect(getAgentHostProfileManifestEntry('Unraid OS')?.id).toBe('unraid');
expect(PLATFORM_TYPE_KEYS).not.toContain('unraid');
expect(PRESENTATION_ONLY_PLATFORM_IDS).toContain('unraid');
});

View file

@ -1,11 +1,11 @@
// This file is generated by scripts/release_control/generate_platform_support_frontend_module.py.
// Do not edit by hand.
// Source: docs/release-control/v6/internal/PLATFORM_SUPPORT_MANIFEST.json
// Source SHA256: 769261b515a0bd5baa4d17847a5d04b1f1a93ea0e5a8e0a048e85cd717def2e3
// Source SHA256: d9f0b92b967180e558b838714f7bd45e0ba982306ff8bd94866aa4ab60db5320
export const PLATFORM_SUPPORT_MANIFEST_SOURCE = {
path: 'docs/release-control/v6/internal/PLATFORM_SUPPORT_MANIFEST.json',
sha256: '769261b515a0bd5baa4d17847a5d04b1f1a93ea0e5a8e0a048e85cd717def2e3',
sha256: 'd9f0b92b967180e558b838714f7bd45e0ba982306ff8bd94866aa4ab60db5320',
} as const;
export const PLATFORM_SUPPORT_MANIFEST = {
schemaVersion: 1,
@ -24,7 +24,7 @@ export const PLATFORM_SUPPORT_MANIFEST = {
family: 'Unraid',
governanceState: 'supported',
readinessStage: 'supported',
hostIdentityTokens: ['unraid'],
hostIdentityTokens: ['unraid', 'unraid-os', 'unraid os'],
runtimePlatform: 'linux',
supportFloor: {
setup: 'supported',
@ -507,7 +507,7 @@ export const SOURCE_AGENT_HOST_PROFILE_READINESS_STAGE = {
unraid: 'supported',
} as const;
export const SOURCE_AGENT_HOST_PROFILE_HOST_IDENTITY_TOKENS = {
unraid: ['unraid'],
unraid: ['unraid', 'unraid-os', 'unraid os'],
} as const;
export const SOURCE_AGENT_HOST_PROFILE_SUPPORT_FLOOR = {
unraid: {

View file

@ -225,7 +225,7 @@ func TestBuildConnections_AgentHostAliasesIncludeReportedIdentityHints(t *testin
{
ID: "pi",
Hostname: "pi",
Platform: "unraid",
Platform: "slackware",
OSName: "Unraid",
OSVersion: "7.1.0",
KernelVersion: "6.12.0",

View file

@ -236,6 +236,9 @@ func New(cfg Config) (*Agent, error) {
}
osVersion := strings.TrimSpace(info.PlatformVersion)
osName, osVersion = resolveHostOSIdentity(collector, osName, osVersion)
if profile, ok := platformsupport.AgentHostProfileForIdentity(osName, platform); ok {
platform = platformsupport.NormalizeRuntimePlatformForAgentHostProfile(profile.ID, platform)
}
kernelVersion := strings.TrimSpace(info.KernelVersion)
arch := strings.TrimSpace(info.KernelArch)
if arch == "" {

View file

@ -516,6 +516,87 @@ Platform = QNAP
t.Fatalf("normalisePlatform(unraid) = %q, want linux", got)
}
})
t.Run("unraid normalizes slackware base platform", func(t *testing.T) {
mc := &mockCollector{
goos: "linux",
hostInfoFn: func(context.Context) (*gohost.InfoStat, error) {
return &gohost.InfoStat{
Hostname: "tower",
HostID: "hid",
Platform: "slackware",
PlatformFamily: "slackware",
PlatformVersion: "15.0+",
KernelVersion: "6.12.54-Unraid",
KernelArch: runtime.GOARCH,
}, nil
},
readFileFn: func(name string) ([]byte, error) {
switch name {
case "/etc/unraid-version":
return []byte(`version="7.2.2"`), nil
default:
return nil, os.ErrNotExist
}
},
}
agent, err := New(Config{APIToken: "token", LogLevel: zerolog.InfoLevel, Collector: mc})
if err != nil {
t.Fatalf("New: %v", err)
}
if agent.platform != "linux" {
t.Fatalf("platform = %q, want linux", agent.platform)
}
if agent.osName != "Unraid" {
t.Fatalf("osName = %q, want Unraid", agent.osName)
}
if agent.osVersion != "7.2.2" {
t.Fatalf("osVersion = %q, want 7.2.2", agent.osVersion)
}
})
t.Run("unraid from os release fallback", func(t *testing.T) {
mc := &mockCollector{
goos: "linux",
hostInfoFn: func(context.Context) (*gohost.InfoStat, error) {
return &gohost.InfoStat{
Hostname: "tower",
HostID: "hid",
Platform: "slackware",
PlatformFamily: "slackware",
PlatformVersion: "15.0+",
KernelArch: runtime.GOARCH,
}, nil
},
readFileFn: func(name string) ([]byte, error) {
switch name {
case "/etc/os-release":
return []byte(`NAME="Unraid OS"
ID="unraid-os"
VERSION_ID="7.2.2"
PRETTY_NAME="Unraid OS 7.2 x86_64"
`), nil
default:
return nil, os.ErrNotExist
}
},
}
agent, err := New(Config{APIToken: "token", LogLevel: zerolog.InfoLevel, Collector: mc})
if err != nil {
t.Fatalf("New: %v", err)
}
if agent.platform != "linux" {
t.Fatalf("platform = %q, want linux", agent.platform)
}
if agent.osName != "Unraid" {
t.Fatalf("osName = %q, want Unraid", agent.osName)
}
if agent.osVersion != "7.2.2" {
t.Fatalf("osVersion = %q, want 7.2.2", agent.osVersion)
}
})
}
func TestNew_UsesCustomCABundleForHTTPTransport(t *testing.T) {

View file

@ -153,16 +153,39 @@ func detectQNAPOSIdentity(collector SystemCollector) (string, string, bool) {
func detectUnraidOSIdentity(collector SystemCollector) (string, string, bool) {
data, err := collector.ReadFile(hostAgentUnraidVersionPath)
if err != nil {
if _, statErr := collector.Stat(hostAgentUnraidVersionPath); statErr != nil {
return "", "", false
if _, statErr := collector.Stat(hostAgentUnraidVersionPath); statErr == nil {
return "Unraid", "", true
}
return "Unraid", "", true
return detectUnraidOSReleaseIdentity(collector)
}
version := cleanUnraidVersion(string(data))
return "Unraid", version, true
}
func detectUnraidOSReleaseIdentity(collector SystemCollector) (string, string, bool) {
data, err := collector.ReadFile("/etc/os-release")
if err != nil || len(data) == 0 {
return "", "", false
}
values := parseAssignmentConfig(string(data))
hints := strings.ToLower(strings.Join([]string{
values["id"],
values["name"],
values["pretty_name"],
}, " "))
if !strings.Contains(hints, "unraid") {
return "", "", false
}
version := strings.TrimSpace(values["version_id"])
if version == "" {
version = cleanUnraidVersion(values["version"])
}
return "Unraid", version, true
}
func cleanUnraidVersion(raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" {

View file

@ -1,7 +1,7 @@
// Code generated by scripts/release_control/generate_platform_support_backend_module.py.
// DO NOT EDIT.
// Source: docs/release-control/v6/internal/PLATFORM_SUPPORT_MANIFEST.json
// Source SHA256: 769261b515a0bd5baa4d17847a5d04b1f1a93ea0e5a8e0a048e85cd717def2e3
// Source SHA256: d9f0b92b967180e558b838714f7bd45e0ba982306ff8bd94866aa4ab60db5320
package platformsupport
@ -13,7 +13,7 @@ const ManifestSourcePath = "docs/release-control/v6/internal/PLATFORM_SUPPORT_MA
// ManifestSourceSHA256 is the sha256 of the manifest bytes used to produce
// this projection.
const ManifestSourceSHA256 = "769261b515a0bd5baa4d17847a5d04b1f1a93ea0e5a8e0a048e85cd717def2e3"
const ManifestSourceSHA256 = "d9f0b92b967180e558b838714f7bd45e0ba982306ff8bd94866aa4ab60db5320"
// ManifestSchemaVersion is the schema_version field of the manifest.
const ManifestSchemaVersion = 1
@ -35,7 +35,7 @@ var agentHostProfileEntries = []AgentHostProfileEntry{
Family: "Unraid",
GovernanceState: "supported",
ReadinessStage: "supported",
HostIdentityTokens: []string{"unraid"},
HostIdentityTokens: []string{"unraid", "unraid-os", "unraid os"},
RuntimePlatform: "linux",
},
}
@ -86,10 +86,7 @@ func NormalizeRuntimePlatformForAgentHostProfile(profileID, platform string) str
if !ok || strings.TrimSpace(profile.RuntimePlatform) == "" {
return reportedPlatform
}
if reportedPlatform == "" || agentHostProfileMatchesIdentity(profile, reportedPlatform) {
return profile.RuntimePlatform
}
return reportedPlatform
return profile.RuntimePlatform
}
func (profile AgentHostProfileEntry) MatchesIdentity(value string) bool {

View file

@ -13,6 +13,9 @@ func TestAgentHostProfileResolverUsesManifestTokens(t *testing.T) {
if profile.RuntimePlatform != "linux" {
t.Fatalf("runtime platform = %q, want linux", profile.RuntimePlatform)
}
if profile, ok := AgentHostProfileForIdentity("unraid-os"); !ok || profile.ID != "unraid" {
t.Fatalf("expected raw os-release Unraid identity token to resolve, got %+v ok=%v", profile, ok)
}
profile.HostIdentityTokens[0] = "mutated"
profile, ok = AgentHostProfileForIdentity("unraid")
@ -34,6 +37,9 @@ func TestNormalizeRuntimePlatformForAgentHostProfile(t *testing.T) {
if got := NormalizeRuntimePlatformForAgentHostProfile("unraid", "linux"); got != "linux" {
t.Fatalf("canonical reported platform = %q, want linux", got)
}
if got := NormalizeRuntimePlatformForAgentHostProfile("unraid", "slackware"); got != "linux" {
t.Fatalf("profiled distro platform = %q, want linux", got)
}
if got := NormalizeRuntimePlatformForAgentHostProfile("unknown", "unraid"); got != "unraid" {
t.Fatalf("unknown host profile platform = %q, want original value", got)
}

View file

@ -146,11 +146,10 @@ func TestResourceFromHostProjectsAgentHostProfile(t *testing.T) {
host := models.Host{
ID: "tower-host",
Hostname: "tower",
Platform: "unraid",
Platform: "slackware",
OSName: "Unraid",
OSVersion: "7.1.0",
Status: "online",
Unraid: &models.HostUnraidStorage{ArrayStarted: true},
}
resource, _ := resourceFromHost(host)

View file

@ -229,10 +229,7 @@ def render_module(normalized: dict[str, Any], manifest_hash: str) -> str:
"\tif !ok || strings.TrimSpace(profile.RuntimePlatform) == \"\" {\n"
"\t\treturn reportedPlatform\n"
"\t}\n"
"\tif reportedPlatform == \"\" || agentHostProfileMatchesIdentity(profile, reportedPlatform) {\n"
"\t\treturn profile.RuntimePlatform\n"
"\t}\n"
"\treturn reportedPlatform\n"
"\treturn profile.RuntimePlatform\n"
"}\n\n"
"func (profile AgentHostProfileEntry) MatchesIdentity(value string) bool {\n"
"\treturn agentHostProfileMatchesIdentity(profile, value)\n"