munin/internal/store/control.go
Thomas Adamek 466f1e40af Workspaces, Änderungshistorie, Papierkorb, Nutzungsübersicht, Hilfe
Umsetzung der Ausbaustufen 2.1, 3.6, 3.8, 3.9, 4.1, 4.2, 4.4, 4.6,
5.1–5.4 (Details in AUSBAUSTUFEN.md).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 07:44:52 +02:00

224 lines
7.6 KiB
Go

package store
import (
"context"
"database/sql"
"embed"
"errors"
"fmt"
)
// controlMigrationsFS enthält das Schema der zentralen Kontroll-Datenbank
// (siehe migrations_control/001_init.sql und AUSBAUSTUFEN.md 4.4).
//
//go:embed migrations_control/*.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 *sql.DB
schreiben *sql.DB
}
// OpenControl öffnet die Kontroll-Datenbank und bringt ihr Schema auf den
// aktuellen Stand.
func OpenControl(path string) (*ControlStore, error) {
lesen, schreiben, err := openPool(path, controlMigrationsFS, "migrations_control")
if err != nil {
return nil, err
}
return &ControlStore{lesen: lesen, schreiben: schreiben}, nil
}
// 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
}
// ── 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) {
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)
}
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
}
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
}