mirror of
https://github.com/open-gitagent/langship.sh.git
synced 2026-08-03 07:21:04 +02:00
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.
This commit is contained in:
@@ -0,0 +1,261 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user