package api import ( "encoding/json" "errors" "log/slog" "net/http" "strings" "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" ) // chainNode is the lightweight shape returned by /chain — we deliberately // don't return full plugin.Workload values for ancestor/descendant rows // because the secret fields don't belong in a chain-traversal response. type chainNode struct { ID string `json:"id"` Name string `json:"name"` SourceKind string `json:"source_kind"` TriggerKind string `json:"trigger_kind"` CreatedAt string `json:"created_at"` UpdatedAt string `json:"updated_at"` } func chainNodeOf(w store.Workload) chainNode { return chainNode{ ID: w.ID, Name: w.Name, SourceKind: w.SourceKind, TriggerKind: w.TriggerKind, CreatedAt: w.CreatedAt, UpdatedAt: w.UpdatedAt, } } // getWorkloadChain handles GET /api/workloads/{id}/chain. // // Returns the workload's parent (or nil), itself, and its direct children // — i.e. one hop in each direction along the parent_workload_id graph. // Deeper traversal is left to the client: the chain is a tree the user // builds incrementally, and a server-side recursive walk would surprise // operators with O(N) loads on big graphs. func (s *Server) getWorkloadChain(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") self, 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 } var parent *chainNode if self.ParentWorkloadID != "" { p, err := s.store.GetWorkloadByID(self.ParentWorkloadID) if err == nil { node := chainNodeOf(p) parent = &node } else if !errors.Is(err, store.ErrNotFound) { slog.Warn("chain: parent lookup failed", "workload", id, "parent", self.ParentWorkloadID, "error", err) } } childRows, err := s.store.ListChildrenByParent(self.ID) if err != nil { respondError(w, http.StatusInternalServerError, "list children") return } children := make([]chainNode, 0, len(childRows)) for _, c := range childRows { children = append(children, chainNodeOf(c)) } respondJSON(w, http.StatusOK, map[string]any{ "parent": parent, "self": chainNodeOf(self), "children": children, }) } // promoteFromRequest is the body of /promote-from. ImageTag is optional — // when blank the server falls back to whatever tag the source workload's // most recent running container reports. The endpoint is intentionally // non-destructive: it updates the SourceConfig.default_tag and queues a // manual deploy. It does not change parent_workload_id. type promoteFromRequest struct { ImageTag string `json:"image_tag"` Deploy bool `json:"deploy"` } // promoteFromWorkload handles POST /api/workloads/{id}/promote-from/{sourceID}. // // Copies the source workload's currently-running image tag into the // target's SourceConfig.default_tag, optionally triggering an immediate // deploy. The target's existing config blob is preserved aside from the // promoted field. Both workloads must use the same source_kind (image) // — promoting across kinds is undefined and rejected. func (s *Server) promoteFromWorkload(w http.ResponseWriter, r *http.Request) { targetID := chi.URLParam(r, "id") sourceID := chi.URLParam(r, "sourceID") if targetID == sourceID { respondError(w, http.StatusBadRequest, "target and source must differ") return } target, err := s.store.GetWorkloadByID(targetID) if err != nil { if errors.Is(err, store.ErrNotFound) { respondNotFound(w, "workload") return } respondError(w, http.StatusInternalServerError, "get target workload") return } source, err := s.store.GetWorkloadByID(sourceID) if err != nil { if errors.Is(err, store.ErrNotFound) { respondNotFound(w, "source workload") return } respondError(w, http.StatusInternalServerError, "get source workload") return } if target.SourceKind != "image" || source.SourceKind != "image" { respondError(w, http.StatusBadRequest, "promote-from is only defined for image source workloads on both ends") return } var req promoteFromRequest if r.ContentLength > 0 { if !decodeJSONStrict(w, r, &req) { return } } // Resolve the tag: explicit override wins; otherwise pick the running // container's image_tag on the source workload. tag := strings.TrimSpace(req.ImageTag) if tag == "" { rows, err := s.store.ListContainersByWorkload(sourceID) if err != nil { respondError(w, http.StatusInternalServerError, "list source containers") return } for _, c := range rows { if c.State == "running" && c.ImageTag != "" { tag = c.ImageTag break } } if tag == "" { respondError(w, http.StatusBadRequest, "source workload has no running container; specify image_tag explicitly") return } } // Decode target source_config, patch default_tag, re-encode. cfg := map[string]any{} if target.SourceConfig != "" && target.SourceConfig != "{}" { if err := json.Unmarshal([]byte(target.SourceConfig), &cfg); err != nil { respondError(w, http.StatusInternalServerError, "decode target source_config") return } } cfg["default_tag"] = tag patched, err := json.Marshal(cfg) if err != nil { respondError(w, http.StatusInternalServerError, "encode target source_config") return } target.SourceConfig = string(patched) if err := s.store.UpdateWorkload(target); err != nil { slog.Error("promote: update target", "target", targetID, "error", err) respondError(w, http.StatusInternalServerError, "update target workload") return } actor := "promote" if claims, ok := auth.ClaimsFromContext(r.Context()); ok && claims.Username != "" { actor = claims.Username } resp := map[string]any{ "workload_id": targetID, "source_id": sourceID, "promoted_tag": tag, "deploy_queued": false, } if req.Deploy { intent := plugin.DeploymentIntent{ Reason: "promote", Reference: tag, Metadata: map[string]string{"source_workload_id": sourceID}, TriggeredAt: time.Now().UTC(), TriggeredBy: actor, } if err := s.deployer.DispatchPlugin(r.Context(), toPluginWorkload(target), intent); err != nil { slog.Warn("promote: dispatch failed", "target", targetID, "error", err) respondError(w, http.StatusInternalServerError, "dispatch failed; see server logs") return } resp["deploy_queued"] = true } respondJSON(w, http.StatusOK, resp) }