Files
orca/internal/store/node_repo.go
T
Jon Chery 41bcf0a6bf feat(P05): drain + daemon drain-and-stop (REQ-061) + job migrate (REQ-116)
orca node drain <host>: marks draining, stops allocs via SSH, marks
drained. orca daemon drain-and-stop: stops v0.8 daemons on peers.
orca job migrate <name> --to <node>: drain+reschedule composite
(C3=a, not live-migrate). Node states: draining, drained.

---ci---
project: orca
phase: 05
milestone: v0.11
status: execute
---/ci---
2026-08-07 05:17:01 +00:00

205 lines
5.5 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
}
// SetNodeState updates a node's state to the given string value
// (REQ-061). Unlike UpdateState, which takes a model.NodeState, this
// accepts a raw string so callers can set arbitrary states (draining,
// drained) without coupling to every constant in the model package.
func (r *NodeRepo) SetNodeState(ctx context.Context, nodeID, state string) error {
res, err := r.db.ExecContext(ctx,
`UPDATE nodes SET state = ?, last_seen = ? WHERE id = ?`,
state, time.Now().UTC(), nodeID)
if err != nil {
return fmt.Errorf("update node state: %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
}