Persist agent/secrets settings with masked secrets and runner hot-reload; add maintenance windows, maintpage binary, and zero-downtime listener handoff.
196 lines
5.3 KiB
Go
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
|
|
}
|