store : SQLite (clusters à secrets chiffrés, paramètres, utilisateurs, journal, audit)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015a8P6WMRPeqdpd5VCh6jAy
This commit is contained in:
1 parent
e61f2e4740
commit
2bbfb5dfbe
5 files changed
+1079
No files matched your search
@@ -0,0 +1,192 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Run statuses.
|
||||
const (
|
||||
RunRunning = "running"
|
||||
RunOK = "ok" // applied and verified, or dry-run without blocking error
|
||||
RunNoop = "noop" // nothing to do
|
||||
RunError = "error" // at least one client failed
|
||||
RunBlocked = "blocked" // dry-run with blocking errors
|
||||
)
|
||||
|
||||
// Run is one entry of the execution journal: a dry-run or a synchronization.
|
||||
type Run struct {
|
||||
ID int64 `json:"id"`
|
||||
StartedAt time.Time `json:"startedAt"`
|
||||
FinishedAt *time.Time `json:"finishedAt,omitempty"`
|
||||
User string `json:"user"`
|
||||
Mode string `json:"mode"` // dry-run or apply
|
||||
Scope string `json:"scope"` // global, client or object
|
||||
Clients []string `json:"clients"`
|
||||
Objects []string `json:"objects,omitempty"`
|
||||
Status string `json:"status"`
|
||||
Summary string `json:"summary"`
|
||||
Detail json.RawMessage `json:"detail,omitempty"`
|
||||
}
|
||||
|
||||
// StartRun records a run and returns its id.
|
||||
func (s *Store) StartRun(ctx context.Context, r Run) (int64, error) {
|
||||
if r.StartedAt.IsZero() {
|
||||
r.StartedAt = time.Now()
|
||||
}
|
||||
res, err := s.db.ExecContext(ctx, `INSERT INTO runs (started_at, user, mode, scope, clients, objects, status, summary, detail)
|
||||
VALUES (?,?,?,?,?,?,?,?,?)`, r.StartedAt.UnixMilli(), r.User, r.Mode, r.Scope,
|
||||
strings.Join(r.Clients, ","), strings.Join(r.Objects, ","), r.Status, r.Summary, detail(r.Detail))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return res.LastInsertId()
|
||||
}
|
||||
|
||||
// UpdateRun stores the progress (detail) of a run; a final status sets finished_at.
|
||||
func (s *Store) UpdateRun(ctx context.Context, id int64, status, summary string, d json.RawMessage) error {
|
||||
fin := int64(0)
|
||||
if status != RunRunning {
|
||||
fin = time.Now().UnixMilli()
|
||||
}
|
||||
_, err := s.db.ExecContext(ctx, `UPDATE runs SET status = ?, summary = ?, detail = ?, finished_at = ? WHERE id = ?`,
|
||||
status, summary, detail(d), fin, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func detail(d json.RawMessage) string {
|
||||
if len(d) == 0 {
|
||||
return "{}"
|
||||
}
|
||||
return string(d)
|
||||
}
|
||||
|
||||
// Runs lists the journal, newest first; before (an id) pages backwards.
|
||||
func (s *Store) Runs(ctx context.Context, limit int, before int64) ([]Run, error) {
|
||||
if limit <= 0 || limit > 500 {
|
||||
limit = 100
|
||||
}
|
||||
if before <= 0 {
|
||||
before = 1 << 62
|
||||
}
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT id, started_at, finished_at, user, mode, scope, clients, objects, status, summary
|
||||
FROM runs WHERE id < ? ORDER BY id DESC LIMIT ?`, before, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []Run
|
||||
for rows.Next() {
|
||||
r, err := scanRun(rows.Scan, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// Run returns one run with its detail.
|
||||
func (s *Store) Run(ctx context.Context, id int64) (Run, error) {
|
||||
row := s.db.QueryRowContext(ctx, `SELECT id, started_at, finished_at, user, mode, scope, clients, objects, status, summary, detail
|
||||
FROM runs WHERE id = ?`, id)
|
||||
r, err := scanRun(row.Scan, true)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return r, ErrNotFound
|
||||
}
|
||||
return r, err
|
||||
}
|
||||
|
||||
// FailRunning closes the runs left "running" by a stop of the server.
|
||||
func (s *Store) FailRunning(ctx context.Context) error {
|
||||
_, err := s.db.ExecContext(ctx, `UPDATE runs SET status = 'error', summary = 'interrompu (arrêt du serveur)', finished_at = ?
|
||||
WHERE status = 'running'`, time.Now().UnixMilli())
|
||||
return err
|
||||
}
|
||||
|
||||
func scanRun(scan func(...any) error, withDetail bool) (Run, error) {
|
||||
var r Run
|
||||
var start, fin int64
|
||||
var clients, objects, d string
|
||||
dest := []any{&r.ID, &start, &fin, &r.User, &r.Mode, &r.Scope, &clients, &objects, &r.Status, &r.Summary}
|
||||
if withDetail {
|
||||
dest = append(dest, &d)
|
||||
}
|
||||
if err := scan(dest...); err != nil {
|
||||
return r, err
|
||||
}
|
||||
r.StartedAt = time.UnixMilli(start).UTC()
|
||||
if fin > 0 {
|
||||
t := time.UnixMilli(fin).UTC()
|
||||
r.FinishedAt = &t
|
||||
}
|
||||
r.Clients = split(clients)
|
||||
r.Objects = split(objects)
|
||||
if withDetail {
|
||||
r.Detail = json.RawMessage(d)
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
func split(s string) []string {
|
||||
if s == "" {
|
||||
return []string{}
|
||||
}
|
||||
return strings.Split(s, ",")
|
||||
}
|
||||
|
||||
// ---- audit ----
|
||||
|
||||
// Event is one entry of the audit trail: who did what, from where, with which result.
|
||||
type Event struct {
|
||||
ID int64 `json:"id"`
|
||||
At time.Time `json:"at"`
|
||||
User string `json:"user"`
|
||||
Role string `json:"role"`
|
||||
IP string `json:"ip"`
|
||||
Action string `json:"action"`
|
||||
Target string `json:"target"`
|
||||
OK bool `json:"ok"`
|
||||
Detail string `json:"detail"`
|
||||
}
|
||||
|
||||
// Audit records an event. Callers never put secrets in Detail.
|
||||
func (s *Store) Audit(ctx context.Context, e Event) error {
|
||||
if e.At.IsZero() {
|
||||
e.At = time.Now()
|
||||
}
|
||||
_, err := s.db.ExecContext(ctx, `INSERT INTO audit (at, user, role, ip, action, target, ok, detail) VALUES (?,?,?,?,?,?,?,?)`,
|
||||
e.At.UnixMilli(), e.User, e.Role, e.IP, e.Action, e.Target, e.OK, e.Detail)
|
||||
return err
|
||||
}
|
||||
|
||||
// Events lists the audit trail, newest first.
|
||||
func (s *Store) Events(ctx context.Context, limit int, before int64) ([]Event, error) {
|
||||
if limit <= 0 || limit > 1000 {
|
||||
limit = 200
|
||||
}
|
||||
if before <= 0 {
|
||||
before = 1 << 62
|
||||
}
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT id, at, user, role, ip, action, target, ok, detail FROM audit
|
||||
WHERE id < ? ORDER BY id DESC LIMIT ?`, before, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []Event
|
||||
for rows.Next() {
|
||||
var e Event
|
||||
var at int64
|
||||
if err := rows.Scan(&e.ID, &at, &e.User, &e.Role, &e.IP, &e.Action, &e.Target, &e.OK, &e.Detail); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
e.At = time.UnixMilli(at).UTC()
|
||||
out = append(out, e)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
Reference in new issue
Block a user