package api import ( "errors" "log/slog" "net/http" "strconv" "time" "github.com/go-chi/chi/v5" "github.com/alexei/tinyforge/internal/auth" "github.com/alexei/tinyforge/internal/store" "github.com/alexei/tinyforge/internal/workload/plugin" ) // parseOffset parses a pagination offset, clamping anything invalid or // negative to 0. parseLimit (secrets.go) handles the limit half. func parseOffset(raw string) int { n, err := strconv.Atoi(raw) if err != nil || n < 0 { return 0 } return n } // rollbackCapableKinds is the single source of truth for which source kinds // support reference-pinned redeploy. The image source resolves // intent.Reference as the tag, so replaying a prior tag is a real rollback. // static/dockerfile clone branch HEAD and cannot yet check out an arbitrary // commit (a later phase); compose has no single artifact handle. var rollbackCapableKinds = map[string]bool{"image": true} // RollbackCapable reports whether a source kind supports one-click rollback. // Used by both the list response (per-row `rollbackable` flag) and the // rollback guard so the UI and the server never disagree. func RollbackCapable(sourceKind string) bool { return rollbackCapableKinds[sourceKind] } // listWorkloadDeploys handles GET /api/workloads/{id}/deploys. Read-only, // open to any authenticated user (mirrors the per-workload events feed). // Returns the structured deploy ledger newest-first with a server-computed // `rollbackable` flag per row. func (s *Server) listWorkloadDeploys(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") if id == "" { respondError(w, http.StatusBadRequest, "workload id is required") return } q := r.URL.Query() limit := parseLimit(q.Get("limit"), 50, 200) offset := parseOffset(q.Get("offset")) rows, err := s.store.ListDeployHistory(id, limit, offset) if err != nil { slog.Error("failed to list deploy history", "workload", id, "error", err) respondError(w, http.StatusInternalServerError, "failed to list deploy history") return } for i := range rows { rows[i].Rollbackable = rows[i].Outcome == "success" && rows[i].Reference != "" && RollbackCapable(rows[i].SourceKind) } respondJSON(w, http.StatusOK, rows) } // rollbackWorkload handles POST /api/workloads/{id}/rollback. Admin-only // (same gate as /deploy). Body: {"deploy_id": }. It resolves the pinned // reference from a prior successful, rollback-capable ledger row belonging // to this workload and replays it as a `rollback`-reason deploy. func (s *Server) rollbackWorkload(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") row, err := s.store.GetWorkloadByID(id) if err != nil { if errors.Is(err, store.ErrNotFound) { respondNotFound(w, "workload") return } respondError(w, http.StatusInternalServerError, "get workload") return } if row.SourceKind == "" { respondError(w, http.StatusBadRequest, "workload has no source_kind; cannot roll back") return } var body struct { DeployID int64 `json:"deploy_id"` } if !decodeJSONStrict(w, r, &body) { return } if body.DeployID <= 0 { respondError(w, http.StatusBadRequest, "deploy_id is required") return } entry, err := s.store.GetDeployHistory(body.DeployID) if err != nil { if errors.Is(err, store.ErrNotFound) { respondNotFound(w, "deploy history entry") return } respondError(w, http.StatusInternalServerError, "get deploy history") return } // No cross-workload replay: the entry must belong to the path workload. if entry.WorkloadID != id { respondError(w, http.StatusBadRequest, "deploy entry does not belong to this workload") return } if entry.Outcome != "success" { respondError(w, http.StatusBadRequest, "cannot roll back to a failed deploy") return } if entry.Reference == "" || !RollbackCapable(row.SourceKind) { respondError(w, http.StatusBadRequest, "this deploy is not rollback-capable") return } actor := "manual" if claims, ok := auth.ClaimsFromContext(r.Context()); ok && claims.Username != "" { actor = claims.Username } intent := plugin.DeploymentIntent{ Reason: "rollback", Reference: entry.Reference, Metadata: map[string]string{ "note": "rollback to " + entry.Reference, "rollback_of": strconv.FormatInt(entry.ID, 10), }, TriggeredAt: time.Now().UTC(), TriggeredBy: actor, } if err := s.deployer.DispatchPlugin(r.Context(), toPluginWorkload(row), intent); err != nil { // Raw error stays in the server log; client gets a generic message // (the wrapped error can carry registry-auth bytes). slog.Warn("rollback dispatch failed", "workload", id, "actor", actor, "reference", entry.Reference, "error", err) respondError(w, http.StatusInternalServerError, "rollback failed; see server logs") return } respondJSON(w, http.StatusAccepted, map[string]any{ "workload_id": id, "reference": entry.Reference, "rollback_of": entry.ID, "triggered_by": actor, }) }