Files
orca/internal/store/node_repo.go
T
Jon Chery 56fcf8b399 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---
2026-08-03 19:47:59 +00:00

187 lines
4.9 KiB
Go

package store
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"iter"
"log/slog"
"time"
"git.cloudinit.dev/coreci/orca/internal/model"
)
var ErrNotFound = errors.New("not found")
type NodeRepo struct {
db *sql.DB
}
func NewNodeRepo(db *sql.DB) *NodeRepo {
return &NodeRepo{db: db}
}
func (r *NodeRepo) Insert(ctx context.Context, n *model.Node) error {
if n.JoinedAt.IsZero() {
n.JoinedAt = time.Now().UTC()
}
if n.LastSeen.IsZero() {
n.LastSeen = n.JoinedAt
}
if n.State == "" {
n.State = model.NodeStateReady
}
metaJSON, err := json.Marshal(n.Metadata)
if err != nil {
return fmt.Errorf("marshal metadata: %w", err)
}
_, err = r.db.ExecContext(ctx,
`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)
}
return nil
}
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, 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, kind, os FROM nodes ORDER BY joined_at ASC`)
if err != nil {
return nil, fmt.Errorf("list nodes: %w", err)
}
defer rows.Close()
var nodes []*model.Node
for rows.Next() {
n, err := scanNode(rows)
if err != nil {
return nil, err
}
nodes = append(nodes, n)
}
return nodes, rows.Err()
}
func (r *NodeRepo) Watch(ctx context.Context) iter.Seq[[]*model.Node] {
return func(yield func([]*model.Node) bool) {
ticker := time.NewTicker(watchInterval)
defer ticker.Stop()
for {
rows, err := r.db.QueryContext(ctx,
`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
} else {
snapshot := make([]*model.Node, 0)
for rows.Next() {
n, scanErr := scanNode(rows)
if scanErr != nil {
slog.Default().Warn("watch nodes: scan failed", "error", scanErr)
continue
}
snapshot = append(snapshot, n)
}
rows.Close()
if !yield(snapshot) {
return // consumer stopped pulling
}
}
select {
case <-ctx.Done():
return
case <-ticker.C:
}
}
}
}
func (r *NodeRepo) UpdateState(ctx context.Context, id string, state model.NodeState) error {
res, err := r.db.ExecContext(ctx,
`UPDATE nodes SET state = ?, last_seen = ? WHERE id = ?`,
string(state), time.Now().UTC(), id)
if err != nil {
return fmt.Errorf("update node state: %w", err)
}
rows, _ := res.RowsAffected()
if rows == 0 {
return ErrNotFound
}
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 {
return fmt.Errorf("delete node: %w", err)
}
rows, _ := res.RowsAffected()
if rows == 0 {
return ErrNotFound
}
return nil
}
type scanner interface {
Scan(dest ...any) error
}
func scanNode(s scanner) (*model.Node, error) {
var (
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, &kind, &os)
if err == sql.ErrNoRows {
return nil, ErrNotFound
}
if err != nil {
return nil, fmt.Errorf("scan node: %w", err)
}
n.State = model.NodeState(state)
if metaJSON.Valid && metaJSON.String != "" {
if err := json.Unmarshal([]byte(metaJSON.String), &n.Metadata); err != nil {
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
}