Files
darroyo 84b185be39 Phase A-E: stability, security, observability, and test coverage
Phase A - Stability:
- Engine.Start(): recovers orphaned jobs (running/queued/waking_up) after crash
- Engine.Stop(): graceful shutdown - cancels all in-flight jobs and waits
- Queue keyed by jobID (not syncPairID): cancel now targets exact job
- Local rsync uses jobCtx (context.Background() replaced)
- Migrations wrapped in transactions; checksums stored

Phase B - Security:
- admin/admin default removed: SYNCSERVER_ADMIN_PASSWORD required on first run
- Path validation: rejects .., leading -, null bytes in sync pair paths
- Rsync flags allowlist: dangerous flags blocked (--rsync-path, -e, --files-from)
- Shell concat in RunRemote replaced with proper sh -c escaping
- knownhosts: replaced custom parser with golang.org/x/crypto/ssh/knownhosts
- RequireAdmin wired: machine CRUD, SSH key ops, settings require admin role
- deploy-keys: uses authorized_keys only (no private key upload)
- Hardcoded /var/lib/syncserver/ssh paths replaced with cfg.SSHDir()

Phase C - Operational:
- /readyz health check: DB query + SSH dir accessibility
- /metrics endpoint: Prometheus text format (jobs, queue, machines)
- Event struct JSON tags: job_id, machine_id, type (snake_case)
- EventBus broadcast: fanned out to all subscribers
- SQLite VACUUM INTO backup: scheduled before cleanup if BackupDir set
- Filesystem job log cleanup: removes .log files for purged jobs
- Backup retention: old backups auto-purged

Phase D - Frontend:
- Schedules page: REST API + full CRUD UI for cron schedules
- Dashboard: cancel button for running/queued jobs
- JobDetail: server-side log download via API
- Settings: displays data_dir from server
- 404 page: proper NotFound component

Phase E - Tests:
- auth_test.go: JWT, bcrypt, middleware, seed (18 tests)
- models_test.go: Job, Machine, SyncPair, Schedule repos (18 tests)
- go test -race: no data races found
2026-07-19 22:14:30 -04:00

272 lines
6.8 KiB
Go

