erste module drin.

zwischenstand: Architektur aktualisiert, TOFU implementiert,
This commit is contained in:
br committed 2026-10-01 22:22:13 +02:00
1 parent 9d5789c5d7
commit c8df9d7651
41 files changed
+6455 -36

No files matched your search

+155
View File
@@ -0,0 +1,155 @@
package store
import (
"context"
"database/sql"
"errors"
"fmt"
"time"
)
// NodeStatus ist der Verbindungsstatus eines Agenten.
type NodeStatus string
const (
NodeOnline NodeStatus = "online"
NodeOffline NodeStatus = "offline"
NodeDrained NodeStatus = "drained"
)
func (s NodeStatus) valid() bool {
return s == NodeOnline || s == NodeOffline || s == NodeDrained
}
// Node ist ein registrierter Agent.
type Node struct {
ID string
TenantID string
Hostname string
PublicKey string // Ed25519 Public Key, vom Aufrufer kodiert (z. B. Base64)
LastSeen time.Time
IPAddress string
Status NodeStatus
}
func (n *Node) normalize(now time.Time) error {
if n.Status == "" {
n.Status = NodeOnline
}
if n.LastSeen.IsZero() {
n.LastSeen = now
}
n.LastSeen = normTime(n.LastSeen)
switch {
case blank(n.ID):
return invalid("Knoten-ID fehlt")
case blank(n.TenantID):
return invalid("Mandant fehlt")
case blank(n.Hostname):
return invalid("Hostname fehlt")
case blank(n.PublicKey):
return invalid("Public Key fehlt")
case blank(n.IPAddress):
return invalid("IP-Adresse fehlt")
case !n.Status.valid():
return invalid("unbekannter Status %q", n.Status)
}
return nil
}
// CreateNode legt einen Knoten an. Status ist ohne Angabe online, LastSeen
// ohne Angabe jetzt. ID und Public Key müssen eindeutig sein (ErrExists), der
// Mandant muss existieren (ErrNotFound).
func (s *Store) CreateNode(ctx context.Context, n Node) (Node, error) {
return s.insertNode(ctx, s.db, n)
}
func (s *Store) insertNode(ctx context.Context, db execer, n Node) (Node, error) {
if err := n.normalize(s.now()); err != nil {
return Node{}, err
}
_, err := db.ExecContext(ctx, `INSERT INTO nodes
(id, tenant_id, hostname, public_key, last_seen, ip_address, status)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
n.ID, n.TenantID, n.Hostname, n.PublicKey, formatTime(n.LastSeen), n.IPAddress, n.Status)
if err != nil {
return Node{}, mapConstraint(err, "Knoten "+n.ID)
}
return n, nil
}
const nodeColumns = `id, tenant_id, hostname, public_key, last_seen, ip_address, status`
func scanNode(row interface{ Scan(...any) error }) (Node, error) {
var n Node
err := row.Scan(&n.ID, &n.TenantID, &n.Hostname, &n.PublicKey, dbTime{&n.LastSeen}, &n.IPAddress, &n.Status)
return n, err
}
func (s *Store) getNode(ctx context.Context, where, arg, what string) (Node, error) {
n, err := scanNode(s.db.QueryRowContext(ctx, `SELECT `+nodeColumns+` FROM nodes WHERE `+where+` = ?`, arg))
if errors.Is(err, sql.ErrNoRows) {
return Node{}, fmt.Errorf("%s: %w", what, ErrNotFound)
}
return n, err
}
// GetNode liest einen Knoten über seine ID.
func (s *Store) GetNode(ctx context.Context, id string) (Node, error) {
return s.getNode(ctx, "id", id, "Knoten "+id)
}
// GetNodeByPublicKey liest einen Knoten über seinen Public Key (für die
// Prüfung signierter Requests).
func (s *Store) GetNodeByPublicKey(ctx context.Context, publicKey string) (Node, error) {
return s.getNode(ctx, "public_key", publicKey, "Knoten mit diesem Public Key")
}
// ListNodes liefert die Knoten eines Mandanten (tenantID "" = alle), sortiert nach ID.
func (s *Store) ListNodes(ctx context.Context, tenantID string) ([]Node, error) {
q, args := `SELECT `+nodeColumns+` FROM nodes ORDER BY id`, []any{}
if tenantID != "" {
q, args = `SELECT `+nodeColumns+` FROM nodes WHERE tenant_id = ? ORDER BY id`, []any{tenantID}
}
rows, err := s.db.QueryContext(ctx, q, args...)
if err != nil {
return nil, err
}
defer rows.Close()
nodes := []Node{}
for rows.Next() {
n, err := scanNode(rows)
if err != nil {
return nil, err
}
nodes = append(nodes, n)
}
return nodes, rows.Err()
}
// TouchNode vermerkt ein Lebenszeichen: last_seen = jetzt und die aktuelle
// IP. Der Status bleibt unverändert (ein gedrainter Knoten bleibt gedraint).
func (s *Store) TouchNode(ctx context.Context, id, ip string) error {
if blank(ip) {
return invalid("IP-Adresse fehlt")
}
res, err := s.db.ExecContext(ctx, `UPDATE nodes SET last_seen = ?, ip_address = ? WHERE id = ?`,
formatTime(s.now()), ip, id)
return requireAffected(res, err, "Knoten "+id)
}
// SetNodeStatus ändert den Status eines Knotens.
func (s *Store) SetNodeStatus(ctx context.Context, id string, status NodeStatus) error {
if !status.valid() {
return invalid("unbekannter Status %q", status)
}
res, err := s.db.ExecContext(ctx, `UPDATE nodes SET status = ? WHERE id = ?`, status, id)
return requireAffected(res, err, "Knoten "+id)
}
// DeleteNode löscht einen Knoten; sein Public Key wird damit wieder frei.
func (s *Store) DeleteNode(ctx context.Context, id string) error {
res, err := s.db.ExecContext(ctx, `DELETE FROM nodes WHERE id = ?`, id)
return requireAffected(res, err, "Knoten "+id)
}