Files
patel-lyzr 6b1a13ecdc feat: implement environment management features
- Added environment creation and editing pages with forms for name and description.
- Integrated environment listing with options to edit and delete environments.
- Updated agent detail page to manage environments followed by agents.
- Enhanced API to support environment operations including listing, creating, updating, and deleting environments.
- Refactored related components and state management for improved clarity and functionality.
2026-05-13 22:15:08 +05:30

262 lines
7.9 KiB
Go

package api
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
"time"
"github.com/lyzrai/flow/pkg/storage"
)
// Environments API — global, named deploy stages. An environment is a
// sequencing container: it owns an ordered list of pipelines (the
// promotion sequence) plus a description. Per-deploy config lives on the
// nodes, not here. Agents subscribe to environments (see agents.go);
// dispatch fans out across the followed envs' pipelines.
//
// Routes:
// GET /api/environments
// POST /api/environments
// GET /api/environments/{name}
// PUT /api/environments/{name} (name + description)
// DELETE /api/environments/{name}
// POST /api/environments/{name}/pipelines/{pipelineId} (bring a pipeline in — appends)
// DELETE /api/environments/{name}/pipelines/{pipelineId} (remove a pipeline)
// PUT /api/environments/{name}/pipelines (reorder; body {"pipelineIds":[...]})
// environmentBody is the create/update wire shape. PipelineIDs are
// managed via the /pipelines sub-routes.
type environmentBody struct {
Name string `json:"name"`
Description string `json:"description,omitempty"`
}
func (b *environmentBody) normalize() (storage.Environment, error) {
name := strings.TrimSpace(b.Name)
if name == "" {
return storage.Environment{}, errors.New("name is required")
}
now := time.Now().UTC()
return storage.Environment{
Name: name,
Description: strings.TrimSpace(b.Description),
CreatedAt: now,
UpdatedAt: now,
}, nil
}
func (s *Server) handleListEnvironments(w http.ResponseWriter, r *http.Request) {
if s.environments == nil {
writeError(w, http.StatusServiceUnavailable, errors.New("environments store not configured"))
return
}
list, err := s.environments.List(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, err)
return
}
if list == nil {
list = []*storage.Environment{}
}
writeJSON(w, http.StatusOK, list)
}
func (s *Server) handleGetEnvironment(w http.ResponseWriter, r *http.Request) {
if s.environments == nil {
writeError(w, http.StatusServiceUnavailable, errors.New("environments store not configured"))
return
}
e, err := s.environments.GetByName(r.Context(), r.PathValue("name"))
if err != nil {
writeStorageErr(w, err, "environment not found")
return
}
writeJSON(w, http.StatusOK, e)
}
func (s *Server) handleCreateEnvironment(w http.ResponseWriter, r *http.Request) {
if s.environments == nil {
writeError(w, http.StatusServiceUnavailable, errors.New("environments store not configured"))
return
}
var body environmentBody
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, err)
return
}
env, err := body.normalize()
if err != nil {
writeError(w, http.StatusBadRequest, err)
return
}
env.ID = newID()
if err := s.environments.Create(r.Context(), &env); err != nil {
if errors.Is(err, storage.ErrAlreadyExists) {
writeError(w, http.StatusConflict, fmt.Errorf("environment %q already exists", env.Name))
return
}
writeError(w, http.StatusInternalServerError, err)
return
}
writeJSON(w, http.StatusCreated, &env)
}
func (s *Server) handleUpdateEnvironment(w http.ResponseWriter, r *http.Request) {
if s.environments == nil {
writeError(w, http.StatusServiceUnavailable, errors.New("environments store not configured"))
return
}
name := r.PathValue("name")
existing, err := s.environments.GetByName(r.Context(), name)
if err != nil {
writeStorageErr(w, err, "environment not found")
return
}
var body environmentBody
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, err)
return
}
if body.Name == "" {
body.Name = name
}
updated, err := body.normalize()
if err != nil {
writeError(w, http.StatusBadRequest, err)
return
}
// Name is immutable through PUT; carry forward identity + pipeline set.
updated.Name = existing.Name
updated.ID = existing.ID
updated.PipelineIDs = existing.PipelineIDs
updated.CreatedAt = existing.CreatedAt
updated.UpdatedAt = time.Now().UTC()
if err := s.environments.Update(r.Context(), &updated); err != nil {
writeError(w, http.StatusInternalServerError, err)
return
}
writeJSON(w, http.StatusOK, &updated)
}
func (s *Server) handleDeleteEnvironment(w http.ResponseWriter, r *http.Request) {
if s.environments == nil {
writeError(w, http.StatusServiceUnavailable, errors.New("environments store not configured"))
return
}
if err := s.environments.Delete(r.Context(), r.PathValue("name")); err != nil {
writeStorageErr(w, err, "environment not found")
return
}
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) handleEnvAddPipeline(w http.ResponseWriter, r *http.Request) {
if s.environments == nil {
writeError(w, http.StatusServiceUnavailable, errors.New("environments store not configured"))
return
}
name := r.PathValue("name")
pipelineID := r.PathValue("pipelineId")
env, err := s.environments.GetByName(r.Context(), name)
if err != nil {
writeStorageErr(w, err, "environment not found")
return
}
if _, err := s.pipelines.Get(r.Context(), pipelineID); err != nil {
writeStorageErr(w, err, "pipeline not found")
return
}
if env.HasPipeline(pipelineID) {
writeJSON(w, http.StatusOK, env) // already in this env
return
}
env.PipelineIDs = append(env.PipelineIDs, pipelineID)
env.UpdatedAt = time.Now().UTC()
if err := s.environments.Update(r.Context(), env); err != nil {
writeError(w, http.StatusInternalServerError, err)
return
}
writeJSON(w, http.StatusOK, env)
}
func (s *Server) handleEnvRemovePipeline(w http.ResponseWriter, r *http.Request) {
if s.environments == nil {
writeError(w, http.StatusServiceUnavailable, errors.New("environments store not configured"))
return
}
name := r.PathValue("name")
pipelineID := r.PathValue("pipelineId")
env, err := s.environments.GetByName(r.Context(), name)
if err != nil {
writeStorageErr(w, err, "environment not found")
return
}
out := env.PipelineIDs[:0]
for _, p := range env.PipelineIDs {
if p != pipelineID {
out = append(out, p)
}
}
env.PipelineIDs = out
env.UpdatedAt = time.Now().UTC()
if err := s.environments.Update(r.Context(), env); err != nil {
writeError(w, http.StatusInternalServerError, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// handleEnvReorderPipelines replaces the env's pipeline order. The body
// must be a permutation of the env's current pipeline set — extra or
// missing IDs are rejected so a stale client can't silently drop a
// pipeline.
func (s *Server) handleEnvReorderPipelines(w http.ResponseWriter, r *http.Request) {
if s.environments == nil {
writeError(w, http.StatusServiceUnavailable, errors.New("environments store not configured"))
return
}
name := r.PathValue("name")
env, err := s.environments.GetByName(r.Context(), name)
if err != nil {
writeStorageErr(w, err, "environment not found")
return
}
var body struct {
PipelineIDs []string `json:"pipelineIds"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, err)
return
}
cur := map[string]bool{}
for _, p := range env.PipelineIDs {
cur[p] = true
}
if len(body.PipelineIDs) != len(env.PipelineIDs) {
writeError(w, http.StatusBadRequest, fmt.Errorf("expected a permutation of %d pipeline ids, got %d", len(env.PipelineIDs), len(body.PipelineIDs)))
return
}
seen := map[string]bool{}
for _, p := range body.PipelineIDs {
if !cur[p] {
writeError(w, http.StatusBadRequest, fmt.Errorf("pipeline %q is not in this environment", p))
return
}
if seen[p] {
writeError(w, http.StatusBadRequest, fmt.Errorf("pipeline %q listed twice", p))
return
}
seen[p] = true
}
env.PipelineIDs = body.PipelineIDs
env.UpdatedAt = time.Now().UTC()
if err := s.environments.Update(r.Context(), env); err != nil {
writeError(w, http.StatusInternalServerError, err)
return
}
writeJSON(w, http.StatusOK, env)
}