Files
Agent-System/internal/maintenance/store.go
T
weichen 86e9d3706b feat(sysadmin): admin env settings + maintenance FD handoff
Persist agent/secrets settings with masked secrets and runner hot-reload;
add maintenance windows, maintpage binary, and zero-downtime listener handoff.
2026-09-24 23:20:20 +00:00

196 lines
5.3 KiB
Go

package maintenance
import (
"context"
"errors"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
var (
ErrNotFound = errors.New("maintenance: window not found")
ErrConflictOpen = errors.New("maintenance: an open window already exists")
ErrInvalidWindow = errors.New("maintenance: invalid schedule")
ErrBadStatus = errors.New("maintenance: invalid status transition")
)
const (
StatusScheduled = "scheduled"
StatusActive = "active"
StatusEnded = "ended"
StatusCancelled = "cancelled"
)
// Window 维护窗口行。
type Window struct {
ID int64 `json:"id"`
ScheduledStart time.Time `json:"scheduledStart"`
ScheduledEnd time.Time `json:"scheduledEnd"`
Message string `json:"message"`
Status string `json:"status"`
CreatedBy *int64 `json:"createdBy,omitempty"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
ActivatedAt *time.Time `json:"activatedAt,omitempty"`
EndedAt *time.Time `json:"endedAt,omitempty"`
}
// Store 持久化接口。
type Store interface {
GetOpen(ctx context.Context) (*Window, error)
GetByID(ctx context.Context, id int64) (*Window, error)
Insert(ctx context.Context, w Window) (Window, error)
UpdateStatus(ctx context.Context, id int64, from, to string) (Window, error)
ListRecent(ctx context.Context, limit int) ([]Window, error)
}
type pgStore struct {
pool *pgxpool.Pool
}
func NewPGStore(pool *pgxpool.Pool) Store {
return &pgStore{pool: pool}
}
func scanWindow(row pgx.Row) (Window, error) {
var w Window
var createdBy *int64
var activated, ended *time.Time
err := row.Scan(
&w.ID, &w.ScheduledStart, &w.ScheduledEnd, &w.Message, &w.Status,
&createdBy, &w.CreatedAt, &w.UpdatedAt, &activated, &ended,
)
if err != nil {
return Window{}, err
}
w.CreatedBy = createdBy
w.ActivatedAt = activated
w.EndedAt = ended
return w, nil
}
func (s *pgStore) GetOpen(ctx context.Context) (*Window, error) {
w, err := scanWindow(s.pool.QueryRow(ctx, `
SELECT id, scheduled_start, scheduled_end, message, status,
created_by, created_at, updated_at, activated_at, ended_at
FROM maintenance_windows
WHERE status IN ('scheduled', 'active')
ORDER BY scheduled_start ASC
LIMIT 1
`))
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
return nil, err
}
return &w, nil
}
func (s *pgStore) GetByID(ctx context.Context, id int64) (*Window, error) {
w, err := scanWindow(s.pool.QueryRow(ctx, `
SELECT id, scheduled_start, scheduled_end, message, status,
created_by, created_at, updated_at, activated_at, ended_at
FROM maintenance_windows WHERE id = $1
`, id))
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
return nil, err
}
return &w, nil
}
func (s *pgStore) Insert(ctx context.Context, in Window) (Window, error) {
w, err := scanWindow(s.pool.QueryRow(ctx, `
INSERT INTO maintenance_windows (scheduled_start, scheduled_end, message, status, created_by)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, scheduled_start, scheduled_end, message, status,
created_by, created_at, updated_at, activated_at, ended_at
`, in.ScheduledStart, in.ScheduledEnd, in.Message, StatusScheduled, in.CreatedBy))
if err != nil {
// unique partial index → conflict
if isUniqueViolation(err) {
return Window{}, ErrConflictOpen
}
return Window{}, err
}
return w, nil
}
func (s *pgStore) UpdateStatus(ctx context.Context, id int64, from, to string) (Window, error) {
var q string
switch to {
case StatusActive:
q = `
UPDATE maintenance_windows
SET status = $2, updated_at = now(), activated_at = now()
WHERE id = $1 AND status = $3
RETURNING id, scheduled_start, scheduled_end, message, status,
created_by, created_at, updated_at, activated_at, ended_at`
case StatusEnded, StatusCancelled:
q = `
UPDATE maintenance_windows
SET status = $2, updated_at = now(), ended_at = now()
WHERE id = $1 AND status = $3
RETURNING id, scheduled_start, scheduled_end, message, status,
created_by, created_at, updated_at, activated_at, ended_at`
default:
return Window{}, ErrBadStatus
}
w, err := scanWindow(s.pool.QueryRow(ctx, q, id, to, from))
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return Window{}, ErrBadStatus
}
return Window{}, err
}
return w, nil
}
func (s *pgStore) ListRecent(ctx context.Context, limit int) ([]Window, error) {
if limit <= 0 {
limit = 20
}
rows, err := s.pool.Query(ctx, `
SELECT id, scheduled_start, scheduled_end, message, status,
created_by, created_at, updated_at, activated_at, ended_at
FROM maintenance_windows
ORDER BY scheduled_start DESC
LIMIT $1
`, limit)
if err != nil {
return nil, err
}
defer rows.Close()
out := make([]Window, 0)
for rows.Next() {
w, err := scanWindow(rows)
if err != nil {
return nil, err
}
out = append(out, w)
}
return out, rows.Err()
}
func isUniqueViolation(err error) bool {
return err != nil && (contains(err.Error(), "uq_maintenance_windows_one_open") || contains(err.Error(), "unique"))
}
func contains(s, sub string) bool {
return len(s) >= len(sub) && (s == sub || len(sub) == 0 || indexOf(s, sub) >= 0)
}
func indexOf(s, sub string) int {
for i := 0; i+len(sub) <= len(s); i++ {
if s[i:i+len(sub)] == sub {
return i
}
}
return -1
}