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:
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user