156 lines
4.7 KiB
Go
156 lines
4.7 KiB
Go
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)
|
|
}
|