munin/internal/store/control.go

275 lines
9.7 KiB
Go

package store
import (
"context"
"database/sql"
"embed"
"errors"
"fmt"
)
// controlMigrationsFS enthält das Schema der zentralen Kontroll-Datenbank
// (siehe migrations_control/sqlite/001_init.sql und AUSBAUSTUFEN.md 4.4), je
// Datenbank in einem eigenen Unterordner wie bei migrationsFS.
//
//go:embed migrations_control/sqlite/*.sql migrations_control/postgres/*.sql
var controlMigrationsFS embed.FS
// ControlStore verwaltet die zentrale Kontroll-Datenbank: die Liste der
// Workspaces (Abteilungen) und den Weg von einem Key-Hash zu seinem Workspace.
//
// Alles andere — Projekte, Einträge, Keys im Volltext, Protokoll — lebt in der
// jeweiligen Workspace-Datei (Store, siehe store.go). Ein eigener Typ statt
// eines zweiten Satzes Methoden auf Store, damit nie aus Versehen eine
// Workspace-Abfrage gegen die falsche Datei läuft.
type ControlStore struct {
lesen pool
schreiben pool
// haken sind die zusätzlichen Schritte, die der Speicher beim Anlegen und
// Umbenennen eines Workspace braucht. Bei SQLite gibt es keine (nil):
// Eine Workspace-Datei entsteht beim ersten Öffnen von selbst. Bei
// PostgreSQL wird geprüft, ob die Verbindungen reichen, und das Schema
// samt lesbarem Namen angelegt (siehe speicher_postgres.go).
haken workspaceHaken
}
// workspaceHaken wird vom Speicher gesetzt, siehe ControlStore.haken.
type workspaceHaken interface {
// vorDemAnlegen prüft, ob ein weiterer Workspace möglich ist. anzahl ist
// die Zahl der Workspaces MIT dem neuen.
vorDemAnlegen(ctx context.Context, anzahl int) error
// nachDemBenennen läuft nach dem Anlegen und nach jedem Umbenennen.
nachDemBenennen(ctx context.Context, workspaceID, name string) error
}
// OpenControl öffnet die Kontroll-Datenbank als SQLite-Datei und bringt ihr
// Schema auf den aktuellen Stand. Für PostgreSQL siehe Speicher.OpenControl.
func OpenControl(path string) (*ControlStore, error) {
lesen, schreiben, err := openSQLite(path, controlMigrationsFS, "migrations_control")
if err != nil {
return nil, err
}
return &ControlStore{lesen: lesen, schreiben: schreiben}, nil
}
// Verbindungen liefert die Zahlen beider Pools (siehe Verbindungen).
func (c *ControlStore) Verbindungen() Verbindungen {
return Verbindungen{Lesen: c.lesen.db.Stats(), Schreiben: c.schreiben.db.Stats()}
}
// Close schließt beide Verbindungspools.
func (c *ControlStore) Close() error {
fehlerLesen := c.lesen.Close()
if err := c.schreiben.Close(); err != nil {
return err
}
return fehlerLesen
}
// Ping prüft mit einer echten Abfrage, ob die Kontroll-Datenbank antwortet.
// Für /ready (siehe internal/web/server.go). Bei SQLite ist das die Datei,
// bei PostgreSQL der Datenbankserver — und damit auch der Weg dorthin.
func (c *ControlStore) Ping(ctx context.Context) error {
var eins int
return c.lesen.QueryRowContext(ctx, `SELECT 1`).Scan(&eins)
}
// ── Workspaces ───────────────────────────────────────────────────────────────
// EnsureDefaultWorkspace legt beim allerersten Start eines Deployments den
// ersten Workspace an und gibt ihn zurück. Gibt es bereits einen oder mehrere,
// wird der älteste verwendet.
//
// Gedacht für den Fall, dass noch keine Workspace-Verwaltung existiert (WP8):
// ein frischer Server soll ohne manuellen Zwischenschritt lauffähig sein.
// Sobald es eine Oberfläche zum Anlegen weiterer Workspaces gibt, verliert
// diese Funktion an Bedeutung, bleibt aber für den ersten Start richtig.
func (c *ControlStore) EnsureDefaultWorkspace(ctx context.Context, name string) (Workspace, error) {
var w Workspace
var createdAt string
err := c.lesen.QueryRowContext(ctx,
`SELECT id, name, created_at FROM workspaces ORDER BY created_at LIMIT 1`,
).Scan(&w.ID, &w.Name, &createdAt)
if err == nil {
w.CreatedAt = parseTime(createdAt)
return w, nil
}
if !errors.Is(err, sql.ErrNoRows) {
return Workspace{}, fmt.Errorf("Workspace konnte nicht gelesen werden: %w", err)
}
return c.CreateWorkspace(ctx, name)
}
// CreateWorkspace legt einen neuen Workspace an.
func (c *ControlStore) CreateWorkspace(ctx context.Context, name string) (Workspace, error) {
if c.haken != nil {
var bisher int
if err := c.lesen.QueryRowContext(ctx, `SELECT COUNT(*) FROM workspaces`).Scan(&bisher); err != nil {
return Workspace{}, fmt.Errorf("Workspaces konnten nicht gezählt werden: %w", err)
}
if err := c.haken.vorDemAnlegen(ctx, bisher+1); err != nil {
return Workspace{}, err
}
}
w := Workspace{ID: NewID(), Name: name}
jetzt := NowString()
if _, err := c.schreiben.ExecContext(ctx,
`INSERT INTO workspaces (id, name, created_at) VALUES (?, ?, ?)`,
w.ID, w.Name, jetzt,
); err != nil {
return Workspace{}, fmt.Errorf("Workspace konnte nicht angelegt werden: %w", err)
}
if c.haken != nil {
if err := c.haken.nachDemBenennen(ctx, w.ID, w.Name); err != nil {
return Workspace{}, err
}
}
w.CreatedAt = parseTime(jetzt)
return w, nil
}
// ListWorkspaces gibt alle Workspaces zurück, älteste zuerst.
func (c *ControlStore) ListWorkspaces(ctx context.Context) ([]Workspace, error) {
rows, err := c.lesen.QueryContext(ctx,
`SELECT id, name, created_at FROM workspaces ORDER BY created_at`,
)
if err != nil {
return nil, fmt.Errorf("Workspaces konnten nicht gelesen werden: %w", err)
}
defer rows.Close()
var ws []Workspace
for rows.Next() {
var w Workspace
var createdAt string
if err := rows.Scan(&w.ID, &w.Name, &createdAt); err != nil {
return nil, err
}
w.CreatedAt = parseTime(createdAt)
ws = append(ws, w)
}
return ws, rows.Err()
}
// WorkspaceByID liest einen Workspace anhand seiner ID.
func (c *ControlStore) WorkspaceByID(ctx context.Context, id string) (Workspace, error) {
var w Workspace
var createdAt string
err := c.lesen.QueryRowContext(ctx,
`SELECT id, name, created_at FROM workspaces WHERE id = ?`, id,
).Scan(&w.ID, &w.Name, &createdAt)
if errors.Is(err, sql.ErrNoRows) {
return Workspace{}, ErrNotFound
}
if err != nil {
return Workspace{}, fmt.Errorf("Workspace konnte nicht gelesen werden: %w", err)
}
w.CreatedAt = parseTime(createdAt)
return w, nil
}
// UpdateWorkspace benennt einen Workspace um.
func (c *ControlStore) UpdateWorkspace(ctx context.Context, id, name string) error {
res, err := c.schreiben.ExecContext(ctx,
`UPDATE workspaces SET name = ? WHERE id = ?`, name, id,
)
if err != nil {
return fmt.Errorf("Workspace konnte nicht umbenannt werden: %w", err)
}
betroffen, err := res.RowsAffected()
if err != nil {
return fmt.Errorf("Workspace konnte nicht umbenannt werden: %w", err)
}
if betroffen == 0 {
return ErrNotFound
}
if c.haken != nil {
return c.haken.nachDemBenennen(ctx, id, name)
}
return nil
}
// DeleteWorkspace entfernt einen Workspace aus der Kontroll-Datenbank.
//
// Löscht nur die Zeile hier — die zugehörigen `key_index`-Einträge fallen
// über den Fremdschlüssel (ON DELETE CASCADE) automatisch mit weg. Die
// eigentliche Workspace-Datei mit allen Projekten und Einträgen bleibt davon
// unberührt; sie zu entfernen ist Sache von workspace.Manager.Delete — erst
// wenn beides passiert ist, ist der Workspace wirklich weg (siehe
// handleWorkspaceDelete in internal/web/handlers_workspaces.go).
func (c *ControlStore) DeleteWorkspace(ctx context.Context, id string) error {
res, err := c.schreiben.ExecContext(ctx, `DELETE FROM workspaces WHERE id = ?`, id)
if err != nil {
return fmt.Errorf("Workspace konnte nicht gelöscht werden: %w", err)
}
betroffen, err := res.RowsAffected()
if err != nil {
return fmt.Errorf("Workspace konnte nicht gelöscht werden: %w", err)
}
if betroffen == 0 {
return ErrNotFound
}
return nil
}
// ── Key-Routing ──────────────────────────────────────────────────────────────
// IndexKey trägt einen Key-Hash in den Routing-Index ein. Aufzurufen im
// selben Zug wie Store.CreateAPIKey auf der jeweiligen Workspace-Datei — ohne
// diesen Eintrag kann der Key künftig nicht mehr seinem Workspace zugeordnet
// werden, bevor dessen Datei bekannt ist.
func (c *ControlStore) IndexKey(ctx context.Context, hash, workspaceID string) error {
_, err := c.schreiben.ExecContext(ctx,
`INSERT INTO key_index (hash, workspace_id, created_at) VALUES (?, ?, ?)`,
hash, workspaceID, NowString(),
)
if err != nil {
return fmt.Errorf("Key konnte nicht im Routing-Index vermerkt werden: %w", err)
}
return nil
}
// RemoveKeyIndex entfernt einen Key-Hash aus dem Routing-Index. Aufzurufen im
// selben Zug wie Store.RevokeAPIKey.
func (c *ControlStore) RemoveKeyIndex(ctx context.Context, hash string) error {
_, err := c.schreiben.ExecContext(ctx,
`DELETE FROM key_index WHERE hash = ?`, hash,
)
if err != nil {
return fmt.Errorf("Key konnte nicht aus dem Routing-Index entfernt werden: %w", err)
}
return nil
}
// ResolveKeyWorkspace schlägt nach, zu welchem Workspace ein Key-Hash gehört.
//
// Das ist der erste Schritt jeder Key-Auflösung, sobald mehrere
// Workspace-Dateien gleichzeitig offen sein können (siehe AUSBAUSTUFEN.md 4.4,
// WP4/WP5): erst hier lässt sich feststellen, welche Datei überhaupt
// angefasst werden muss, bevor Store.ResolveKeyHash dort wie gewohnt läuft.
func (c *ControlStore) ResolveKeyWorkspace(ctx context.Context, hash string) (workspaceID string, err error) {
err = c.lesen.QueryRowContext(ctx,
`SELECT workspace_id FROM key_index WHERE hash = ?`, hash,
).Scan(&workspaceID)
if errors.Is(err, sql.ErrNoRows) {
return "", ErrNotFound
}
if err != nil {
return "", fmt.Errorf("Workspace konnte nicht zum Key aufgelöst werden: %w", err)
}
return workspaceID, nil
}