package api
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"os"
"strconv"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/syncserver/internal/models"
"github.com/syncserver/internal/syncengine"
)
type JobHandler struct {
db *sql.DB
engine *syncengine.Engine
}
func NewJobHandler(db *sql.DB, engine *syncengine.Engine) *JobHandler {
return &JobHandler{db: db, engine: engine}
}
func (h *JobHandler) List(w http.ResponseWriter, r *http.Request) {
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
offset, _ := strconv.Atoi(r.URL.Query().Get("offset"))
if limit <= 0 || limit > 100 {
limit = 50
}
var syncPairID *int64
if spidStr := r.URL.Query().Get("sync_pair_id"); spidStr != "" {
if spid, err := strconv.ParseInt(spidStr, 10, 64); err == nil {
syncPairID = &spid
}
}
status := r.URL.Query().Get("status")
triggerType := r.URL.Query().Get("trigger_type")
var from, to *time.Time
if fromStr := r.URL.Query().Get("from"); fromStr != "" {
if t, err := time.Parse(time.RFC3339, fromStr); err == nil {
from = &t
}
}
if toStr := r.URL.Query().Get("to"); toStr != "" {
if t, err := time.Parse(time.RFC3339, toStr); err == nil {
to = &t
}
}
repo := models.NewJobLogRepository(h.db)
jobs, total, err := repo.GetAllFiltered(limit, offset, syncPairID, status, triggerType, from, to)
if err != nil {
slog.Error("failed to fetch jobs", "error", err)
writeError(w, http.StatusInternalServerError, "failed to fetch jobs")
return
}
out := make([]JobResponse, len(jobs))
for i, j := range jobs {
out[i] = jobWithStatsToResp(j)
}
w.Header().Set("X-Total-Count", fmt.Sprintf("%d", total))
writeJSON(w, out)
}
func (h *JobHandler) Get(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid id")
return
}
repo := models.NewJobRepository(h.db)
j, err := repo.GetByID(id)
if err == sql.ErrNoRows {
writeError(w, http.StatusNotFound, "job not found")
return
}
if err != nil {
slog.Error("failed to fetch job", "id", id, "error", err)
writeError(w, http.StatusInternalServerError, "failed to fetch job")
return
}
writeJSON(w, jobToResp(*j))
}
func (h *JobHandler) Cancel(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid id")
return
}
repo := models.NewJobRepository(h.db)
j, err := repo.GetByID(id)
if err == sql.ErrNoRows {
writeError(w, http.StatusNotFound, "job not found")
return
}
if err != nil {
slog.Error("failed to fetch job", "id", id, "error", err)
writeError(w, http.StatusInternalServerError, "failed to fetch job")
return
}
if j.Status != "queued" && j.Status != "waking_up" && j.Status != "running" {
writeError(w, http.StatusBadRequest, "job is not cancellable")
return
}
var body struct {
Reason string `json:"reason"`
}
if r.Body != nil && r.ContentLength > 0 {
_ = json.NewDecoder(r.Body).Decode(&body)
}
reason := strings.TrimSpace(body.Reason)
if reason == "" {
reason = "Job was cancelled by user"
}
if h.engine != nil {
h.engine.Cancel(id, true)
}
repo.UpdateStatus(id, "cancelled")
repo.SetError(id, "cancelled_user", reason)
writeJSON(w, map[string]string{"status": "cancelled"})
}
func (h *JobHandler) TriggerRun(w http.ResponseWriter, r *http.Request) {
pairID, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid id")
return
}
if h.engine == nil {
writeError(w, http.StatusInternalServerError, "engine not available")
return
}
jobID, err := h.engine.CreateJob(pairID, "manual")
if err != nil {
slog.Error("failed to create job", "pair_id", pairID, "error", err)
writeError(w, http.StatusInternalServerError, "failed to create job")
return
}
go func() {
defer func() {
if r := recover(); r != nil {
slog.Error("job run goroutine panicked", "job_id", jobID, "panic", r)
}
}()
h.engine.Run(context.Background(), jobID, pairID)
}()
jobRepo := models.NewJobRepository(h.db)
j, err := jobRepo.GetByID(jobID)
if err != nil {
slog.Error("failed to fetch created job", "job_id", jobID, "error", err)
writeError(w, http.StatusInternalServerError, "failed to fetch created job")
return
}
writeJSON(w, jobToResp(*j), http.StatusCreated)
}
func (h *JobHandler) GetLog(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid id")
return
}
offset, _ := strconv.Atoi(r.URL.Query().Get("offset"))
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
if limit <= 0 {
limit = 1000
}
logRepo := models.NewJobLogRepository(h.db)
logs, err := logRepo.GetByJobID(id, limit, offset)
if err != nil {
slog.Error("failed to fetch logs", "id", id, "error", err)
writeError(w, http.StatusInternalServerError, "failed to fetch logs")
return
}
count, _ := logRepo.CountByJobID(id)
w.Header().Set("X-Total-Count", fmt.Sprintf("%d", count))
if logs == nil {
writeJSON(w, []any{})
} else {
writeJSON(w, logs)
}
}
func (h *JobHandler) DownloadLog(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid id")
return
}
jobRepo := models.NewJobRepository(h.db)
j, err := jobRepo.GetByID(id)
if err != nil {
slog.Error("failed to fetch job", "id", id, "error", err)
writeError(w, http.StatusInternalServerError, "failed to fetch job")
return
}
if j.LogFile != nil {
data, err := os.ReadFile(*j.LogFile)
if err == nil {
w.Header().Set("Content-Type", "text/plain")
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="job-%d.log"`, id))
w.Write(data)
return
}
}
logRepo := models.NewJobLogRepository(h.db)
logs, _ := logRepo.GetByJobID(id, 100000, 0)
for _, l := range logs {
fmt.Fprintf(w, "[%s] %s\n", l.Timestamp.Format(time.RFC3339), l.Content)
}
}
func jobToResp(j models.Job) JobResponse {
resp := JobResponse{
ID: j.ID,
SyncPairID: j.SyncPairID,
TriggerType: j.TriggerType,
Status: j.Status,
LogFile: j.LogFile,
ErrorMessage: j.ErrorMessage,
ErrorCode: j.ErrorCode,
}
if j.StartedAt != nil {
s := j.StartedAt.Format(time.RFC3339)
resp.StartedAt = &s
}
if j.FinishedAt != nil {
s := j.FinishedAt.Format(time.RFC3339)
resp.FinishedAt = &s
}
if j.TotalSizeBytes > 0 {
resp.TotalSizeBytes = &j.TotalSizeBytes
}
if j.SentBytes > 0 {
resp.SentBytes = &j.SentBytes
}
return resp
}
func jobWithStatsToResp(j models.JobWithStats) JobResponse {
resp := jobToResp(j.Job)
resp.DurationSeconds = j.DurationSeconds
resp.LogLineCount = &j.LogLineCount
return resp
}