56fcf8b399
orca init transforms from a bare mkdir into a full single-node cluster bootstrap. After `orca init`, `orca doctor` passes with zero FAILs on the bootstrap checks (CA, cert, db, localhost node). Changes: - migration 0006: nodes.kind + nodes.os nullable columns (REQ-049) - model.Node: Kind + OS fields + NodeKind constants (localhost|linux|proxmox) - NodeRepo: extended Insert/Get/List/Watch/scanNode for kind/os columns (NULL -> "" mapping); added GetByName + UpdateLastSeenAndOS helpers - internal/cli/osdetect.go: detectOS() from /etc/os-release ID= field (D-032); fallback to /usr/lib/os-release then "linux" - internal/cli/init.go: full bootstrap sequence (REQ-047, REQ-048): 1. MkdirAll namespace dir 2. store.Open (runs migrations 0001..0006) 3. security.CAInit (idempotent fast-path) 4. server cert gen if absent (D-036: skip if present) 5. detectOS from /etc/os-release 6. localhost node upsert (insert if new, refresh last_seen+os if exists) Idempotent re-run: no duplicate node, no cert regen, id/joined_at preserved - --json output: full bootstrap summary (namespace, db, ca_fp, cert_fp, os, node_id, steps array) - tests: init idempotency, osdetect parsing (ubuntu/debian/alpine/pve), kind/os round-trip, NULL->"" mapping, GetByName, UpdateLastSeenAndOS E2E smoke test: orca init -> 5 PASS / 0 WARN / 1 FAIL (network=daemon not running, expected); orca node list shows localhost node (os=ubuntu). ---ci--- project: orca phase: 1 milestone: v0.6 status: execute ---/ci---
206 lines
5.6 KiB
Go
206 lines
5.6 KiB
Go
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)
|
|
}
|
|
}
|