software steht so weit.
jetzt build + docker build und testen
This commit is contained in:
1 parent
42f0c0accb
commit
ca6cdcca63
90 files changed
+10298
-480
No files matched your search
@@ -0,0 +1,63 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
// PurgeLeases löscht Leases, die vor mehr als olderThan abgelaufen sind.
|
||||
func (s *Store) PurgeLeases(ctx context.Context, olderThan time.Duration) (int64, error) {
|
||||
res, err := s.db.ExecContext(ctx, `DELETE FROM leases WHERE valid_until <= ?`, formatTime(s.now().Add(-olderThan)))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return res.RowsAffected()
|
||||
}
|
||||
|
||||
// PurgeNodeCommands löscht zugestellte Befehle, die älter als olderThan sind.
|
||||
func (s *Store) PurgeNodeCommands(ctx context.Context, olderThan time.Duration) (int64, error) {
|
||||
res, err := s.db.ExecContext(ctx, `DELETE FROM node_commands WHERE sent_at IS NOT NULL AND sent_at <= ?`,
|
||||
formatTime(s.now().Add(-olderThan)))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return res.RowsAffected()
|
||||
}
|
||||
|
||||
// Notified meldet, ob zum Ereignis key schon benachrichtigt wurde.
|
||||
func (s *Store) Notified(ctx context.Context, key string) bool {
|
||||
var one int
|
||||
return s.db.QueryRowContext(ctx, `SELECT 1 FROM notifications WHERE key = ?`, key).Scan(&one) == nil
|
||||
}
|
||||
|
||||
// RecordNotification vermerkt den Versand zu key (mehrfach unschädlich).
|
||||
func (s *Store) RecordNotification(ctx context.Context, key string) error {
|
||||
_, err := s.db.ExecContext(ctx, `INSERT OR IGNORE INTO notifications (key, sent_at) VALUES (?, ?)`,
|
||||
key, formatTime(s.now()))
|
||||
return err
|
||||
}
|
||||
|
||||
// MarkNodeSeen setzt last_seen auf jetzt (z. B. beim Trennen: "offline seit").
|
||||
func (s *Store) MarkNodeSeen(ctx context.Context, nodeID string) error {
|
||||
res, err := s.db.ExecContext(ctx, `UPDATE nodes SET last_seen = ? WHERE id = ?`, formatTime(s.now()), nodeID)
|
||||
return requireAffected(res, err, "Knoten "+nodeID)
|
||||
}
|
||||
|
||||
// NotificationKeys liefert die vermerkten Ereignisse mit dem Präfix prefix.
|
||||
func (s *Store) NotificationKeys(ctx context.Context, prefix string) ([]string, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT key FROM notifications WHERE substr(key, 1, length(?)) = ? ORDER BY key`,
|
||||
prefix, prefix)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var keys []string
|
||||
for rows.Next() {
|
||||
var k string
|
||||
if err := rows.Scan(&k); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
keys = append(keys, k)
|
||||
}
|
||||
return keys, rows.Err()
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Lease ist die aktuelle Lease eines Knotens. Document ist das signierte
|
||||
// JSON (license.Lease); die Zeiten liegen zusätzlich als Spalten vor, damit
|
||||
// der Server ohne Parsen entscheiden kann.
|
||||
type Lease struct {
|
||||
NodeID string
|
||||
IssuedAt time.Time
|
||||
RenewAfter time.Time
|
||||
ValidUntil time.Time
|
||||
Document string
|
||||
}
|
||||
|
||||
// PutLease speichert bzw. ersetzt die Lease eines Knotens.
|
||||
func (s *Store) PutLease(ctx context.Context, l Lease) error {
|
||||
_, err := s.db.ExecContext(ctx, `INSERT INTO leases (node_id, issued_at, renew_after, valid_until, document)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(node_id) DO UPDATE SET issued_at = excluded.issued_at, renew_after = excluded.renew_after,
|
||||
valid_until = excluded.valid_until, document = excluded.document`,
|
||||
l.NodeID, formatTime(l.IssuedAt), formatTime(l.RenewAfter), formatTime(l.ValidUntil), l.Document)
|
||||
if sqliteCode(err) != 0 {
|
||||
return fmt.Errorf("Lease für %s: Knoten: %w", l.NodeID, ErrNotFound)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// GetLease liest die Lease eines Knotens.
|
||||
func (s *Store) GetLease(ctx context.Context, nodeID string) (Lease, error) {
|
||||
l := Lease{NodeID: nodeID}
|
||||
err := s.db.QueryRowContext(ctx, `SELECT issued_at, renew_after, valid_until, document FROM leases WHERE node_id = ?`, nodeID).
|
||||
Scan(dbTime{&l.IssuedAt}, dbTime{&l.RenewAfter}, dbTime{&l.ValidUntil}, &l.Document)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return Lease{}, fmt.Errorf("Lease für %s: %w", nodeID, ErrNotFound)
|
||||
}
|
||||
return l, err
|
||||
}
|
||||
|
||||
// NodeCommand ist ein Befehl in der Warteschlange für einen Knoten. Die CLI
|
||||
// (eigener Prozess) reiht ein, der Server stellt zu.
|
||||
type NodeCommand struct {
|
||||
ID int64
|
||||
NodeID string
|
||||
Command string
|
||||
Reason string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// EnqueueNodeCommand reiht einen Befehl für nodeID ein.
|
||||
func (s *Store) EnqueueNodeCommand(ctx context.Context, nodeID, command, reason string) error {
|
||||
if blank(command) {
|
||||
return invalid("Befehl fehlt")
|
||||
}
|
||||
_, err := s.db.ExecContext(ctx, `INSERT INTO node_commands (node_id, command, reason, created_at) VALUES (?, ?, ?, ?)`,
|
||||
nodeID, command, reason, formatTime(s.now()))
|
||||
return mapConstraint(err, "Befehl für "+nodeID)
|
||||
}
|
||||
|
||||
// PendingNodeCommands liefert die noch nicht zugestellten Befehle in Reihenfolge.
|
||||
func (s *Store) PendingNodeCommands(ctx context.Context, nodeID string) ([]NodeCommand, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT id, command, reason, created_at FROM node_commands
|
||||
WHERE node_id = ? AND sent_at IS NULL ORDER BY id`, nodeID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []NodeCommand
|
||||
for rows.Next() {
|
||||
c := NodeCommand{NodeID: nodeID}
|
||||
if err := rows.Scan(&c.ID, &c.Command, &c.Reason, dbTime{&c.CreatedAt}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, c)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// MarkNodeCommandSent vermerkt die Zustellung.
|
||||
func (s *Store) MarkNodeCommandSent(ctx context.Context, id int64) error {
|
||||
res, err := s.db.ExecContext(ctx, `UPDATE node_commands SET sent_at = ? WHERE id = ?`, formatTime(s.now()), id)
|
||||
return requireAffected(res, err, fmt.Sprintf("Befehl %d", id))
|
||||
}
|
||||
|
||||
// UpdateNodePublicKey tauscht den Schlüssel eines Knotens (rotate_key).
|
||||
func (s *Store) UpdateNodePublicKey(ctx context.Context, nodeID, publicKey string) error {
|
||||
if blank(publicKey) {
|
||||
return invalid("Public Key fehlt")
|
||||
}
|
||||
res, err := s.db.ExecContext(ctx, `UPDATE nodes SET public_key = ? WHERE id = ?`, publicKey, nodeID)
|
||||
if err != nil {
|
||||
return mapConstraint(err, "Knoten "+nodeID)
|
||||
}
|
||||
return requireAffected(res, nil, "Knoten "+nodeID)
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// Maintenance ist eine angemeldete Wartung eines Knotens.
|
||||
type Maintenance struct {
|
||||
Since time.Time
|
||||
Until time.Time // Nullwert = unbefristet
|
||||
Reason string // intern (Log, Monitoring) – nie öffentlich
|
||||
PublicReason string // optional, ersetzt den Standardtext der Wartungsseite
|
||||
}
|
||||
|
||||
// Active meldet, ob die Wartung zum Zeitpunkt now gilt (nil = keine).
|
||||
func (m *Maintenance) Active(now time.Time) bool {
|
||||
return m != nil && (m.Until.IsZero() || now.Before(m.Until))
|
||||
}
|
||||
|
||||
const (
|
||||
maxPublicReason = 300
|
||||
maxReason = 500
|
||||
)
|
||||
|
||||
// validText: einzeilig, ohne Steuerzeichen, höchstens max Zeichen.
|
||||
func validText(s string, max int) bool {
|
||||
return utf8.RuneCountInString(s) <= max && !strings.ContainsFunc(s, unicode.IsControl)
|
||||
}
|
||||
|
||||
// SetNodeMaintenance meldet eine Wartung an (Since = jetzt). m.Until muss
|
||||
// in der Zukunft liegen oder Nullwert (unbefristet) sein.
|
||||
func (s *Store) SetNodeMaintenance(ctx context.Context, nodeID string, m Maintenance) error {
|
||||
now := s.now()
|
||||
switch {
|
||||
case !m.Until.IsZero() && !m.Until.After(now):
|
||||
return invalid("Wartungsende liegt nicht in der Zukunft")
|
||||
case !validText(m.PublicReason, maxPublicReason):
|
||||
return invalid("öffentlicher Grund: einzeilig, höchstens %d Zeichen", maxPublicReason)
|
||||
case !validText(m.Reason, maxReason):
|
||||
return invalid("Grund: einzeilig, höchstens %d Zeichen", maxReason)
|
||||
}
|
||||
var until any
|
||||
if !m.Until.IsZero() {
|
||||
until = formatTime(m.Until)
|
||||
}
|
||||
res, err := s.db.ExecContext(ctx, `UPDATE nodes SET maintenance_since = ?, maintenance_until = ?,
|
||||
maintenance_reason = ?, maintenance_public_reason = ? WHERE id = ?`,
|
||||
formatTime(now), until, m.Reason, m.PublicReason, nodeID)
|
||||
return requireAffected(res, err, "Knoten "+nodeID)
|
||||
}
|
||||
|
||||
// ClearNodeMaintenance beendet die Wartung eines Knotens.
|
||||
func (s *Store) ClearNodeMaintenance(ctx context.Context, nodeID string) error {
|
||||
res, err := s.db.ExecContext(ctx, `UPDATE nodes SET maintenance_since = NULL, maintenance_until = NULL,
|
||||
maintenance_reason = '', maintenance_public_reason = '' WHERE id = ?`, nodeID)
|
||||
return requireAffected(res, err, "Knoten "+nodeID)
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestNodeMaintenance(t *testing.T) {
|
||||
s, clock := openTest(t)
|
||||
mustTenant(t, s, "t1")
|
||||
mustNode(t, s, "n1", "t1", "k1")
|
||||
|
||||
if n, _ := s.GetNode(ctx, "n1"); n.Maintenance != nil {
|
||||
t.Fatalf("neuer Knoten mit Wartung: %+v", n.Maintenance)
|
||||
}
|
||||
until := clock.now().Add(4 * time.Hour)
|
||||
err := s.SetNodeMaintenance(ctx, "n1", Maintenance{
|
||||
Until: until, Reason: "Kernel-Update", PublicReason: "Wir spielen ein Sicherheitsupdate ein.",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SetNodeMaintenance: %v", err)
|
||||
}
|
||||
n, _ := s.GetNode(ctx, "n1")
|
||||
m := n.Maintenance
|
||||
if m == nil || !m.Since.Equal(clock.now()) || !m.Until.Equal(until) ||
|
||||
m.Reason != "Kernel-Update" || m.PublicReason != "Wir spielen ein Sicherheitsupdate ein." {
|
||||
t.Errorf("Wartung: %+v", m)
|
||||
}
|
||||
if n.Status != NodeOnline {
|
||||
t.Errorf("Wartung darf den Verbindungsstatus nicht ändern: %s", n.Status)
|
||||
}
|
||||
if nodes, _ := s.ListNodes(ctx, ""); nodes[0].Maintenance == nil {
|
||||
t.Error("ListNodes ohne Wartungsdaten")
|
||||
}
|
||||
|
||||
// Unbefristet: Until bleibt Nullwert.
|
||||
if err := s.SetNodeMaintenance(ctx, "n1", Maintenance{Reason: "Umbau"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n, _ := s.GetNode(ctx, "n1"); n.Maintenance == nil || !n.Maintenance.Until.IsZero() {
|
||||
t.Errorf("unbefristet: %+v", n.Maintenance)
|
||||
}
|
||||
|
||||
if err := s.ClearNodeMaintenance(ctx, "n1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n, _ := s.GetNode(ctx, "n1"); n.Maintenance != nil {
|
||||
t.Errorf("nach Clear: %+v", n.Maintenance)
|
||||
}
|
||||
if err := s.SetNodeMaintenance(ctx, "fehlt", Maintenance{}); !errors.Is(err, ErrNotFound) {
|
||||
t.Errorf("unbekannter Knoten: %v", err)
|
||||
}
|
||||
if err := s.ClearNodeMaintenance(ctx, "fehlt"); !errors.Is(err, ErrNotFound) {
|
||||
t.Errorf("unbekannter Knoten: %v", err)
|
||||
}
|
||||
if err := s.SetNodeMaintenance(ctx, "n1", Maintenance{Until: clock.now().Add(-time.Minute)}); !errors.Is(err, ErrInvalid) {
|
||||
t.Errorf("Ende in der Vergangenheit: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaintenanceActive(t *testing.T) {
|
||||
now := time.Date(2026, 10, 2, 12, 0, 0, 0, time.UTC)
|
||||
tests := []struct {
|
||||
name string
|
||||
m *Maintenance
|
||||
want bool
|
||||
}{
|
||||
{"keine", nil, false},
|
||||
{"unbefristet", &Maintenance{Since: now.Add(-time.Hour)}, true},
|
||||
{"läuft", &Maintenance{Since: now.Add(-time.Hour), Until: now.Add(time.Minute)}, true},
|
||||
{"abgelaufen", &Maintenance{Since: now.Add(-time.Hour), Until: now}, false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := tt.m.Active(now); got != tt.want {
|
||||
t.Errorf("%s: Active = %v", tt.name, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
-2
@@ -30,6 +30,9 @@ type Node struct {
|
||||
LastSeen time.Time
|
||||
IPAddress string
|
||||
Status NodeStatus
|
||||
// Maintenance ist die angemeldete Wartung (nil = keine). Sie ist eine
|
||||
// eigene Dimension: Ein Knoten in Wartung kann verbunden sein oder nicht.
|
||||
Maintenance *Maintenance
|
||||
}
|
||||
|
||||
func (n *Node) normalize(now time.Time) error {
|
||||
@@ -79,11 +82,17 @@ func (s *Store) insertNode(ctx context.Context, db execer, n Node) (Node, error)
|
||||
return n, nil
|
||||
}
|
||||
|
||||
const nodeColumns = `id, tenant_id, hostname, public_key, last_seen, ip_address, status`
|
||||
const nodeColumns = `id, tenant_id, hostname, public_key, last_seen, ip_address, status,
|
||||
maintenance_since, maintenance_until, maintenance_reason, maintenance_public_reason`
|
||||
|
||||
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)
|
||||
var m Maintenance
|
||||
err := row.Scan(&n.ID, &n.TenantID, &n.Hostname, &n.PublicKey, dbTime{&n.LastSeen}, &n.IPAddress, &n.Status,
|
||||
dbTime{&m.Since}, dbTime{&m.Until}, &m.Reason, &m.PublicReason)
|
||||
if err == nil && !m.Since.IsZero() {
|
||||
n.Maintenance = &m
|
||||
}
|
||||
return n, err
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// Setting liest eine Server-Einstellung (ErrNotFound, wenn nicht gesetzt).
|
||||
func (s *Store) Setting(ctx context.Context, key string) (string, error) {
|
||||
var v string
|
||||
err := s.db.QueryRowContext(ctx, `SELECT value FROM settings WHERE key = ?`, key).Scan(&v)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return "", fmt.Errorf("Einstellung %s: %w", key, ErrNotFound)
|
||||
}
|
||||
return v, err
|
||||
}
|
||||
|
||||
// SetSetting setzt bzw. überschreibt eine Server-Einstellung.
|
||||
func (s *Store) SetSetting(ctx context.Context, key, value string) error {
|
||||
if blank(key) {
|
||||
return invalid("Schlüssel fehlt")
|
||||
}
|
||||
_, err := s.db.ExecContext(ctx,
|
||||
`INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value`,
|
||||
key, value)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSettings(t *testing.T) {
|
||||
s, _ := openTest(t)
|
||||
if _, err := s.Setting(ctx, "public_url"); !errors.Is(err, ErrNotFound) {
|
||||
t.Errorf("fehlende Einstellung: erwartet ErrNotFound, bekommen %v", err)
|
||||
}
|
||||
if err := s.SetSetting(ctx, "public_url", "https://a.example:8443"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.SetSetting(ctx, "public_url", "https://b.example:8443"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if v, err := s.Setting(ctx, "public_url"); err != nil || v != "https://b.example:8443" {
|
||||
t.Errorf("Setting = %q, %v", v, err)
|
||||
}
|
||||
if err := s.SetSetting(ctx, "", "x"); !errors.Is(err, ErrInvalid) {
|
||||
t.Errorf("leerer Schlüssel: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSettings_SurviveReopen(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "sultan.db")
|
||||
s, err := Open(ctx, path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s.SetSetting(ctx, "public_url", "https://a.example:8443")
|
||||
s.Close()
|
||||
s, _ = Open(ctx, path)
|
||||
defer s.Close()
|
||||
if v, _ := s.Setting(ctx, "public_url"); v != "https://a.example:8443" {
|
||||
t.Errorf("nach Neustart: %q", v)
|
||||
}
|
||||
if v, _ := s.SchemaVersion(ctx); v < 2 {
|
||||
t.Errorf("Schema-Version %d, erwartet mindestens 2", v)
|
||||
}
|
||||
}
|
||||
@@ -64,6 +64,41 @@ var migrations = []string{
|
||||
used INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE INDEX enrollment_tokens_tenant_id ON enrollment_tokens(tenant_id);`,
|
||||
// v2: Server-Einstellungen, die auch `sultan token` (eigener Prozess) braucht.
|
||||
`CREATE TABLE settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);`,
|
||||
// v3: Wartung pro Knoten (eigene Dimension neben online/offline/drained).
|
||||
`ALTER TABLE nodes ADD COLUMN maintenance_since DATETIME;
|
||||
ALTER TABLE nodes ADD COLUMN maintenance_until DATETIME;
|
||||
ALTER TABLE nodes ADD COLUMN maintenance_reason TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE nodes ADD COLUMN maintenance_public_reason TEXT NOT NULL DEFAULT '';`,
|
||||
// v4: Leases, Befehlswarteschlange, Features und Zahlungsstatus, Einlösezeitpunkt.
|
||||
`CREATE TABLE leases (
|
||||
node_id TEXT PRIMARY KEY REFERENCES nodes(id) ON DELETE CASCADE,
|
||||
issued_at DATETIME NOT NULL,
|
||||
renew_after DATETIME NOT NULL,
|
||||
valid_until DATETIME NOT NULL,
|
||||
document TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE node_commands (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
node_id TEXT NOT NULL REFERENCES nodes(id) ON DELETE CASCADE,
|
||||
command TEXT NOT NULL,
|
||||
reason TEXT NOT NULL DEFAULT '',
|
||||
created_at DATETIME NOT NULL,
|
||||
sent_at DATETIME
|
||||
);
|
||||
CREATE INDEX node_commands_pending ON node_commands(node_id, sent_at);
|
||||
ALTER TABLE tenants ADD COLUMN features TEXT NOT NULL DEFAULT '[]';
|
||||
ALTER TABLE tenants ADD COLUMN payment_overdue_since DATETIME;
|
||||
ALTER TABLE enrollment_tokens ADD COLUMN used_at DATETIME;`,
|
||||
// v5: versandte Benachrichtigungen (jedes Ereignis genau einmal).
|
||||
`CREATE TABLE notifications (
|
||||
key TEXT PRIMARY KEY,
|
||||
sent_at DATETIME NOT NULL
|
||||
);`,
|
||||
}
|
||||
|
||||
// Store ist der Zugriff auf die SQLite-Datenbank. Er ist für parallele
|
||||
@@ -147,6 +182,9 @@ type dbTime struct{ t *time.Time }
|
||||
|
||||
func (d dbTime) Scan(src any) error {
|
||||
switch v := src.(type) {
|
||||
case nil: // NULL = nicht gesetzt
|
||||
*d.t = time.Time{}
|
||||
return nil
|
||||
case time.Time:
|
||||
*d.t = normTime(v)
|
||||
return nil
|
||||
|
||||
+49
-19
@@ -50,8 +50,12 @@ type Tenant struct {
|
||||
AllowedDomains []string
|
||||
ValidUntil time.Time
|
||||
Status TenantStatus
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
Features []string // freigeschaltete Lizenz-Features (z. B. custom_middleware)
|
||||
// PaymentOverdueSince: seit wann eine Zahlung offen ist (Nullwert = nichts
|
||||
// offen); daraus berechnet die Policy die Mahnstufe.
|
||||
PaymentOverdueSince time.Time
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// normalize setzt Defaults (Tier community, Status active, leere
|
||||
@@ -66,6 +70,9 @@ func (t *Tenant) normalize() error {
|
||||
if t.AllowedDomains == nil {
|
||||
t.AllowedDomains = []string{}
|
||||
}
|
||||
if t.Features == nil {
|
||||
t.Features = []string{}
|
||||
}
|
||||
t.ValidUntil = normTime(t.ValidUntil)
|
||||
|
||||
switch {
|
||||
@@ -89,6 +96,11 @@ func (t *Tenant) normalize() error {
|
||||
return invalid("leerer Eintrag in allowed_domains")
|
||||
}
|
||||
}
|
||||
for _, f := range t.Features {
|
||||
if blank(f) {
|
||||
return invalid("leerer Eintrag in features")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -98,40 +110,47 @@ func (s *Store) CreateTenant(ctx context.Context, t Tenant) (Tenant, error) {
|
||||
if err := t.normalize(); err != nil {
|
||||
return Tenant{}, err
|
||||
}
|
||||
domains, err := json.Marshal(t.AllowedDomains)
|
||||
if err != nil {
|
||||
return Tenant{}, err
|
||||
}
|
||||
domains, _ := json.Marshal(t.AllowedDomains)
|
||||
features, _ := json.Marshal(t.Features)
|
||||
now := normTime(s.now())
|
||||
t.CreatedAt, t.UpdatedAt = now, now
|
||||
t.PaymentOverdueSince = time.Time{}
|
||||
|
||||
_, err = s.db.ExecContext(ctx, `INSERT INTO tenants
|
||||
(id, name, email, tier, max_routes, allowed_domains, valid_until, status, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
_, err := s.db.ExecContext(ctx, `INSERT INTO tenants
|
||||
(id, name, email, tier, max_routes, allowed_domains, valid_until, status, created_at, updated_at, features)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
t.ID, t.Name, t.Email, t.Tier, t.MaxRoutes, string(domains),
|
||||
formatTime(t.ValidUntil), t.Status, formatTime(now), formatTime(now))
|
||||
formatTime(t.ValidUntil), t.Status, formatTime(now), formatTime(now), string(features))
|
||||
if err != nil {
|
||||
return Tenant{}, mapConstraint(err, "Mandant "+t.ID)
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
const tenantColumns = `id, name, email, tier, max_routes, allowed_domains, valid_until, status, created_at, updated_at`
|
||||
const tenantColumns = `id, name, email, tier, max_routes, allowed_domains, valid_until, status, created_at, updated_at,
|
||||
features, payment_overdue_since`
|
||||
|
||||
func scanTenant(row interface{ Scan(...any) error }) (Tenant, error) {
|
||||
var t Tenant
|
||||
var domains string
|
||||
var domains, features string
|
||||
err := row.Scan(&t.ID, &t.Name, &t.Email, &t.Tier, &t.MaxRoutes, &domains,
|
||||
dbTime{&t.ValidUntil}, &t.Status, dbTime{&t.CreatedAt}, dbTime{&t.UpdatedAt})
|
||||
dbTime{&t.ValidUntil}, &t.Status, dbTime{&t.CreatedAt}, dbTime{&t.UpdatedAt},
|
||||
&features, dbTime{&t.PaymentOverdueSince})
|
||||
if err != nil {
|
||||
return Tenant{}, err
|
||||
}
|
||||
if err := json.Unmarshal([]byte(domains), &t.AllowedDomains); err != nil {
|
||||
return Tenant{}, fmt.Errorf("store: allowed_domains von %s: %w", t.ID, err)
|
||||
}
|
||||
if err := json.Unmarshal([]byte(features), &t.Features); err != nil {
|
||||
return Tenant{}, fmt.Errorf("store: features von %s: %w", t.ID, err)
|
||||
}
|
||||
if t.AllowedDomains == nil {
|
||||
t.AllowedDomains = []string{}
|
||||
}
|
||||
if t.Features == nil {
|
||||
t.Features = []string{}
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
@@ -168,15 +187,14 @@ func (s *Store) UpdateTenant(ctx context.Context, t Tenant) (Tenant, error) {
|
||||
if err := t.normalize(); err != nil {
|
||||
return Tenant{}, err
|
||||
}
|
||||
domains, err := json.Marshal(t.AllowedDomains)
|
||||
if err != nil {
|
||||
return Tenant{}, err
|
||||
}
|
||||
domains, _ := json.Marshal(t.AllowedDomains)
|
||||
features, _ := json.Marshal(t.Features)
|
||||
res, err := s.db.ExecContext(ctx, `UPDATE tenants SET
|
||||
name = ?, email = ?, tier = ?, max_routes = ?, allowed_domains = ?, valid_until = ?, status = ?, updated_at = ?
|
||||
name = ?, email = ?, tier = ?, max_routes = ?, allowed_domains = ?, valid_until = ?, status = ?, updated_at = ?,
|
||||
features = ?
|
||||
WHERE id = ?`,
|
||||
t.Name, t.Email, t.Tier, t.MaxRoutes, string(domains), formatTime(t.ValidUntil), t.Status,
|
||||
formatTime(s.now()), t.ID)
|
||||
formatTime(s.now()), string(features), t.ID)
|
||||
if err := requireAffected(res, err, "Mandant "+t.ID); err != nil {
|
||||
return Tenant{}, err
|
||||
}
|
||||
@@ -193,6 +211,18 @@ func (s *Store) SetTenantStatus(ctx context.Context, id string, status TenantSta
|
||||
return requireAffected(res, err, "Mandant "+id)
|
||||
}
|
||||
|
||||
// SetTenantPayment setzt, seit wann eine Zahlung offen ist (Nullwert =
|
||||
// bezahlt). Grundlage der Mahnstufen.
|
||||
func (s *Store) SetTenantPayment(ctx context.Context, id string, overdueSince time.Time) error {
|
||||
var v any
|
||||
if !overdueSince.IsZero() {
|
||||
v = formatTime(overdueSince)
|
||||
}
|
||||
res, err := s.db.ExecContext(ctx, `UPDATE tenants SET payment_overdue_since = ?, updated_at = ? WHERE id = ?`,
|
||||
v, formatTime(s.now()), id)
|
||||
return requireAffected(res, err, "Mandant "+id)
|
||||
}
|
||||
|
||||
// DeleteTenant löscht den Mandanten samt seiner Knoten und Tokens.
|
||||
func (s *Store) DeleteTenant(ctx context.Context, id string) error {
|
||||
res, err := s.db.ExecContext(ctx, `DELETE FROM tenants WHERE id = ?`, id)
|
||||
|
||||
+8
-6
@@ -62,7 +62,8 @@ func (s *Store) Enroll(ctx context.Context, token string, n Node) (Node, error)
|
||||
return Node{}, ErrTokenExpired
|
||||
}
|
||||
|
||||
if _, err := tx.ExecContext(ctx, `UPDATE enrollment_tokens SET used = 1 WHERE token_hash = ?`, hash); err != nil {
|
||||
if _, err := tx.ExecContext(ctx, `UPDATE enrollment_tokens SET used = 1, used_at = ? WHERE token_hash = ?`,
|
||||
formatTime(s.now()), hash); err != nil {
|
||||
return Node{}, err
|
||||
}
|
||||
n.TenantID = tenantID
|
||||
@@ -76,11 +77,12 @@ func (s *Store) Enroll(ctx context.Context, token string, n Node) (Node, error)
|
||||
return node, nil
|
||||
}
|
||||
|
||||
// PurgeEnrollmentTokens löscht verbrauchte und abgelaufene Tokens und
|
||||
// liefert ihre Anzahl.
|
||||
func (s *Store) PurgeEnrollmentTokens(ctx context.Context) (int64, error) {
|
||||
res, err := s.db.ExecContext(ctx,
|
||||
`DELETE FROM enrollment_tokens WHERE used = 1 OR expires_at <= ?`, formatTime(s.now()))
|
||||
// PurgeEnrollmentTokens löscht Einladungen, die vor mehr als olderThan
|
||||
// eingelöst wurden oder abgelaufen sind (0 = sofort), und liefert ihre Anzahl.
|
||||
func (s *Store) PurgeEnrollmentTokens(ctx context.Context, olderThan time.Duration) (int64, error) {
|
||||
cutoff := formatTime(s.now().Add(-olderThan))
|
||||
res, err := s.db.ExecContext(ctx, `DELETE FROM enrollment_tokens
|
||||
WHERE (used = 1 AND (used_at IS NULL OR used_at <= ?)) OR expires_at <= ?`, cutoff, cutoff)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
@@ -172,7 +172,7 @@ func TestPurgeEnrollmentTokens(t *testing.T) {
|
||||
}
|
||||
clock.advance(time.Minute)
|
||||
|
||||
n, err := s.PurgeEnrollmentTokens(ctx)
|
||||
n, err := s.PurgeEnrollmentTokens(ctx, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestLeases(t *testing.T) {
|
||||
s, clock := openTest(t)
|
||||
mustTenant(t, s, "t1")
|
||||
mustNode(t, s, "n1", "t1", "k1")
|
||||
|
||||
if _, err := s.GetLease(ctx, "n1"); !errors.Is(err, ErrNotFound) {
|
||||
t.Errorf("ohne Lease: %v", err)
|
||||
}
|
||||
l := Lease{
|
||||
NodeID: "n1",
|
||||
IssuedAt: clock.now(),
|
||||
RenewAfter: clock.now().Add(7 * 24 * time.Hour),
|
||||
ValidUntil: clock.now().Add(14 * 24 * time.Hour),
|
||||
Document: `{"node_id":"n1"}`,
|
||||
}
|
||||
if err := s.PutLease(ctx, l); err != nil {
|
||||
t.Fatalf("PutLease: %v", err)
|
||||
}
|
||||
got, err := s.GetLease(ctx, "n1")
|
||||
if err != nil || !reflect.DeepEqual(got, l) {
|
||||
t.Errorf("GetLease = %+v, %v", got, err)
|
||||
}
|
||||
l.ValidUntil = l.ValidUntil.Add(time.Hour)
|
||||
if err := s.PutLease(ctx, l); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, _ := s.GetLease(ctx, "n1"); !got.ValidUntil.Equal(l.ValidUntil) {
|
||||
t.Error("PutLease ersetzt nicht")
|
||||
}
|
||||
if err := s.PutLease(ctx, Lease{NodeID: "fehlt", Document: "{}"}); !errors.Is(err, ErrNotFound) {
|
||||
t.Errorf("unbekannter Knoten: %v", err)
|
||||
}
|
||||
s.DeleteNode(ctx, "n1")
|
||||
if _, err := s.GetLease(ctx, "n1"); !errors.Is(err, ErrNotFound) {
|
||||
t.Error("Lease nach Löschen des Knotens noch da")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTenantFeaturesAndPayment(t *testing.T) {
|
||||
s, clock := openTest(t)
|
||||
in := sampleTenant("t1")
|
||||
in.Features = []string{"custom_middleware", "websockets"}
|
||||
created, err := s.CreateTenant(ctx, in)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, _ := s.GetTenant(ctx, "t1")
|
||||
if !reflect.DeepEqual(got.Features, []string{"custom_middleware", "websockets"}) || !reflect.DeepEqual(got, created) {
|
||||
t.Errorf("Features: %+v", got.Features)
|
||||
}
|
||||
if !got.PaymentOverdueSince.IsZero() {
|
||||
t.Error("neuer Mandant mit offener Zahlung")
|
||||
}
|
||||
if def, _ := s.CreateTenant(ctx, sampleTenant("t2")); def.Features == nil || len(def.Features) != 0 {
|
||||
t.Errorf("Default-Features: %#v", def.Features)
|
||||
}
|
||||
|
||||
due := clock.now().Add(-10 * 24 * time.Hour)
|
||||
if err := s.SetTenantPayment(ctx, "t1", due); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, _ := s.GetTenant(ctx, "t1"); !got.PaymentOverdueSince.Equal(due) {
|
||||
t.Errorf("offen seit: %v", got.PaymentOverdueSince)
|
||||
}
|
||||
if err := s.SetTenantPayment(ctx, "t1", time.Time{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, _ := s.GetTenant(ctx, "t1"); !got.PaymentOverdueSince.IsZero() {
|
||||
t.Error("bezahlt: Fälligkeit nicht gelöscht")
|
||||
}
|
||||
if err := s.SetTenantPayment(ctx, "fehlt", due); !errors.Is(err, ErrNotFound) {
|
||||
t.Errorf("unbekannter Mandant: %v", err)
|
||||
}
|
||||
bad := sampleTenant("t3")
|
||||
bad.Features = []string{"ok", " "}
|
||||
if _, err := s.CreateTenant(ctx, bad); !errors.Is(err, ErrInvalid) {
|
||||
t.Errorf("leeres Feature: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnroll_RecordsUsedAt(t *testing.T) {
|
||||
s, clock := openTest(t)
|
||||
mustTenant(t, s, "t1")
|
||||
s.AddEnrollmentToken(ctx, "tok", "t1", clock.now().Add(time.Hour))
|
||||
clock.advance(time.Minute)
|
||||
if _, err := s.Enroll(ctx, "tok", sampleNode("n1", "k1")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var usedAt time.Time
|
||||
if err := s.db.QueryRowContext(ctx, "SELECT used_at FROM enrollment_tokens").Scan(dbTime{&usedAt}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !usedAt.Equal(clock.now()) {
|
||||
t.Errorf("used_at = %v, erwartet %v", usedAt, clock.now())
|
||||
}
|
||||
}
|
||||
|
||||
func TestNodeCommands(t *testing.T) {
|
||||
s, clock := openTest(t)
|
||||
mustTenant(t, s, "t1")
|
||||
mustNode(t, s, "n1", "t1", "k1")
|
||||
|
||||
if err := s.EnqueueNodeCommand(ctx, "n1", "resync", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
clock.advance(time.Second)
|
||||
s.EnqueueNodeCommand(ctx, "n1", "drain", "Umzug")
|
||||
cmds, err := s.PendingNodeCommands(ctx, "n1")
|
||||
if err != nil || len(cmds) != 2 || cmds[0].Command != "resync" || cmds[1].Command != "drain" || cmds[1].Reason != "Umzug" {
|
||||
t.Fatalf("ausstehend: %+v %v", cmds, err)
|
||||
}
|
||||
if err := s.MarkNodeCommandSent(ctx, cmds[0].ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cmds, _ := s.PendingNodeCommands(ctx, "n1"); len(cmds) != 1 || cmds[0].Command != "drain" {
|
||||
t.Errorf("nach Versand: %+v", cmds)
|
||||
}
|
||||
if err := s.EnqueueNodeCommand(ctx, "fehlt", "resync", ""); !errors.Is(err, ErrNotFound) {
|
||||
t.Errorf("unbekannter Knoten: %v", err)
|
||||
}
|
||||
if err := s.EnqueueNodeCommand(ctx, "n1", "", ""); !errors.Is(err, ErrInvalid) {
|
||||
t.Errorf("leerer Befehl: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateNodePublicKey(t *testing.T) {
|
||||
s, _ := openTest(t)
|
||||
mustTenant(t, s, "t1")
|
||||
mustNode(t, s, "n1", "t1", "k1")
|
||||
mustNode(t, s, "n2", "t1", "k2")
|
||||
if err := s.UpdateNodePublicKey(ctx, "n1", "k1-neu"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n, _ := s.GetNodeByPublicKey(ctx, "k1-neu"); n.ID != "n1" {
|
||||
t.Errorf("neuer Schlüssel nicht gefunden: %+v", n)
|
||||
}
|
||||
if _, err := s.GetNodeByPublicKey(ctx, "k1"); !errors.Is(err, ErrNotFound) {
|
||||
t.Error("alter Schlüssel noch gültig")
|
||||
}
|
||||
if err := s.UpdateNodePublicKey(ctx, "n1", "k2"); !errors.Is(err, ErrExists) {
|
||||
t.Errorf("fremder Schlüssel: %v", err)
|
||||
}
|
||||
if err := s.UpdateNodePublicKey(ctx, "fehlt", "x"); !errors.Is(err, ErrNotFound) {
|
||||
t.Errorf("unbekannter Knoten: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Aufräumen erst nach einer Frist (Job: 10 Tage), CLI-purge sofort (0).
|
||||
func TestPurgeEnrollmentTokens_Retention(t *testing.T) {
|
||||
s, clock := openTest(t)
|
||||
mustTenant(t, s, "t1")
|
||||
start := clock.now()
|
||||
s.AddEnrollmentToken(ctx, "alt-benutzt", "t1", start.Add(time.Hour))
|
||||
s.AddEnrollmentToken(ctx, "jung-benutzt", "t1", start.Add(30*24*time.Hour))
|
||||
s.AddEnrollmentToken(ctx, "alt-abgelaufen", "t1", start.Add(time.Hour))
|
||||
s.AddEnrollmentToken(ctx, "gueltig", "t1", start.Add(30*24*time.Hour))
|
||||
s.Enroll(ctx, "alt-benutzt", sampleNode("n1", "k1"))
|
||||
clock.advance(9 * 24 * time.Hour)
|
||||
s.Enroll(ctx, "jung-benutzt", sampleNode("n2", "k2"))
|
||||
clock.advance(2 * 24 * time.Hour) // jetzt: alt-* vor > 10 Tagen benutzt/abgelaufen
|
||||
|
||||
n, err := s.PurgeEnrollmentTokens(ctx, 10*24*time.Hour)
|
||||
if err != nil || n != 2 {
|
||||
t.Errorf("gelöscht %d (%v), erwartet 2 (alt-benutzt, alt-abgelaufen)", n, err)
|
||||
}
|
||||
if n, _ := s.PurgeEnrollmentTokens(ctx, 0); n != 1 {
|
||||
t.Errorf("sofort: %d gelöscht, erwartet 1 (jung-benutzt)", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPurgeLeasesAndCommands(t *testing.T) {
|
||||
s, clock := openTest(t)
|
||||
mustTenant(t, s, "t1")
|
||||
mustNode(t, s, "n1", "t1", "k1")
|
||||
mustNode(t, s, "n2", "t1", "k2")
|
||||
now := clock.now()
|
||||
s.PutLease(ctx, Lease{NodeID: "n1", IssuedAt: now, RenewAfter: now, ValidUntil: now.Add(time.Hour), Document: "{}"})
|
||||
s.PutLease(ctx, Lease{NodeID: "n2", IssuedAt: now, RenewAfter: now, ValidUntil: now.Add(20 * 24 * time.Hour), Document: "{}"})
|
||||
s.EnqueueNodeCommand(ctx, "n1", "resync", "")
|
||||
s.EnqueueNodeCommand(ctx, "n1", "drain", "")
|
||||
cmds, _ := s.PendingNodeCommands(ctx, "n1")
|
||||
s.MarkNodeCommandSent(ctx, cmds[0].ID)
|
||||
clock.advance(11 * 24 * time.Hour)
|
||||
|
||||
if n, err := s.PurgeLeases(ctx, 10*24*time.Hour); err != nil || n != 1 {
|
||||
t.Errorf("Leases: %d %v", n, err)
|
||||
}
|
||||
if _, err := s.GetLease(ctx, "n2"); err != nil {
|
||||
t.Error("gültige Lease gelöscht")
|
||||
}
|
||||
if n, err := s.PurgeNodeCommands(ctx, 10*24*time.Hour); err != nil || n != 1 {
|
||||
t.Errorf("Befehle: %d %v", n, err)
|
||||
}
|
||||
if cmds, _ := s.PendingNodeCommands(ctx, "n1"); len(cmds) != 1 {
|
||||
t.Error("nicht zugestellter Befehl gelöscht")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotificationsOnce(t *testing.T) {
|
||||
s, _ := openTest(t)
|
||||
if s.Notified(ctx, "contract:t1:2026-12-15:14") {
|
||||
t.Error("neu: schon benachrichtigt")
|
||||
}
|
||||
if err := s.RecordNotification(ctx, "contract:t1:2026-12-15:14"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !s.Notified(ctx, "contract:t1:2026-12-15:14") {
|
||||
t.Error("nicht vermerkt")
|
||||
}
|
||||
if err := s.RecordNotification(ctx, "contract:t1:2026-12-15:14"); err != nil {
|
||||
t.Errorf("doppelt vermerken: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkNodeSeen(t *testing.T) {
|
||||
s, clock := openTest(t)
|
||||
mustTenant(t, s, "t1")
|
||||
mustNode(t, s, "n1", "t1", "k1")
|
||||
clock.advance(time.Hour)
|
||||
if err := s.MarkNodeSeen(ctx, "n1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n, _ := s.GetNode(ctx, "n1"); !n.LastSeen.Equal(clock.now()) {
|
||||
t.Errorf("last_seen = %v", n.LastSeen)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotificationKeysWithPrefix(t *testing.T) {
|
||||
s, _ := openTest(t)
|
||||
for _, k := range []string{"offline:n1:a", "offline:n1:b", "offline:n10:c", "contract:x"} {
|
||||
s.RecordNotification(ctx, k)
|
||||
}
|
||||
got, err := s.NotificationKeys(ctx, "offline:n1:")
|
||||
if err != nil || len(got) != 2 || got[0] != "offline:n1:a" || got[1] != "offline:n1:b" {
|
||||
t.Errorf("Schlüssel: %v %v", got, err)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user