Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7cfc4b7027 | |||
| f66472fd37 |
@@ -1,11 +1,11 @@
|
|||||||
{
|
{
|
||||||
"phase": 2,
|
"phase": 3,
|
||||||
"stage": "verify",
|
"stage": "verify",
|
||||||
"milestone": "v0.6",
|
"milestone": "v0.6",
|
||||||
"milestone_slug": "node-bootstrap-proxmox",
|
"milestone_slug": "node-bootstrap-proxmox",
|
||||||
"phase_role": "execution",
|
"phase_role": "execution",
|
||||||
"attempts": 0,
|
"attempts": 0,
|
||||||
"updated_at": "2026-08-03T19:57:00Z",
|
"updated_at": "2026-08-03T20:02:00Z",
|
||||||
"milestone_complete": false,
|
"milestone_complete": false,
|
||||||
"next_milestone": null
|
"next_milestone": null
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
# Phase 3 Verification — Orca v0.6 P03
|
||||||
|
|
||||||
|
**Phase**: P03 — Doctor Extensions + Audit Logging
|
||||||
|
**REQ Coverage**: REQ-052
|
||||||
|
**Verification date**: 2026-08-03
|
||||||
|
**Result**: ✅ PASS (all 4 layers)
|
||||||
|
|
||||||
|
## Structural Verification
|
||||||
|
|
||||||
|
- ✅ `go build ./...` — PASS
|
||||||
|
- ✅ `go vet ./...` — PASS
|
||||||
|
- ✅ `gofmt -l .` — PASS
|
||||||
|
- ✅ `make lint` — PASS
|
||||||
|
- ✅ `internal/osdetect` new shared package (extracted from cli to avoid import cycle)
|
||||||
|
- ✅ `doctor.OS()` and `doctor.Proxmox()` follow existing check pattern (Check struct, Result, Run func)
|
||||||
|
- ✅ `doctor.All()` extended with OS + Proxmox in logical order
|
||||||
|
|
||||||
|
## Behavioral Verification
|
||||||
|
|
||||||
|
### REQ-052: doctor os + doctor proxmox + audit logging
|
||||||
|
- ✅ `TestOSCheck_MissingLocalhostNode`: no localhost node → FAIL with clear message
|
||||||
|
- ✅ `TestOSCheck_Match`: stored os matches detected → PASS
|
||||||
|
- ✅ `TestOSCheck_Drift`: stored os differs from detected → WARN ("OS drift: init=debian, now=ubuntu")
|
||||||
|
- ✅ `TestProxmoxCheck_NoProxmoxNodes`: zero proxmox nodes → WARN ("no proxmox nodes registered")
|
||||||
|
- ✅ `TestProxmoxCheck_UnreachableNode`: unreachable proxmox node → FAIL with node name
|
||||||
|
- ✅ E2E: `orca doctor os` → PASS (os=ubuntu matches)
|
||||||
|
- ✅ E2E: `orca doctor proxmox` → WARN (no proxmox nodes)
|
||||||
|
- ✅ E2E: `orca doctor os --json` → valid JSON
|
||||||
|
- ✅ E2E: `orca doctor` (full) → 6 PASS / 1 WARN / 1 FAIL (network=daemon not running, expected)
|
||||||
|
- ✅ osdetect package: 11 tests (ubuntu/debian/alpine/pve parsing, quoted/unquoted, missing ID, comments, fallback)
|
||||||
|
- ✅ Audit logging: proxmox.BootstrapProxmox emits `proxmox.bootstrap_ok` (P02); doctor checks are read-only
|
||||||
|
|
||||||
|
## Security Verification
|
||||||
|
|
||||||
|
- ✅ Doctor checks are strictly read-only (no state changes)
|
||||||
|
- ✅ SSH probe uses orca SSH key (not password) — no password in doctor flow
|
||||||
|
- ✅ TOFU host-key verification via knownhosts.New (D-035)
|
||||||
|
- ✅ 3s timeout per proxmox probe (D-038 bounded-probe-timeout pattern)
|
||||||
|
- ✅ No secrets in doctor output (fingerprints only, never private keys)
|
||||||
|
|
||||||
|
## Quality Verification
|
||||||
|
|
||||||
|
- ✅ `go test -race -count=1 ./...` — all PASS (13 packages)
|
||||||
|
- ✅ Test coverage: osdetect (11 tests), doctor OS (3 tests), doctor Proxmox (2 tests)
|
||||||
|
- ✅ Error wrapping with `fmt.Errorf("...: %w", err)` (REQ-018)
|
||||||
|
- ✅ `context.Context` propagation (REQ-017)
|
||||||
|
- ✅ No goroutine leaks (netDialer cleans up on ctx cancellation)
|
||||||
|
- ✅ D-036: doctor os handles pre-0006 rows (empty os field → WARN)
|
||||||
|
|
||||||
|
## Must-Have Checklist
|
||||||
|
|
||||||
|
- [x] `internal/osdetect/osdetect.go` — Detect + ParseID (shared package)
|
||||||
|
- [x] `internal/osdetect/osdetect_test.go` — 11 tests
|
||||||
|
- [x] `internal/cli/osdetect.go` — thin wrapper
|
||||||
|
- [x] `internal/cli/osdetect_test.go` — delegation test
|
||||||
|
- [x] `internal/doctor/doctor.go` — OS() + Proxmox() checks, All() extended
|
||||||
|
- [x] `internal/doctor/doctor_test.go` — 5 new tests
|
||||||
|
- [x] `internal/cli/doctor.go` — doctor os + doctor proxmox subcommands
|
||||||
|
|
||||||
|
## Escalations
|
||||||
|
|
||||||
|
None.
|
||||||
+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() {
|
func init() {
|
||||||
doctorCmd.AddCommand(doctorCertCmd, doctorNetworkCmd, doctorDBCmd)
|
doctorCmd.AddCommand(doctorCertCmd, doctorNetworkCmd, doctorDBCmd, doctorOSCmd, doctorProxmoxCmd)
|
||||||
rootCmd.AddCommand(doctorCmd)
|
rootCmd.AddCommand(doctorCmd)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,60 +1,10 @@
|
|||||||
package cli
|
package cli
|
||||||
|
|
||||||
import (
|
import "git.cloudinit.dev/coreci/orca/internal/osdetect"
|
||||||
"bufio"
|
|
||||||
"os"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
// osReleasePaths are checked in order for the os-release file. The
|
// detectOS reads /etc/os-release and returns the ID= value.
|
||||||
// freedesktop.org spec says /etc/os-release is the canonical path,
|
// Delegates to internal/osdetect to avoid import cycles with
|
||||||
// with /usr/lib/os-release as a fallback for minimal containers that
|
// internal/doctor (both need OS detection).
|
||||||
// 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.
|
|
||||||
func detectOS() string {
|
func detectOS() string {
|
||||||
for _, p := range osReleasePaths {
|
return osdetect.Detect()
|
||||||
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 ""
|
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-129
@@ -1,137 +1,19 @@
|
|||||||
package cli
|
package cli
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestParseOSReleaseID_Ubuntu(t *testing.T) {
|
// The osdetect parsing/detection logic is tested in
|
||||||
content := `NAME="Ubuntu"
|
// internal/osdetect/osdetect_test.go. These tests verify the cli
|
||||||
VERSION="24.04.4 LTS (Noble Numbat)"
|
// wrapper delegates correctly.
|
||||||
ID=ubuntu
|
|
||||||
ID_LIKE=debian
|
func TestDetectOS_DelegatesToPackage(t *testing.T) {
|
||||||
PRETTY_NAME="Ubuntu 24.04.4 LTS"`
|
// On this host (Ubuntu), detectOS should return "ubuntu" via the
|
||||||
if got := parseOSReleaseID([]byte(content)); got != "ubuntu" {
|
// osdetect package. If /etc/os-release is absent (e.g., in a
|
||||||
t.Errorf("got %q, want ubuntu", got)
|
// minimal container), it returns "linux".
|
||||||
}
|
result := detectOS()
|
||||||
}
|
if result == "" {
|
||||||
|
t.Error("detectOS returned empty string, expected a non-empty OS ID")
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,8 +25,12 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"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/certpaths"
|
||||||
"git.cloudinit.dev/coreci/orca/internal/model"
|
"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/security"
|
||||||
"git.cloudinit.dev/coreci/orca/internal/store"
|
"git.cloudinit.dev/coreci/orca/internal/store"
|
||||||
"git.cloudinit.dev/coreci/orca/internal/transport"
|
"git.cloudinit.dev/coreci/orca/internal/transport"
|
||||||
@@ -68,7 +72,9 @@ func All() []Check {
|
|||||||
CertServer(),
|
CertServer(),
|
||||||
CertExpiry(),
|
CertExpiry(),
|
||||||
CertFingerprint(),
|
CertFingerprint(),
|
||||||
|
OS(),
|
||||||
Network(),
|
Network(),
|
||||||
|
Proxmox(),
|
||||||
DB(),
|
DB(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -300,6 +306,179 @@ func probeHealthz(ctx context.Context, caPath, certPath, keyPath, serverName, ad
|
|||||||
return nil
|
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
|
// loadCert reads a PEM cert from path and parses the first CERTIFICATE
|
||||||
// block.
|
// block.
|
||||||
func loadCert(path string) (*x509.Certificate, error) {
|
func loadCert(path string) (*x509.Certificate, error) {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.cloudinit.dev/coreci/orca/internal/model"
|
"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/security"
|
||||||
"git.cloudinit.dev/coreci/orca/internal/store"
|
"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() {
|
func init() {
|
||||||
// Suppress slog noise during tests.
|
// Suppress slog noise during tests.
|
||||||
_ = os.Setenv("ORCA_LOG_LEVEL", "error")
|
_ = 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