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) }