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