feat(P01): orca init full bootstrap + schema 0006

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---
This commit is contained in:
Jon Chery
2026-08-03 19:47:59 +00:00
parent 77dcb32054
commit 56fcf8b399
12 changed files with 771 additions and 33 deletions
+2 -2
View File
@@ -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).
@@ -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=<detected>; 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);
+34 -6
View File
@@ -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
}
+113
View File
@@ -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{