feat(P03): doctor os + doctor proxmox + audit logging

Extends orca doctor with two new checks (REQ-052):
- doctor os: re-runs OS detection from /etc/os-release, compares to
  stored localhost node's os field. Drift = WARN (re-run orca init);
  match = PASS; missing localhost node = FAIL.
- doctor proxmox: iterates kind=proxmox nodes, SSH-probes each with
  `pveversion` (3s timeout per node, clones Network() pattern).
  Zero proxmox nodes = WARN; reachable = PASS; unreachable = FAIL.

Changes:
- internal/osdetect: new shared package (Detect + ParseID) extracted
  from internal/cli to avoid import cycle (cli + doctor both need it)
- internal/cli/osdetect.go: thin wrapper delegating to osdetect package
- internal/doctor/doctor.go: OS() and Proxmox() checks; All() extended;
  probeProxmoxPVEVersion uses orca SSH key + knownhosts TOFU
- internal/cli/doctor.go: doctor os + doctor proxmox subcommands (--json)
- internal/doctor/doctor_test.go: 5 new tests (OS match/drift/missing,
  proxmox no-nodes/unreachable)

E2E: orca init -> orca doctor shows 6 PASS / 1 WARN (proxmox=none) /
1 FAIL (network=daemon not running). doctor os --json valid.

