275 lines
9.7 KiB
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
|
|
}
|