Files
imap-copier/internal/httpapi/run.go
T
vasyanskandClaude Opus 5 039ac2f1da Add pause and cancel to a running migration
A live run could only be stopped one account at a time, and stopping it
at all meant losing the queue: the accounts that had not started yet
stayed idle with no record that they were meant to run.

A run now carries a handle holding the context that stops every account
under it plus the reason it was stopped. Pause and cancel take the same
path and differ only in the status left behind — paused accounts are
what Resume re-runs, and the migration journal makes each one continue
where it stopped instead of re-copying. Accounts still queued when the
stop lands get the same status as the interrupted ones, so the whole
remainder is resumable after a pause and cancelled after a cancel.
Database writes keep using the uncancellable context, so statuses and
counters survive the stop.

The scheduler skips paused tasks: auto-starting a full run would defeat
the pause. An operator stopping a run no longer trips the schedule
breaker either — that is for failures, not for intent.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 11:20:52 +07:00

262 lines
7.8 KiB
Go

package httpapi
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"net/http"
"github.com/vasyansk/imap-copier/internal/crypto"
"github.com/vasyansk/imap-copier/internal/csvimport"
"github.com/vasyansk/imap-copier/internal/orchestrator"
"github.com/vasyansk/imap-copier/internal/store"
)
// parseImportRows picks the CSV dialect from the "format" form field. A Kerio
// Connect export holds one login/password pair and no domain, so the operator
// supplies the domain alongside the file; anything else is the plain 4-column
// src/dst format.
func parseImportRows(r *http.Request, file io.Reader) ([]csvimport.Row, error) {
if r.FormValue("format") == "kerio" {
return csvimport.ParseKerio(file, r.FormValue("domain"))
}
return csvimport.Parse(file)
}
func (s *Server) handleImportCSV(w http.ResponseWriter, r *http.Request) {
taskID, err := pathID(r, "id")
if err != nil {
http.Error(w, "bad id", http.StatusBadRequest)
return
}
file, _, err := r.FormFile("file")
if err != nil {
http.Error(w, "file required", http.StatusBadRequest)
return
}
defer file.Close()
rows, err := parseImportRows(r, file)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
for _, row := range rows {
srcEnc, err := crypto.Encrypt(s.cfg.EncKey, []byte(row.SrcPass))
if err != nil {
http.Error(w, "encrypt", http.StatusInternalServerError)
return
}
dstEnc, err := crypto.Encrypt(s.cfg.EncKey, []byte(row.DstPass))
if err != nil {
http.Error(w, "encrypt", http.StatusInternalServerError)
return
}
if _, err := s.store.CreateAccount(r.Context(), store.Account{
TaskID: taskID, SrcLogin: row.SrcLogin, SrcPassEnc: srcEnc,
DstLogin: row.DstLogin, DstPassEnc: dstEnc,
}); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
}
writeJSON(w, http.StatusCreated, map[string]int{"imported": len(rows)})
}
func (s *Server) handleTestAccounts(w http.ResponseWriter, r *http.Request) {
taskID, err := pathID(r, "id")
if err != nil {
http.Error(w, "bad id", http.StatusBadRequest)
return
}
// Detach from the request context: the request context is cancelled when
// this handler returns, which would otherwise kill the background test run.
ctx := context.WithoutCancel(r.Context())
go s.orch.TestAccounts(ctx, taskID) // прогресс через WS
w.WriteHeader(http.StatusAccepted)
}
// parseRunAccountIDs reads an optional {"account_ids":[...]} run body. An empty
// body means "all accounts" and yields a nil slice. Malformed JSON is an error.
func parseRunAccountIDs(r *http.Request) ([]int64, error) {
raw, err := io.ReadAll(r.Body)
if err != nil {
return nil, err
}
if len(bytes.TrimSpace(raw)) == 0 {
return nil, nil
}
var body struct {
AccountIDs []int64 `json:"account_ids"`
}
if err := json.Unmarshal(raw, &body); err != nil {
return nil, err
}
return body.AccountIDs, nil
}
func (s *Server) handleRun(w http.ResponseWriter, r *http.Request) {
taskID, err := pathID(r, "id")
if err != nil {
http.Error(w, "bad id", http.StatusBadRequest)
return
}
accountIDs, err := parseRunAccountIDs(r)
if err != nil {
http.Error(w, "bad request body", http.StatusBadRequest)
return
}
runID, err := s.orch.Run(r.Context(), taskID, "manual", accountIDs)
if errors.Is(err, orchestrator.ErrNoAccountsSelected) {
http.Error(w, "no matching accounts selected", http.StatusBadRequest)
return
}
if errors.Is(err, orchestrator.ErrNotTested) {
http.Error(w, "accounts must pass connection tests first", http.StatusConflict)
return
}
if errors.Is(err, orchestrator.ErrAlreadyRunning) {
http.Error(w, "task is already running", http.StatusConflict)
return
}
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, http.StatusAccepted, map[string]int64{"run_id": runID})
}
// handlePauseRun stops the live run but keeps the unfinished accounts
// resumable; handleCancelRun stops it for good. Both are no-ops (409) when the
// task has no run in flight.
func (s *Server) handlePauseRun(w http.ResponseWriter, r *http.Request) {
s.stopRun(w, r, s.orch.PauseTask)
}
func (s *Server) handleCancelRun(w http.ResponseWriter, r *http.Request) {
s.stopRun(w, r, s.orch.CancelTask)
}
func (s *Server) stopRun(w http.ResponseWriter, r *http.Request, stop func(int64) bool) {
taskID, err := pathID(r, "id")
if err != nil {
http.Error(w, "bad id", http.StatusBadRequest)
return
}
if !stop(taskID) {
http.Error(w, "task is not running", http.StatusConflict)
return
}
w.WriteHeader(http.StatusAccepted)
}
// handleResumeRun restarts a paused task with the accounts its pause left
// unfinished; already-copied messages are skipped by the migration journal.
func (s *Server) handleResumeRun(w http.ResponseWriter, r *http.Request) {
taskID, err := pathID(r, "id")
if err != nil {
http.Error(w, "bad id", http.StatusBadRequest)
return
}
runID, err := s.orch.ResumeTask(r.Context(), taskID)
switch {
case errors.Is(err, orchestrator.ErrNothingToResume):
http.Error(w, "no paused accounts to resume", http.StatusConflict)
return
case errors.Is(err, orchestrator.ErrNotTested):
http.Error(w, "accounts must pass connection tests first", http.StatusConflict)
return
case errors.Is(err, orchestrator.ErrAlreadyRunning):
http.Error(w, "task is already running", http.StatusConflict)
return
case err != nil:
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, http.StatusAccepted, map[string]int64{"run_id": runID})
}
func (s *Server) handleCancelAccount(w http.ResponseWriter, r *http.Request) {
taskID, err := pathID(r, "id")
if err != nil {
http.Error(w, "bad id", http.StatusBadRequest)
return
}
accID, err := pathID(r, "accountId")
if err != nil {
http.Error(w, "bad account id", http.StatusBadRequest)
return
}
// Live in-flight copy: signal it to stop.
if s.orch.CancelAccount(accID) {
w.WriteHeader(http.StatusAccepted)
return
}
// No live goroutine but the DB may still say "running" (stale state left by
// a crash/restart): clear it so the account/task become usable again.
cleared, err := s.store.ClearStuckAccount(r.Context(), accID)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if !cleared {
http.Error(w, "account is not running", http.StatusConflict)
return
}
if err := s.store.ReconcileTaskStatus(r.Context(), taskID); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusAccepted)
}
func (s *Server) handleDeleteTask(w http.ResponseWriter, r *http.Request) {
id, err := pathID(r, "id")
if err != nil {
http.Error(w, "bad id", http.StatusBadRequest)
return
}
task, err := s.store.GetTask(r.Context(), id)
if err != nil {
http.Error(w, "not found", http.StatusNotFound)
return
}
if task.Status == "running" {
http.Error(w, "cannot delete a running task", http.StatusConflict)
return
}
if err := s.store.DeleteTask(r.Context(), id); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) handleDeleteAccount(w http.ResponseWriter, r *http.Request) {
taskID, err := pathID(r, "id")
if err != nil {
http.Error(w, "bad id", http.StatusBadRequest)
return
}
accID, err := pathID(r, "accountId")
if err != nil {
http.Error(w, "bad account id", http.StatusBadRequest)
return
}
task, err := s.store.GetTask(r.Context(), taskID)
if err != nil {
http.Error(w, "not found", http.StatusNotFound)
return
}
if task.Status == "running" {
http.Error(w, "cannot modify accounts while task is running", http.StatusConflict)
return
}
if err := s.store.DeleteAccount(r.Context(), accID); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}