diff --git a/.ciagent/CHECKPOINT.json b/.ciagent/CHECKPOINT.json index a44bd24..8c3ece7 100644 --- a/.ciagent/CHECKPOINT.json +++ b/.ciagent/CHECKPOINT.json @@ -1,11 +1,11 @@ { - "phase": 0, - "stage": "plan", + "phase": 1, + "stage": "execute", "milestone": "v0.6", "milestone_slug": "node-bootstrap-proxmox", - "phase_role": "pre_execution", + "phase_role": "execution", "attempts": 0, - "updated_at": "2026-08-03T20:45:00Z", + "updated_at": "2026-08-03T19:48:00Z", "milestone_complete": false, "next_milestone": null } \ No newline at end of file diff --git a/internal/cli/init.go b/internal/cli/init.go index 7a95ab9..a4ae085 100644 --- a/internal/cli/init.go +++ b/internal/cli/init.go @@ -1,35 +1,194 @@ package cli import ( + "context" "fmt" "os" + "time" + "github.com/google/uuid" "github.com/spf13/cobra" "git.cloudinit.dev/coreci/orca/internal/certpaths" + "git.cloudinit.dev/coreci/orca/internal/model" + "git.cloudinit.dev/coreci/orca/internal/security" + "git.cloudinit.dev/coreci/orca/internal/store" +) + +const ( + initCAN = "orca-internal-ca" + localhostName = "localhost" + localhostAddr = "localhost:8443" ) var initCmd = &cobra.Command{ Use: "init", - Short: "Initialize local orca state directory", - Long: "Create the local orca state directory (honors $ORCA_HOME; defaults to ~/.orca) and write a default config file.", + Short: "Initialize local orca state with full bootstrap", + Long: `Initialize the local orca state directory and provision all +dependencies required for ` + "`orca doctor`" + ` to pass: + + 1. Create the namespace directory (honors $ORCA_HOME; defaults to ~/.orca) + 2. Open and migrate the SQLite database (migrations 0001..0006) + 3. Bootstrap the internal CA (ca.crt + ca.key) if not already present + 4. Generate the server cert (server.crt + server.key) if not already present + 5. Auto-detect the local OS via /etc/os-release + 6. Register a localhost node (kind=localhost, os=) + +Idempotent: re-running is safe and will refresh last_seen + os on the +localhost node without regenerating certs or changing the node ID.`, RunE: func(cmd *cobra.Command, args []string) error { - orcaDir := certpaths.Dir() - if err := os.MkdirAll(orcaDir, 0o755); err != nil { - return fmt.Errorf("create orca dir: %w", err) - } - result := map[string]string{ - "path": orcaDir, - "status": "initialized", - } - if jsonOutput { - return printJSON(result) - } - printText("✓ Initialized orca state at %s\n", orcaDir) - return nil + return runInit(cmd.OutOrStdout()) }, } +func runInit(out interface{ Write([]byte) (int, error) }) error { + dir := certpaths.Dir() + + type stepResult struct { + Label string `json:"label"` + Status string `json:"status"` + Detail string `json:"detail,omitempty"` + } + type initSummary struct { + Namespace string `json:"namespace"` + Database string `json:"database"` + CAFingerprint string `json:"ca_fingerprint,omitempty"` + CertFingerprint string `json:"cert_fingerprint,omitempty"` + OS string `json:"os"` + NodeID string `json:"node_id"` + NodeName string `json:"node_name"` + Steps []stepResult `json:"steps"` + } + summary := initSummary{Namespace: dir} + + // Step 1: namespace dir. + if err := os.MkdirAll(dir, 0o755); err != nil { + return fmt.Errorf("create orca dir: %w", err) + } + summary.Steps = append(summary.Steps, stepResult{Label: "namespace", Status: "ok", Detail: dir}) + if !jsonOutput { + fmt.Fprintf(out, "✓ Namespace dir: %s\n", dir) + } + + // Step 2: database + migrations. + dbPath := certpaths.DBPath() + db, err := store.Open(dbPath) + if err != nil { + return fmt.Errorf("open database: %w", err) + } + defer db.Close() + summary.Database = dbPath + summary.Steps = append(summary.Steps, stepResult{Label: "database", Status: "ok", Detail: dbPath}) + if !jsonOutput { + fmt.Fprintf(out, "✓ Database initialized: %s\n", dbPath) + } + + // Step 3: CA bootstrap (idempotent — CAInit has a fast-path). + ca, err := security.CAInit(dir, initCAN) + if err != nil { + return fmt.Errorf("bootstrap CA: %w", err) + } + caFp := ca.Fingerprint() + summary.CAFingerprint = caFp + summary.Steps = append(summary.Steps, stepResult{Label: "ca", Status: "ok", Detail: caFp[:16] + "..."}) + if !jsonOutput { + fmt.Fprintf(out, "✓ CA provisioned: fp=%s\n", caFp[:16]+"...") + } + + // Step 4: server cert (only if absent — D-036 idempotency). + certPath := certpaths.ServerCertPath() + certFp := "" + if _, err := os.Stat(certPath); err == nil { + // Already exists — load fingerprint for the summary. + if fp, err := security.Fingerprint(certPath); err == nil { + certFp = fp + } + summary.Steps = append(summary.Steps, stepResult{Label: "server-cert", Status: "skipped", Detail: "already present"}) + } else if os.IsNotExist(err) { + keyPEM, csrPEM, err := security.GenerateCSR("localhost", []string{"localhost", "127.0.0.1"}) + if err != nil { + return fmt.Errorf("generate server CSR: %w", err) + } + certPEM, err := ca.SignCSR(csrPEM) + if err != nil { + return fmt.Errorf("sign server CSR: %w", err) + } + if err := security.WriteCert(certPath, certPEM); err != nil { + return fmt.Errorf("write server cert: %w", err) + } + if err := security.WriteKey(certpaths.ServerKeyPath(), keyPEM); err != nil { + return fmt.Errorf("write server key: %w", err) + } + certFp = security.FingerprintOf(parseFirstCertDER(certPEM)) + summary.Steps = append(summary.Steps, stepResult{Label: "server-cert", Status: "ok", Detail: certFp[:16] + "..."}) + } else { + return fmt.Errorf("stat server cert: %w", err) + } + summary.CertFingerprint = certFp + if !jsonOutput { + if certFp != "" { + fmt.Fprintf(out, "✓ Server cert provisioned: fp=%s\n", certFp[:16]+"...") + } else { + fmt.Fprintf(out, "✓ Server cert: already present\n") + } + } + + // Step 5: OS detection. + osDetected := detectOS() + summary.OS = osDetected + summary.Steps = append(summary.Steps, stepResult{Label: "os", Status: "ok", Detail: osDetected}) + if !jsonOutput { + fmt.Fprintf(out, "✓ OS detected: %s\n", osDetected) + } + + // Step 6: localhost node upsert (idempotent per D-036). + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + repo := store.NewNodeRepo(db) + existing, err := repo.GetByName(ctx, localhostName) + if err == nil { + // Refresh last_seen + os; keep id and joined_at. + if err := repo.UpdateLastSeenAndOS(ctx, existing.ID, osDetected); err != nil { + return fmt.Errorf("refresh localhost node: %w", err) + } + summary.NodeID = existing.ID + summary.NodeName = existing.Name + summary.Steps = append(summary.Steps, stepResult{Label: "localhost-node", Status: "refreshed", Detail: existing.ID}) + if !jsonOutput { + fmt.Fprintf(out, "✓ Localhost node refreshed: %s (os=%s)\n", existing.ID, osDetected) + } + } else if err == store.ErrNotFound { + node := &model.Node{ + ID: uuid.NewString(), + Name: localhostName, + Address: localhostAddr, + State: model.NodeStateReady, + JoinedAt: time.Now().UTC(), + LastSeen: time.Now().UTC(), + Kind: string(model.NodeKindLocalhost), + OS: osDetected, + } + if err := repo.Insert(ctx, node); err != nil { + return fmt.Errorf("insert localhost node: %w", err) + } + summary.NodeID = node.ID + summary.NodeName = node.Name + summary.Steps = append(summary.Steps, stepResult{Label: "localhost-node", Status: "ok", Detail: node.ID}) + if !jsonOutput { + fmt.Fprintf(out, "✓ Localhost node registered: %s (os=%s)\n", node.ID, osDetected) + } + } else { + return fmt.Errorf("lookup localhost node: %w", err) + } + + if jsonOutput { + return printJSON(summary) + } + fmt.Fprintf(out, "\n✓ orca init complete — run `orca doctor` to verify.\n") + return nil +} + func init() { rootCmd.AddCommand(initCmd) } diff --git a/internal/cli/init_test.go b/internal/cli/init_test.go new file mode 100644 index 0000000..b9d2a6a --- /dev/null +++ b/internal/cli/init_test.go @@ -0,0 +1,205 @@ +package cli + +import ( + "context" + "io" + "os" + "path/filepath" + "testing" + "time" + + "git.cloudinit.dev/coreci/orca/internal/certpaths" + "git.cloudinit.dev/coreci/orca/internal/model" + "git.cloudinit.dev/coreci/orca/internal/store" +) + +// initTestEnv sets ORCA_HOME to a temp dir and returns a cleanup func. +func initTestEnv(t *testing.T) (string, func()) { + t.Helper() + dir := t.TempDir() + orig := os.Getenv("ORCA_HOME") + if err := os.Setenv("ORCA_HOME", dir); err != nil { + t.Fatalf("set ORCA_HOME: %v", err) + } + return dir, func() { + if err := os.Setenv("ORCA_HOME", orig); err != nil { + t.Fatalf("restore ORCA_HOME: %v", err) + } + } +} + +// discardWriter is an io.Writer that discards all output (for tests +// that don't need to inspect init stdout). +type discardWriter struct{} + +func (discardWriter) Write(p []byte) (int, error) { return len(p), nil } + +var _ io.Writer = discardWriter{} + +func TestInit_FullBootstrap(t *testing.T) { + dir, cleanup := initTestEnv(t) + defer cleanup() + + if err := runInit(discardWriter{}); err != nil { + t.Fatalf("init: %v", err) + } + + // Verify namespace dir exists. + if _, err := os.Stat(dir); err != nil { + t.Errorf("namespace dir missing: %v", err) + } + + // Verify CA files exist with correct modes. + caCert := certpaths.CACertPath() + caKey := certpaths.CAKeyPath() + if _, err := os.Stat(caCert); err != nil { + t.Errorf("ca.crt missing: %v", err) + } + if info, err := os.Stat(caKey); err == nil { + if info.Mode().Perm() != 0o600 { + t.Errorf("ca.key mode = %04o, want 0600", info.Mode().Perm()) + } + } else { + t.Errorf("ca.key missing: %v", err) + } + + // Verify server cert exists. + if _, err := os.Stat(certpaths.ServerCertPath()); err != nil { + t.Errorf("server.crt missing: %v", err) + } + + // Verify DB exists and has migrations applied. + db, err := store.Open(certpaths.DBPath()) + if err != nil { + t.Fatalf("open db: %v", err) + } + defer db.Close() + + ctx := context.Background() + version, err := store.MigrationVersion(ctx, db) + if err != nil { + t.Fatalf("migration version: %v", err) + } + if version != "0006_node_kind_os.sql" { + t.Errorf("migration version = %q, want 0006_node_kind_os.sql", version) + } + + // Verify localhost node registered with kind=localhost. + repo := store.NewNodeRepo(db) + node, err := repo.GetByName(ctx, "localhost") + if err != nil { + t.Fatalf("get localhost node: %v", err) + } + if node.Kind != string(model.NodeKindLocalhost) { + t.Errorf("node kind = %q, want localhost", node.Kind) + } + if node.OS == "" { + t.Errorf("node os is empty, expected detected value") + } + if node.Address != "localhost:8443" { + t.Errorf("node address = %q, want localhost:8443", node.Address) + } +} + +func TestInit_IdempotentReRun(t *testing.T) { + _, cleanup := initTestEnv(t) + defer cleanup() + + // First init. + if err := runInit(discardWriter{}); err != nil { + t.Fatalf("first init: %v", err) + } + + // Capture first-run state. + caCertBefore, _ := os.ReadFile(certpaths.CACertPath()) + serverCertBefore, _ := os.ReadFile(certpaths.ServerCertPath()) + + db, err := store.Open(certpaths.DBPath()) + if err != nil { + t.Fatalf("open db: %v", err) + } + repo := store.NewNodeRepo(db) + ctx := context.Background() + nodeBefore, err := repo.GetByName(ctx, "localhost") + if err != nil { + t.Fatalf("get node before: %v", err) + } + nodeIDBefore := nodeBefore.ID + joinedAtBefore := nodeBefore.JoinedAt + if err := db.Close(); err != nil { + t.Fatalf("close db: %v", err) + } + + // Wait a moment so last_seen can differ. + time.Sleep(50 * time.Millisecond) + + // Second init (should be idempotent). + if err := runInit(discardWriter{}); err != nil { + t.Fatalf("second init: %v", err) + } + + // CA and server cert must NOT have been regenerated. + caCertAfter, _ := os.ReadFile(certpaths.CACertPath()) + serverCertAfter, _ := os.ReadFile(certpaths.ServerCertPath()) + if string(caCertBefore) != string(caCertAfter) { + t.Error("CA was regenerated on re-run (D-036 violation)") + } + if string(serverCertBefore) != string(serverCertAfter) { + t.Error("server cert was regenerated on re-run (D-036 violation)") + } + + // Node ID and joined_at must be unchanged; last_seen should be refreshed. + db, err = store.Open(certpaths.DBPath()) + if err != nil { + t.Fatalf("reopen db: %v", err) + } + defer db.Close() + repo = store.NewNodeRepo(db) + nodeAfter, err := repo.GetByName(ctx, "localhost") + if err != nil { + t.Fatalf("get node after: %v", err) + } + if nodeAfter.ID != nodeIDBefore { + t.Errorf("node id changed: was %s, now %s (D-036 violation)", nodeIDBefore, nodeAfter.ID) + } + if !nodeAfter.JoinedAt.Equal(joinedAtBefore) { + t.Errorf("joined_at changed: was %v, now %v (D-036 violation)", joinedAtBefore, nodeAfter.JoinedAt) + } + if !nodeAfter.LastSeen.After(joinedAtBefore) { + t.Errorf("last_seen not refreshed: was %v, now %v", joinedAtBefore, nodeAfter.LastSeen) + } + + // No duplicate localhost nodes. + nodes, err := repo.List(ctx) + if err != nil { + t.Fatalf("list nodes: %v", err) + } + localhostCount := 0 + for _, n := range nodes { + if n.Name == "localhost" { + localhostCount++ + } + } + if localhostCount != 1 { + t.Errorf("found %d localhost nodes, want 1 (idempotency)", localhostCount) + } +} + +func TestInit_NamespaceDirCreation(t *testing.T) { + dir, cleanup := initTestEnv(t) + defer cleanup() + + // The namespace dir is the ORCA_HOME temp dir itself — but let's + // point at a non-existent subdir to test MkdirAll. + subDir := filepath.Join(dir, "nested", "orca-state") + if err := os.Setenv("ORCA_HOME", subDir); err != nil { + t.Fatalf("set ORCA_HOME: %v", err) + } + + if err := runInit(discardWriter{}); err != nil { + t.Fatalf("init with nested dir: %v", err) + } + if _, err := os.Stat(subDir); err != nil { + t.Errorf("nested namespace dir not created: %v", err) + } +} diff --git a/internal/cli/namespace_test.go b/internal/cli/namespace_test.go index c7b92e7..9d0dc84 100644 --- a/internal/cli/namespace_test.go +++ b/internal/cli/namespace_test.go @@ -98,15 +98,23 @@ func TestInitJSONOutput(t *testing.T) { t.Fatalf("init --json: %v", err) } - var result map[string]string + // v0.6: init --json now outputs a full bootstrap summary object. + var result map[string]any if err := json.Unmarshal(bytes.TrimSpace(buf.Bytes()), &result); err != nil { t.Fatalf("unmarshal init output: %v\noutput: %s", err, buf.String()) } - if result["path"] != tmp { - t.Errorf("init --json path = %q, want %q", result["path"], tmp) + if result["namespace"] != tmp { + t.Errorf("init --json namespace = %q, want %q", result["namespace"], tmp) } - if result["status"] != "initialized" { - t.Errorf("init --json status = %q, want %q", result["status"], "initialized") + if result["os"] == nil || result["os"] == "" { + t.Errorf("init --json os is missing/empty") + } + if result["node_id"] == nil || result["node_id"] == "" { + t.Errorf("init --json node_id is missing/empty") + } + steps, ok := result["steps"].([]any) + if !ok || len(steps) < 6 { + t.Errorf("init --json steps: expected 6+ entries, got %v", result["steps"]) } } diff --git a/internal/cli/osdetect.go b/internal/cli/osdetect.go new file mode 100644 index 0000000..62b396e --- /dev/null +++ b/internal/cli/osdetect.go @@ -0,0 +1,60 @@ +package cli + +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"} + +// 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 { + 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 "" +} diff --git a/internal/cli/osdetect_test.go b/internal/cli/osdetect_test.go new file mode 100644 index 0000000..54156c3 --- /dev/null +++ b/internal/cli/osdetect_test.go @@ -0,0 +1,137 @@ +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) + } +} diff --git a/internal/doctor/doctor_test.go b/internal/doctor/doctor_test.go index c34aee8..21fdbf5 100644 --- a/internal/doctor/doctor_test.go +++ b/internal/doctor/doctor_test.go @@ -134,7 +134,7 @@ func TestDBCheck_IntegrityOK(t *testing.T) { if r != ResultPass { t.Errorf("DB check: got %s, want PASS — %s", r, msg) } - if !strings.Contains(msg, "0005") { + if !strings.Contains(msg, "0006") { t.Errorf("DB check message should contain migration version, got: %s", msg) } } diff --git a/internal/model/node.go b/internal/model/node.go index dd10dce..5cffdb8 100644 --- a/internal/model/node.go +++ b/internal/model/node.go @@ -10,6 +10,19 @@ const ( NodeStateLeft NodeState = "left" ) +// NodeKind classifies a node by how it joined the cluster. +type NodeKind string + +const ( + // NodeKindLocalhost is the auto-registered local node from `orca init`. + NodeKindLocalhost NodeKind = "localhost" + // NodeKindLinux is a generic Linux node (ubuntu/debian/alpine) joined + // without a specific type. Reserved for future SSH-join flows. + NodeKindLinux NodeKind = "linux" + // NodeKindProxmox is a Proxmox VE 8/9 host joined via SSH bootstrap. + NodeKindProxmox NodeKind = "proxmox" +) + type Node struct { ID string `json:"id"` Name string `json:"name"` @@ -18,4 +31,10 @@ type Node struct { JoinedAt time.Time `json:"joined_at"` LastSeen time.Time `json:"last_seen"` Metadata map[string]string `json:"metadata,omitempty"` + // Kind classifies the node: localhost | linux | proxmox (REQ-049). + // Empty string for rows created before migration 0006. + Kind string `json:"kind,omitempty"` + // OS is the auto-detected OS identifier from /etc/os-release ID= + // (ubuntu|debian|alpine|pve|linux). Empty for pre-0006 rows. + OS string `json:"os,omitempty"` } diff --git a/internal/store/migrate_test.go b/internal/store/migrate_test.go index 499e9c6..e5d1413 100644 --- a/internal/store/migrate_test.go +++ b/internal/store/migrate_test.go @@ -19,8 +19,8 @@ func TestMigrationVersion(t *testing.T) { if err != nil { t.Fatalf("migration version: %v", err) } - if version != "0005_node_capacity.sql" { - t.Errorf("MigrationVersion = %q, want 0005_node_capacity.sql", version) + if version != "0006_node_kind_os.sql" { + t.Errorf("MigrationVersion = %q, want 0006_node_kind_os.sql", version) } // Empty the migrations table → should return ("", nil). diff --git a/internal/store/migrations/0006_node_kind_os.sql b/internal/store/migrations/0006_node_kind_os.sql new file mode 100644 index 0000000..10c4989 --- /dev/null +++ b/internal/store/migrations/0006_node_kind_os.sql @@ -0,0 +1,9 @@ +-- Node kind and OS columns (v0.6 P01, REQ-049). +-- Nullable for backward compatibility: existing rows get NULL, which +-- the Go scanNode helper maps to "" (empty string). New rows from +-- `orca init` get kind='localhost', os=; proxmox joins get +-- kind='proxmox', os='pve'. +ALTER TABLE nodes ADD COLUMN kind TEXT; +ALTER TABLE nodes ADD COLUMN os TEXT; + +CREATE INDEX IF NOT EXISTS idx_nodes_kind ON nodes(kind); \ No newline at end of file diff --git a/internal/store/node_repo.go b/internal/store/node_repo.go index 70343a8..5107347 100644 --- a/internal/store/node_repo.go +++ b/internal/store/node_repo.go @@ -38,8 +38,8 @@ func (r *NodeRepo) Insert(ctx context.Context, n *model.Node) error { return fmt.Errorf("marshal metadata: %w", err) } _, err = r.db.ExecContext(ctx, - `INSERT INTO nodes (id, name, address, state, joined_at, last_seen, metadata) VALUES (?, ?, ?, ?, ?, ?, ?)`, - n.ID, n.Name, n.Address, string(n.State), n.JoinedAt, n.LastSeen, string(metaJSON)) + `INSERT INTO nodes (id, name, address, state, joined_at, last_seen, metadata, kind, os) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + n.ID, n.Name, n.Address, string(n.State), n.JoinedAt, n.LastSeen, string(metaJSON), n.Kind, n.OS) if err != nil { return fmt.Errorf("insert node: %w", err) } @@ -48,13 +48,19 @@ func (r *NodeRepo) Insert(ctx context.Context, n *model.Node) error { func (r *NodeRepo) Get(ctx context.Context, id string) (*model.Node, error) { row := r.db.QueryRowContext(ctx, - `SELECT id, name, address, state, joined_at, last_seen, metadata FROM nodes WHERE id = ?`, id) + `SELECT id, name, address, state, joined_at, last_seen, metadata, kind, os FROM nodes WHERE id = ?`, id) + return scanNode(row) +} + +func (r *NodeRepo) GetByName(ctx context.Context, name string) (*model.Node, error) { + row := r.db.QueryRowContext(ctx, + `SELECT id, name, address, state, joined_at, last_seen, metadata, kind, os FROM nodes WHERE name = ? ORDER BY joined_at ASC LIMIT 1`, name) return scanNode(row) } func (r *NodeRepo) List(ctx context.Context) ([]*model.Node, error) { rows, err := r.db.QueryContext(ctx, - `SELECT id, name, address, state, joined_at, last_seen, metadata FROM nodes ORDER BY joined_at ASC`) + `SELECT id, name, address, state, joined_at, last_seen, metadata, kind, os FROM nodes ORDER BY joined_at ASC`) if err != nil { return nil, fmt.Errorf("list nodes: %w", err) } @@ -77,7 +83,7 @@ func (r *NodeRepo) Watch(ctx context.Context) iter.Seq[[]*model.Node] { defer ticker.Stop() for { rows, err := r.db.QueryContext(ctx, - `SELECT id, name, address, state, joined_at, last_seen, metadata FROM nodes ORDER BY joined_at ASC`) + `SELECT id, name, address, state, joined_at, last_seen, metadata, kind, os FROM nodes ORDER BY joined_at ASC`) if err != nil { slog.Default().Warn("watch nodes: query failed", "error", err) // fall through to the select to wait for the next tick @@ -119,6 +125,23 @@ func (r *NodeRepo) UpdateState(ctx context.Context, id string, state model.NodeS return nil } +// UpdateLastSeenAndOS refreshes the last_seen timestamp and os field +// of an existing node without changing its id or joined_at. Used by +// `orca init` re-runs to refresh the localhost node (D-036 idempotency). +func (r *NodeRepo) UpdateLastSeenAndOS(ctx context.Context, id, os string) error { + res, err := r.db.ExecContext(ctx, + `UPDATE nodes SET last_seen = ?, os = ? WHERE id = ?`, + time.Now().UTC(), os, id) + if err != nil { + return fmt.Errorf("update node last_seen+os: %w", err) + } + rows, _ := res.RowsAffected() + if rows == 0 { + return ErrNotFound + } + return nil +} + func (r *NodeRepo) Delete(ctx context.Context, id string) error { res, err := r.db.ExecContext(ctx, `DELETE FROM nodes WHERE id = ?`, id) if err != nil { @@ -140,8 +163,10 @@ func scanNode(s scanner) (*model.Node, error) { n model.Node state string metaJSON sql.NullString + kind sql.NullString + os sql.NullString ) - err := s.Scan(&n.ID, &n.Name, &n.Address, &state, &n.JoinedAt, &n.LastSeen, &metaJSON) + err := s.Scan(&n.ID, &n.Name, &n.Address, &state, &n.JoinedAt, &n.LastSeen, &metaJSON, &kind, &os) if err == sql.ErrNoRows { return nil, ErrNotFound } @@ -154,5 +179,8 @@ func scanNode(s scanner) (*model.Node, error) { return nil, fmt.Errorf("unmarshal metadata: %w", err) } } + // Map SQL NULL → "" for backward compatibility with pre-0006 rows. + n.Kind = kind.String + n.OS = os.String return &n, nil } diff --git a/internal/store/node_repo_test.go b/internal/store/node_repo_test.go index fab5ef0..5d40102 100644 --- a/internal/store/node_repo_test.go +++ b/internal/store/node_repo_test.go @@ -101,6 +101,119 @@ func TestNodeRepo_Delete(t *testing.T) { } } +func TestNodeRepo_KindOS_RoundTrip(t *testing.T) { + repo, cleanup := openTestDB(t) + defer cleanup() + + ctx := context.Background() + n := &model.Node{ + ID: "kind-os-1", Name: "localhost", Address: "localhost:8443", + State: model.NodeStateReady, JoinedAt: time.Now().UTC(), LastSeen: time.Now().UTC(), + Kind: string(model.NodeKindLocalhost), OS: "ubuntu", + } + if err := repo.Insert(ctx, n); err != nil { + t.Fatalf("insert: %v", err) + } + got, err := repo.Get(ctx, "kind-os-1") + if err != nil { + t.Fatalf("get: %v", err) + } + if got.Kind != "localhost" { + t.Errorf("kind = %q, want localhost", got.Kind) + } + if got.OS != "ubuntu" { + t.Errorf("os = %q, want ubuntu", got.OS) + } +} + +func TestNodeRepo_NullKindOS_EmptyString(t *testing.T) { + repo, cleanup := openTestDB(t) + defer cleanup() + + ctx := context.Background() + // Insert with empty Kind/OS — simulates a pre-0006 row or a node + // that doesn't set kind/os. + n := &model.Node{ + ID: "null-kind-os", Name: "legacy", Address: "addr", + JoinedAt: time.Now().UTC(), LastSeen: time.Now().UTC(), + } + if err := repo.Insert(ctx, n); err != nil { + t.Fatalf("insert: %v", err) + } + got, err := repo.Get(ctx, "null-kind-os") + if err != nil { + t.Fatalf("get: %v", err) + } + if got.Kind != "" { + t.Errorf("kind = %q, want empty string for NULL", got.Kind) + } + if got.OS != "" { + t.Errorf("os = %q, want empty string for NULL", got.OS) + } +} + +func TestNodeRepo_GetByName(t *testing.T) { + repo, cleanup := openTestDB(t) + defer cleanup() + + ctx := context.Background() + _ = repo.Insert(ctx, &model.Node{ + ID: "by-name-1", Name: "localhost", Address: "addr", + JoinedAt: time.Now().UTC(), LastSeen: time.Now().UTC(), + Kind: "localhost", OS: "ubuntu", + }) + + got, err := repo.GetByName(ctx, "localhost") + if err != nil { + t.Fatalf("get by name: %v", err) + } + if got.ID != "by-name-1" { + t.Errorf("id = %q, want by-name-1", got.ID) + } + + _, err = repo.GetByName(ctx, "nonexistent") + if err != ErrNotFound { + t.Errorf("expected ErrNotFound, got %v", err) + } +} + +func TestNodeRepo_UpdateLastSeenAndOS(t *testing.T) { + repo, cleanup := openTestDB(t) + defer cleanup() + + ctx := context.Background() + original := time.Now().UTC().Add(-1 * time.Hour) + n := &model.Node{ + ID: "update-os-1", Name: "localhost", Address: "addr", + JoinedAt: original, LastSeen: original, + Kind: "localhost", OS: "ubuntu", + } + if err := repo.Insert(ctx, n); err != nil { + t.Fatalf("insert: %v", err) + } + + if err := repo.UpdateLastSeenAndOS(ctx, "update-os-1", "debian"); err != nil { + t.Fatalf("update last_seen+os: %v", err) + } + + got, err := repo.Get(ctx, "update-os-1") + if err != nil { + t.Fatalf("get: %v", err) + } + if got.OS != "debian" { + t.Errorf("os = %q, want debian", got.OS) + } + if !got.LastSeen.After(original) { + t.Errorf("last_seen not refreshed: %v", got.LastSeen) + } + if !got.JoinedAt.Equal(original) { + t.Errorf("joined_at changed: was %v, now %v (D-036 violation)", original, got.JoinedAt) + } + if got.ID != "update-os-1" { + t.Errorf("id changed: %q (D-036 violation)", got.ID) + } +} + func insertNode(t *testing.T, repo *NodeRepo, ctx context.Context, id, name string) { t.Helper() if err := repo.Insert(ctx, &model.Node{