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() }