package http import ( stdhttp "net/http" "time" "github.com/go-chi/chi/v5" "github.com/oklog/ulid/v2" "gitea.dcglab.co.uk/steve/restic-manager/internal/api" "gitea.dcglab.co.uk/steve/restic-manager/internal/store" ) // handleCancelJob is POST /api/jobs/{id}/cancel. Sends a command.cancel // envelope to the host that owns the job; the agent kills the running // restic subprocess, and the resulting job.finished envelope (status = // canceled) is what actually transitions the job row — this handler // does not touch the jobs table directly. Returning 202 makes that // asynchronicity explicit. // // 4xx cases: // - job not found (404) // - job already in a terminal state (409 — nothing to cancel) // - host offline (503 — same code path the run-now endpoint uses) // // Audit-logged as job.cancel with the job ID as target. func (s *Server) handleCancelJob(w stdhttp.ResponseWriter, r *stdhttp.Request) { user, ok := s.requireUser(r) if !ok { writeJSONError(w, stdhttp.StatusUnauthorized, "unauthorized", "") return } jobID := chi.URLParam(r, "id") if jobID == "" { writeJSONError(w, stdhttp.StatusBadRequest, "missing_job_id", "") return } job, err := s.deps.Store.GetJob(r.Context(), jobID) if err != nil { writeJSONError(w, stdhttp.StatusNotFound, "job_not_found", "") return } switch api.JobStatus(job.Status) { case api.JobSucceeded, api.JobFailed, api.JobCancelled: writeJSONError(w, stdhttp.StatusConflict, "job_terminal", "job is already in a terminal state ("+job.Status+")") return } if !s.deps.Hub.Connected(job.HostID) { writeJSONError(w, stdhttp.StatusServiceUnavailable, "host_offline", "agent is not connected; can't deliver cancel signal") return } env, err := api.Marshal(api.MsgCommandCancel, jobID, api.CommandCancelPayload{ JobID: jobID, }) if err != nil { writeJSONError(w, stdhttp.StatusInternalServerError, "internal", "") return } if err := s.deps.Hub.Send(r.Context(), job.HostID, env); err != nil { writeJSONError(w, stdhttp.StatusServiceUnavailable, "host_offline", err.Error()) return } var actorID *string actor := "system" if user != nil { actor = "user" actorID = &user.ID } _ = s.deps.Store.AppendAudit(r.Context(), store.AuditEntry{ ID: ulid.Make().String(), UserID: actorID, Actor: actor, Action: "job.cancel", TargetKind: ptr("job"), TargetID: &jobID, TS: time.Now().UTC(), }) w.WriteHeader(stdhttp.StatusAccepted) }