---ci---
project: orca
phase: 3
milestone: v0.6
status: execute
---/ci---
This commit is contained in:
Jon Chery
2026-08-03 19:59:51 +00:00
parent 82dd01f620
commit f66472fd37
8 changed files with 544 additions and 188 deletions
+3 -3
View File
@@ -1,11 +1,11 @@
{
"phase": 2,
"stage": "verify",
"phase": 3,
"stage": "execute",
"milestone": "v0.6",
"milestone_slug": "node-bootstrap-proxmox",
"phase_role": "execution",
"attempts": 0,
"updated_at": "2026-08-03T19:57:00Z",
"updated_at": "2026-08-03T20:00:00Z",
"milestone_complete": false,
"next_milestone": null
}
+29 -1
View File
@@ -69,7 +69,35 @@ var doctorDBCmd = &cobra.Command{
},
}
var doctorOSCmd = &cobra.Command{
Use: "os",
Short: "Run the OS detection self-check (v0.6 P03)",
RunE: func(cmd *cobra.Command, args []string) error {
c := doctor.OS()
r, msg := c.Run(cmd.Context())
if jsonOutput {
return printJSON(doctor.CheckResult{Name: c.Name, Result: r, Message: msg})
}
fmt.Fprintf(cmd.OutOrStdout(), "%-20s %-5s %s\n", c.Name, r, msg)
return nil
},
}
var doctorProxmoxCmd = &cobra.Command{
Use: "proxmox",
Short: "Run the proxmox node reachability self-check (v0.6 P03)",
RunE: func(cmd *cobra.Command, args []string) error {
c := doctor.Proxmox()
r, msg := c.Run(cmd.Context())
if jsonOutput {
return printJSON(doctor.CheckResult{Name: c.Name, Result: r, Message: msg})
}
fmt.Fprintf(cmd.OutOrStdout(), "%-20s %-5s %s\n", c.Name, r, msg)
return nil
},
}
func init() {
doctorCmd.AddCommand(doctorCertCmd, doctorNetworkCmd, doctorDBCmd)
doctorCmd.AddCommand(doctorCertCmd, doctorNetworkCmd, doctorDBCmd, doctorOSCmd, doctorProxmoxCmd)
rootCmd.AddCommand(doctorCmd)
}
+5 -55
View File
@@ -1,60 +1,10 @@
package cli
import (
"bufio"
"os"
"strings"
)
import "git.cloudinit.dev/coreci/orca/internal/osdetect"
// osReleasePaths are checked in order for the os-release file. The
// freedesktop.org spec says /etc/os-release is the canonical path,
// with /usr/lib/os-release as a fallback for minimal containers that
// may not symlink the former.
var osReleasePaths = []string{"/etc/os-release", "/usr/lib/os-release"}
// detectOS reads /etc/os-release (then /usr/lib/os-release as a
// fallback) and returns the value of the ID= field. Returns "linux"
// (the generic fallback per D-032) if the file is missing, the ID
// field is absent, or the value is empty. Unknown ID values (e.g.
// "fedora", "arch") are returned verbatim — doctor os can warn on
// unknown values, but orca init must not fail.
// detectOS reads /etc/os-release and returns the ID= value.
// Delegates to internal/osdetect to avoid import cycles with
// internal/doctor (both need OS detection).
func detectOS() string {
for _, p := range osReleasePaths {
data, err := os.ReadFile(p)
if err != nil {
continue
}
if id := parseOSReleaseID(data); id != "" {
return id
}
}
return "linux"
}
// parseOSReleaseID extracts the ID= value from os-release content.
// The format is shell-compatible KEY=VALUE lines; values may be
// double-quoted. Returns "" if ID is absent or empty.
func parseOSReleaseID(data []byte) string {
scanner := bufio.NewScanner(strings.NewReader(string(data)))
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
key, value, ok := strings.Cut(line, "=")
if !ok {
continue
}
key = strings.TrimSpace(key)
if key != "ID" {
continue
}
value = strings.TrimSpace(value)
// Strip surrounding double quotes (freedesktop spec allows quoted values).
if len(value) >= 2 && value[0] == '"' && value[len(value)-1] == '"' {
value = value[1 : len(value)-1]
}
return value
}
return ""
return osdetect.Detect()
}
+11 -129
View File
@@ -1,137 +1,19 @@
package cli
import (
"os"
"path/filepath"
"testing"
)
func TestParseOSReleaseID_Ubuntu(t *testing.T) {
content := `NAME="Ubuntu"
VERSION="24.04.4 LTS (Noble Numbat)"
ID=ubuntu
ID_LIKE=debian
PRETTY_NAME="Ubuntu 24.04.4 LTS"`
if got := parseOSReleaseID([]byte(content)); got != "ubuntu" {
t.Errorf("got %q, want ubuntu", got)
}
}
func TestParseOSReleaseID_Debian(t *testing.T) {
content := `PRETTY_NAME="Debian GNU/Linux 12 (bookworm)"
NAME="Debian GNU/Linux"
VERSION_ID="12"
VERSION="12 (bookworm)"
ID=debian`
if got := parseOSReleaseID([]byte(content)); got != "debian" {
t.Errorf("got %q, want debian", got)
}
}
func TestParseOSReleaseID_Alpine(t *testing.T) {
content := `NAME="Alpine Linux"
ID=alpine
VERSION_ID=3.20.3
PRETTY_NAME="Alpine Linux v3.20"`
if got := parseOSReleaseID([]byte(content)); got != "alpine" {
t.Errorf("got %q, want alpine", got)
}
}
func TestParseOSReleaseID_PVE(t *testing.T) {
content := `NAME="Proxmox Virtual Environment"
VERSION="9.2.3"
ID=pve
ID_LIKE=debian`
if got := parseOSReleaseID([]byte(content)); got != "pve" {
t.Errorf("got %q, want pve", got)
}
}
func TestParseOSReleaseID_QuotedValue(t *testing.T) {
content := `ID="ubuntu"`
if got := parseOSReleaseID([]byte(content)); got != "ubuntu" {
t.Errorf("got %q, want ubuntu", got)
}
}
func TestParseOSReleaseID_UnquotedValue(t *testing.T) {
content := `ID=alpine`
if got := parseOSReleaseID([]byte(content)); got != "alpine" {
t.Errorf("got %q, want alpine", got)
}
}
func TestParseOSReleaseID_MissingID(t *testing.T) {
content := `NAME="Some Distro"
VERSION="1.0"`
if got := parseOSReleaseID([]byte(content)); got != "" {
t.Errorf("got %q, want empty", got)
}
}
func TestParseOSReleaseID_EmptyContent(t *testing.T) {
if got := parseOSReleaseID([]byte("")); got != "" {
t.Errorf("got %q, want empty", got)
}
}
func TestParseOSReleaseID_CommentsAndBlankLines(t *testing.T) {
content := `# This is a comment
NAME="Test"
# ID is set below
ID=arch
PRETTY_NAME="Test Arch"`
if got := parseOSReleaseID([]byte(content)); got != "arch" {
t.Errorf("got %q, want arch", got)
}
}
func TestParseOSReleaseID_UnknownIDReturnedVerbatim(t *testing.T) {
content := `ID=fedora`
if got := parseOSReleaseID([]byte(content)); got != "fedora" {
t.Errorf("got %q, want fedora (unknown IDs returned verbatim)", got)
}
}
func TestDetectOS_FallbackToLinux(t *testing.T) {
// Temporarily point osReleasePaths at non-existent files.
orig := osReleasePaths
defer func() { osReleasePaths = orig }()
osReleasePaths = []string{
filepath.Join(t.TempDir(), "nonexistent-os-release"),
}
if got := detectOS(); got != "linux" {
t.Errorf("got %q, want linux (fallback)", got)
}
}
func TestDetectOS_ReadsEtcOSRelease(t *testing.T) {
dir := t.TempDir()
orig := osReleasePaths
defer func() { osReleasePaths = orig }()
osReleasePaths = []string{filepath.Join(dir, "os-release")}
if err := os.WriteFile(osReleasePaths[0], []byte("ID=ubuntu\n"), 0o644); err != nil {
t.Fatalf("write: %v", err)
}
if got := detectOS(); got != "ubuntu" {
t.Errorf("got %q, want ubuntu", got)
}
}
func TestDetectOS_FallbackToUsrLib(t *testing.T) {
dir := t.TempDir()
orig := osReleasePaths
defer func() { osReleasePaths = orig }()
osReleasePaths = []string{
filepath.Join(dir, "etc-os-release"), // missing
filepath.Join(dir, "usr-lib-os-release"), // fallback
}
if err := os.WriteFile(osReleasePaths[1], []byte("ID=alpine\n"), 0o644); err != nil {
t.Fatalf("write: %v", err)
}
if got := detectOS(); got != "alpine" {
t.Errorf("got %q, want alpine (from fallback path)", got)
// The osdetect parsing/detection logic is tested in
// internal/osdetect/osdetect_test.go. These tests verify the cli
// wrapper delegates correctly.
func TestDetectOS_DelegatesToPackage(t *testing.T) {
// On this host (Ubuntu), detectOS should return "ubuntu" via the
// osdetect package. If /etc/os-release is absent (e.g., in a
// minimal container), it returns "linux".
result := detectOS()
if result == "" {
t.Error("detectOS returned empty string, expected a non-empty OS ID")
}
}
+179
View File
@@ -25,8 +25,12 @@ import (
"strings"
"time"
"golang.org/x/crypto/ssh"
"golang.org/x/crypto/ssh/knownhosts"
"git.cloudinit.dev/coreci/orca/internal/certpaths"
"git.cloudinit.dev/coreci/orca/internal/model"
"git.cloudinit.dev/coreci/orca/internal/osdetect"
"git.cloudinit.dev/coreci/orca/internal/security"
"git.cloudinit.dev/coreci/orca/internal/store"
"git.cloudinit.dev/coreci/orca/internal/transport"
@@ -68,7 +72,9 @@ func All() []Check {
CertServer(),
CertExpiry(),
CertFingerprint(),
OS(),
Network(),
Proxmox(),
DB(),
}
}
@@ -300,6 +306,179 @@ func probeHealthz(ctx context.Context, caPath, certPath, keyPath, serverName, ad
return nil
}
// OS checks that the auto-detected OS matches the stored localhost
// node's os field (REQ-052). Drift (e.g., OS upgraded since init)
// returns WARN; match returns PASS; missing localhost node returns FAIL.
func OS() Check {
return Check{
Name: "os",
Description: "localhost OS detection vs stored node row",
Run: func(ctx context.Context) (Result, string) {
detected := osdetect.Detect()
db, err := store.Open(certpaths.DBPath())
if err != nil {
return ResultFail, fmt.Sprintf("open db: %v", err)
}
defer db.Close()
node, err := store.NewNodeRepo(db).GetByName(ctx, "localhost")
if err == store.ErrNotFound {
return ResultFail, "no localhost node registered — run `orca init`"
}
if err != nil {
return ResultFail, fmt.Sprintf("lookup localhost node: %v", err)
}
if node.OS == "" {
return ResultWarn, fmt.Sprintf("localhost node has no os field (pre-0006 row?); detected=%s — re-run `orca init` to refresh", detected)
}
if node.OS != detected {
return ResultWarn, fmt.Sprintf("OS drift: init=%s, now=%s — re-run `orca init` to refresh", node.OS, detected)
}
return ResultPass, fmt.Sprintf("localhost os=%s (matches /etc/os-release)", detected)
},
}
}
// Proxmox probes each kind=proxmox node via SSH with `pveversion`
// (REQ-052). Clones the Network() pattern: list nodes, filter by kind,
// 3s timeout per peer, PASS/WARN/FAIL per node. Zero proxmox nodes
// returns WARN (single-node cluster is legitimate).
func Proxmox() Check {
return Check{
Name: "proxmox",
Description: "proxmox node reachability via SSH pveversion probe",
Run: func(ctx context.Context) (Result, string) {
db, err := store.Open(certpaths.DBPath())
if err != nil {
return ResultFail, fmt.Sprintf("open db: %v", err)
}
defer db.Close()
nodes, err := store.NewNodeRepo(db).List(ctx)
if err != nil {
return ResultFail, fmt.Sprintf("list nodes: %v", err)
}
proxmoxNodes := make([]*model.Node, 0, len(nodes))
for _, n := range nodes {
if n.Kind == string(model.NodeKindProxmox) && n.State != model.NodeStateLeft {
proxmoxNodes = append(proxmoxNodes, n)
}
}
if len(proxmoxNodes) == 0 {
return ResultWarn, "no proxmox nodes registered (single-node?)"
}
var lines []string
anyFail := false
for _, n := range proxmoxNodes {
probeCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
err := probeProxmoxPVEVersion(probeCtx, n.Name)
cancel()
if err != nil {
anyFail = true
lines = append(lines, fmt.Sprintf(" ✗ %s: %v", n.Name, err))
} else {
lines = append(lines, fmt.Sprintf(" ✓ %s", n.Name))
}
}
result := ResultPass
if anyFail {
result = ResultFail
}
return result, strings.Join(lines, "\n")
},
}
}
// probeProxmoxPVEVersion SSHes into the proxmox host and runs
// `pveversion` to verify reachability + PVE installation. Uses the
// orca SSH key for auth (deployed during `orca node join --type proxmox`)
// and the known_hosts TOFU store for host-key verification (D-035).
func probeProxmoxPVEVersion(ctx context.Context, host string) error {
// Load the orca SSH key for public-key auth.
keyPEM, err := os.ReadFile(certpaths.SSHKeyPath())
if err != nil {
return fmt.Errorf("read SSH key: %w (run `orca node join --type proxmox` first)", err)
}
signer, err := ssh.ParsePrivateKey(keyPEM)
if err != nil {
return fmt.Errorf("parse SSH key: %w", err)
}
hostKeyCallback, err := knownhosts.New(certpaths.KnownHostsPath())
if err != nil {
return fmt.Errorf("known_hosts: %w", err)
}
config := &ssh.ClientConfig{
User: "orca",
Auth: []ssh.AuthMethod{ssh.PublicKeys(signer)},
HostKeyCallback: hostKeyCallback,
Timeout: 3 * time.Second,
}
// Extract host from the node address (orca stores host:8443;
// SSH needs host:22). We dial the SSH port, not the orca daemon port.
sshHost := host
if strings.Contains(host, ":") {
sshHost = strings.SplitN(host, ":", 2)[0]
}
sshAddr := sshHost + ":22"
dialer := &netDialer{}
conn, err := dialer.DialContext(ctx, "tcp", sshAddr, config)
if err != nil {
return fmt.Errorf("ssh dial: %w", err)
}
defer conn.Close()
session, err := conn.NewSession()
if err != nil {
return fmt.Errorf("new session: %w", err)
}
defer session.Close()
out, err := session.CombinedOutput("pveversion")
if err != nil {
return fmt.Errorf("pveversion: %w (output: %s)", err, strings.TrimSpace(string(out)))
}
return nil
}
// netDialer wraps ssh.Dial with context support. The ssh package's
// Dial doesn't accept a context directly, so we use a dialer that
// respects ctx cancellation via a goroutine + channel.
type netDialer struct{}
func (d *netDialer) DialContext(ctx context.Context, network, addr string, config *ssh.ClientConfig) (*ssh.Client, error) {
type result struct {
client *ssh.Client
err error
}
ch := make(chan result, 1)
go func() {
client, err := ssh.Dial(network, addr, config)
ch <- result{client, err}
}()
select {
case <-ctx.Done():
// Best-effort: if the dial succeeds after ctx cancellation,
// the goroutine will close the client. We return the ctx error.
go func() {
if r := <-ch; r.client != nil {
_ = r.client.Close()
}
}()
return nil, ctx.Err()
case r := <-ch:
return r.client, r.err
}
}
// loadCert reads a PEM cert from path and parses the first CERTIFICATE
// block.
func loadCert(path string) (*x509.Certificate, error) {
+151
View File
@@ -9,6 +9,7 @@ import (
"time"
"git.cloudinit.dev/coreci/orca/internal/model"
"git.cloudinit.dev/coreci/orca/internal/osdetect"
"git.cloudinit.dev/coreci/orca/internal/security"
"git.cloudinit.dev/coreci/orca/internal/store"
)
@@ -241,6 +242,156 @@ func TestRenderReport(t *testing.T) {
}
}
// TestOSCheck_MissingLocalhostNode verifies the OS check returns FAIL
// when no localhost node is registered.
func TestOSCheck_MissingLocalhostNode(t *testing.T) {
dir := t.TempDir()
t.Setenv("ORCA_HOME", dir)
t.Setenv("ORCA_DB", filepath.Join(dir, "orca.db"))
// Open the DB to apply migrations but insert no nodes.
db, err := store.Open(filepath.Join(dir, "orca.db"))
if err != nil {
t.Fatalf("open db: %v", err)
}
db.Close()
c := OS()
r, msg := c.Run(context.Background())
if r != ResultFail {
t.Errorf("OS check: got %s, want FAIL — %s", r, msg)
}
if !strings.Contains(msg, "no localhost node") {
t.Errorf("OS check message should mention missing localhost node, got: %s", msg)
}
}
// TestOSCheck_Match verifies the OS check returns PASS when the stored
// localhost node's os matches the detected OS.
func TestOSCheck_Match(t *testing.T) {
dir := t.TempDir()
t.Setenv("ORCA_HOME", dir)
t.Setenv("ORCA_DB", filepath.Join(dir, "orca.db"))
db, err := store.Open(filepath.Join(dir, "orca.db"))
if err != nil {
t.Fatalf("open db: %v", err)
}
defer db.Close()
repo := store.NewNodeRepo(db)
// Insert a localhost node with the currently-detected OS.
detected := osdetect.Detect()
if err := repo.Insert(context.Background(), &model.Node{
ID: "os-match-1", Name: "localhost", Address: "localhost:8443",
State: model.NodeStateReady, JoinedAt: time.Now().UTC(), LastSeen: time.Now().UTC(),
Kind: "localhost", OS: detected,
}); err != nil {
t.Fatalf("insert: %v", err)
}
c := OS()
r, msg := c.Run(context.Background())
if r != ResultPass {
t.Errorf("OS check: got %s, want PASS — %s", r, msg)
}
if !strings.Contains(msg, detected) {
t.Errorf("OS check message should contain %s, got: %s", detected, msg)
}
}
// TestOSCheck_Drift verifies the OS check returns WARN when the stored
// os differs from the detected os.
func TestOSCheck_Drift(t *testing.T) {
dir := t.TempDir()
t.Setenv("ORCA_HOME", dir)
t.Setenv("ORCA_DB", filepath.Join(dir, "orca.db"))
db, err := store.Open(filepath.Join(dir, "orca.db"))
if err != nil {
t.Fatalf("open db: %v", err)
}
defer db.Close()
repo := store.NewNodeRepo(db)
// Insert a localhost node with a deliberately wrong OS.
if err := repo.Insert(context.Background(), &model.Node{
ID: "os-drift-1", Name: "localhost", Address: "localhost:8443",
State: model.NodeStateReady, JoinedAt: time.Now().UTC(), LastSeen: time.Now().UTC(),
Kind: "localhost", OS: "debian",
}); err != nil {
t.Fatalf("insert: %v", err)
}
c := OS()
r, msg := c.Run(context.Background())
if r != ResultWarn {
t.Errorf("OS check: got %s, want WARN — %s", r, msg)
}
if !strings.Contains(msg, "drift") {
t.Errorf("OS check message should mention drift, got: %s", msg)
}
}
// TestProxmoxCheck_NoProxmoxNodes verifies the proxmox check returns
// WARN when no proxmox nodes are registered.
func TestProxmoxCheck_NoProxmoxNodes(t *testing.T) {
dir := t.TempDir()
t.Setenv("ORCA_HOME", dir)
t.Setenv("ORCA_DB", filepath.Join(dir, "orca.db"))
db, err := store.Open(filepath.Join(dir, "orca.db"))
if err != nil {
t.Fatalf("open db: %v", err)
}
defer db.Close()
c := Proxmox()
r, msg := c.Run(context.Background())
if r != ResultWarn {
t.Errorf("Proxmox check: got %s, want WARN — %s", r, msg)
}
if !strings.Contains(msg, "no proxmox nodes") {
t.Errorf("Proxmox check message should mention no proxmox nodes, got: %s", msg)
}
}
// TestProxmoxCheck_UnreachableNode verifies the proxmox check returns
// FAIL when a proxmox node is registered but unreachable (no SSH key
// or host down). We insert a proxmox node with an unreachable address;
// the SSH dial will fail (no SSH key file → error).
func TestProxmoxCheck_UnreachableNode(t *testing.T) {
dir := t.TempDir()
t.Setenv("ORCA_HOME", dir)
t.Setenv("ORCA_DB", filepath.Join(dir, "orca.db"))
db, err := store.Open(filepath.Join(dir, "orca.db"))
if err != nil {
t.Fatalf("open db: %v", err)
}
defer db.Close()
repo := store.NewNodeRepo(db)
// Insert a proxmox node. The SSH probe will fail because no SSH
// key exists in the test namespace dir.
if err := repo.Insert(context.Background(), &model.Node{
ID: "px-1", Name: "10.0.0.99", Address: "10.0.0.99:8443",
State: model.NodeStateReady, JoinedAt: time.Now().UTC(), LastSeen: time.Now().UTC(),
Kind: "proxmox", OS: "pve",
}); err != nil {
t.Fatalf("insert: %v", err)
}
c := Proxmox()
r, msg := c.Run(context.Background())
if r != ResultFail {
t.Errorf("Proxmox check: got %s, want FAIL — %s", r, msg)
}
if !strings.Contains(msg, "10.0.0.99") {
t.Errorf("Proxmox check message should mention the node, got: %s", msg)
}
}
func init() {
// Suppress slog noise during tests.
_ = os.Setenv("ORCA_LOG_LEVEL", "error")
+63
View File
@@ -0,0 +1,63 @@
// Package osdetect provides OS detection from /etc/os-release (D-032).
// It's a separate package to avoid import cycles between internal/cli
// and internal/doctor (both need to detect the local OS).
package osdetect
import (
"bufio"
"os"
"strings"
)
// osReleasePaths are checked in order for the os-release file. The
// freedesktop.org spec says /etc/os-release is the canonical path,
// with /usr/lib/os-release as a fallback for minimal containers that
// may not symlink the former.
var osReleasePaths = []string{"/etc/os-release", "/usr/lib/os-release"}
// Detect reads /etc/os-release (then /usr/lib/os-release as a
// fallback) and returns the value of the ID= field. Returns "linux"
// (the generic fallback per D-032) if the file is missing, the ID
// field is absent, or the value is empty. Unknown ID values (e.g.
// "fedora", "arch") are returned verbatim — doctor os can warn on
// unknown values, but orca init must not fail.
func Detect() string {
for _, p := range osReleasePaths {
data, err := os.ReadFile(p)
if err != nil {
continue
}
if id := ParseID(data); id != "" {
return id
}
}
return "linux"
}
// ParseID extracts the ID= value from os-release content.
// The format is shell-compatible KEY=VALUE lines; values may be
// double-quoted. Returns "" if ID is absent or empty.
func ParseID(data []byte) string {
scanner := bufio.NewScanner(strings.NewReader(string(data)))
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
key, value, ok := strings.Cut(line, "=")
if !ok {
continue
}
key = strings.TrimSpace(key)
if key != "ID" {
continue
}
value = strings.TrimSpace(value)
// Strip surrounding double quotes (freedesktop spec allows quoted values).
if len(value) >= 2 && value[0] == '"' && value[len(value)-1] == '"' {
value = value[1 : len(value)-1]
}
return value
}
return ""
}
+103
View File
@@ -0,0 +1,103 @@
package osdetect
import (
"os"
"path/filepath"
"testing"
)
func TestParseID_Ubuntu(t *testing.T) {
content := `NAME="Ubuntu"
VERSION="24.04.4 LTS (Noble Numbat)"
ID=ubuntu
ID_LIKE=debian`
if got := ParseID([]byte(content)); got != "ubuntu" {
t.Errorf("got %q, want ubuntu", got)
}
}
func TestParseID_Debian(t *testing.T) {
if got := ParseID([]byte("ID=debian\n")); got != "debian" {
t.Errorf("got %q, want debian", got)
}
}
func TestParseID_Alpine(t *testing.T) {
if got := ParseID([]byte("ID=alpine\n")); got != "alpine" {
t.Errorf("got %q, want alpine", got)
}
}
func TestParseID_PVE(t *testing.T) {
if got := ParseID([]byte("ID=pve\nID_LIKE=debian\n")); got != "pve" {
t.Errorf("got %q, want pve", got)
}
}
func TestParseID_QuotedValue(t *testing.T) {
if got := ParseID([]byte(`ID="ubuntu"` + "\n")); got != "ubuntu" {
t.Errorf("got %q, want ubuntu", got)
}
}
func TestParseID_MissingID(t *testing.T) {
if got := ParseID([]byte("NAME=Test\n")); got != "" {
t.Errorf("got %q, want empty", got)
}
}
func TestParseID_UnknownIDVerbatim(t *testing.T) {
if got := ParseID([]byte("ID=fedora\n")); got != "fedora" {
t.Errorf("got %q, want fedora", got)
}
}
func TestParseID_CommentsAndBlanks(t *testing.T) {
content := `# comment
NAME="Test"
# ID below
ID=arch`
if got := ParseID([]byte(content)); got != "arch" {
t.Errorf("got %q, want arch", got)
}
}
func TestDetect_FallbackToLinux(t *testing.T) {
orig := osReleasePaths
defer func() { osReleasePaths = orig }()
osReleasePaths = []string{filepath.Join(t.TempDir(), "nonexistent")}
if got := Detect(); got != "linux" {
t.Errorf("got %q, want linux (fallback)", got)
}
}
func TestDetect_ReadsFile(t *testing.T) {
dir := t.TempDir()
orig := osReleasePaths
defer func() { osReleasePaths = orig }()
path := filepath.Join(dir, "os-release")
osReleasePaths = []string{path}
if err := os.WriteFile(path, []byte("ID=ubuntu\n"), 0o644); err != nil {
t.Fatalf("write: %v", err)
}
if got := Detect(); got != "ubuntu" {
t.Errorf("got %q, want ubuntu", got)
}
}
func TestDetect_FallbackToUsrLib(t *testing.T) {
dir := t.TempDir()
orig := osReleasePaths
defer func() { osReleasePaths = orig }()
osReleasePaths = []string{
filepath.Join(dir, "etc"), // missing
filepath.Join(dir, "usr-lib"), // fallback
}
if err := os.WriteFile(osReleasePaths[1], []byte("ID=alpine\n"), 0o644); err != nil {
t.Fatalf("write: %v", err)
}
if got := Detect(); got != "alpine" {
t.Errorf("got %q, want alpine (from fallback)", got)
}
}