feat: environment model + deployment tracking (phase 3a)
- Environment/Deployment XORM models + migration 010 - Full CRUD API: GET/POST/PATCH/DELETE /environments + /deployments - Deployment status update endpoint, publishes deployment.* NATS events - EnvironmentsPage with deploy cards, history accordion, deploy modal - Sidebar Environments nav item between Pipelines and Settings - Repo page deployment status badges (env name + SHA pill per environment) - Environment/Deployment types in types/api.ts + environments.ts query hooks
This commit is contained in:
@@ -0,0 +1,377 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"xorm.io/xorm"
|
||||
|
||||
"github.com/forgeo/forgebucket/internal/api/middleware"
|
||||
"github.com/forgeo/forgebucket/internal/events"
|
||||
"github.com/forgeo/forgebucket/internal/models"
|
||||
)
|
||||
|
||||
type EnvironmentHandler struct {
|
||||
db *xorm.Engine
|
||||
bus events.EventBus
|
||||
}
|
||||
|
||||
func NewEnvironmentHandler(db *xorm.Engine, bus events.EventBus) *EnvironmentHandler {
|
||||
return &EnvironmentHandler{db: db, bus: bus}
|
||||
}
|
||||
|
||||
// ── Environment CRUD ──────────────────────────────────────────────────────────
|
||||
|
||||
// ListEnvironments returns all environments for a repository, each annotated
|
||||
// with its most recent deployment (or nil if none).
|
||||
func (h *EnvironmentHandler) ListEnvironments(w http.ResponseWriter, r *http.Request) {
|
||||
repoID, ok := h.resolveRepo(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var envs []models.Environment
|
||||
if err := h.db.Where("repo_id = ?", repoID).Asc("name").Find(&envs); err != nil {
|
||||
jsonError(w, "could not list environments", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if envs == nil {
|
||||
envs = []models.Environment{}
|
||||
}
|
||||
|
||||
type envResponse struct {
|
||||
models.Environment
|
||||
LatestDeployment *models.Deployment `json:"latestDeployment"`
|
||||
}
|
||||
|
||||
result := make([]envResponse, len(envs))
|
||||
for i, env := range envs {
|
||||
var latest models.Deployment
|
||||
found, _ := h.db.Where("env_id = ?", env.ID).Desc("id").Limit(1).Get(&latest)
|
||||
er := envResponse{Environment: env}
|
||||
if found {
|
||||
er.LatestDeployment = &latest
|
||||
}
|
||||
result[i] = er
|
||||
}
|
||||
|
||||
jsonOK(w, result)
|
||||
}
|
||||
|
||||
// CreateEnvironment creates a new named environment for a repository.
|
||||
func (h *EnvironmentHandler) CreateEnvironment(w http.ResponseWriter, r *http.Request) {
|
||||
repoID, ok := h.resolveRepo(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var body struct {
|
||||
Name string `json:"name"`
|
||||
URL string `json:"url"`
|
||||
ProtectionRules string `json:"protectionRules"` // raw JSON string
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
jsonError(w, "invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if body.Name == "" {
|
||||
jsonError(w, "name is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Reject duplicate name within repo.
|
||||
var existing models.Environment
|
||||
if found, _ := h.db.Where("repo_id = ? AND name = ?", repoID, body.Name).Get(&existing); found {
|
||||
jsonError(w, "environment with this name already exists", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
|
||||
env := &models.Environment{
|
||||
RepoID: repoID,
|
||||
Name: body.Name,
|
||||
URL: body.URL,
|
||||
ProtectionRules: body.ProtectionRules,
|
||||
}
|
||||
if _, err := h.db.Insert(env); err != nil {
|
||||
jsonError(w, "could not create environment", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
json.NewEncoder(w).Encode(env) //nolint:errcheck
|
||||
}
|
||||
|
||||
// GetEnvironment returns a single environment with its latest deployment.
|
||||
func (h *EnvironmentHandler) GetEnvironment(w http.ResponseWriter, r *http.Request) {
|
||||
env, ok := h.resolveEnv(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var latest models.Deployment
|
||||
found, _ := h.db.Where("env_id = ?", env.ID).Desc("id").Limit(1).Get(&latest)
|
||||
|
||||
type envResponse struct {
|
||||
models.Environment
|
||||
LatestDeployment *models.Deployment `json:"latestDeployment"`
|
||||
}
|
||||
resp := envResponse{Environment: *env}
|
||||
if found {
|
||||
resp.LatestDeployment = &latest
|
||||
}
|
||||
jsonOK(w, resp)
|
||||
}
|
||||
|
||||
// UpdateEnvironment patches the URL and/or protection rules of an environment.
|
||||
func (h *EnvironmentHandler) UpdateEnvironment(w http.ResponseWriter, r *http.Request) {
|
||||
env, ok := h.resolveEnv(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var body struct {
|
||||
URL *string `json:"url"`
|
||||
ProtectionRules *string `json:"protectionRules"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
jsonError(w, "invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
cols := []string{}
|
||||
if body.URL != nil {
|
||||
env.URL = *body.URL
|
||||
cols = append(cols, "url")
|
||||
}
|
||||
if body.ProtectionRules != nil {
|
||||
env.ProtectionRules = *body.ProtectionRules
|
||||
cols = append(cols, "protection_rules")
|
||||
}
|
||||
if len(cols) > 0 {
|
||||
if _, err := h.db.ID(env.ID).Cols(cols...).Update(env); err != nil {
|
||||
jsonError(w, "could not update environment", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
jsonOK(w, env)
|
||||
}
|
||||
|
||||
// DeleteEnvironment removes an environment and all its deployment records.
|
||||
func (h *EnvironmentHandler) DeleteEnvironment(w http.ResponseWriter, r *http.Request) {
|
||||
env, ok := h.resolveEnv(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
// Cascade-delete deployments first (XORM has no cascade by default).
|
||||
h.db.Where("env_id = ?", env.ID).Delete(&models.Deployment{}) //nolint:errcheck
|
||||
if _, err := h.db.ID(env.ID).Delete(&models.Environment{}); err != nil {
|
||||
jsonError(w, "could not delete environment", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// ── Deployment endpoints ──────────────────────────────────────────────────────
|
||||
|
||||
// ListDeployments returns all deployments for an environment, newest first.
|
||||
func (h *EnvironmentHandler) ListDeployments(w http.ResponseWriter, r *http.Request) {
|
||||
env, ok := h.resolveEnv(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
limit := 30
|
||||
if l, err := strconv.Atoi(r.URL.Query().Get("limit")); err == nil && l > 0 && l <= 100 {
|
||||
limit = l
|
||||
}
|
||||
|
||||
var deploys []models.Deployment
|
||||
if err := h.db.Where("env_id = ?", env.ID).Desc("id").Limit(limit).Find(&deploys); err != nil {
|
||||
jsonError(w, "could not list deployments", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if deploys == nil {
|
||||
deploys = []models.Deployment{}
|
||||
}
|
||||
jsonOK(w, deploys)
|
||||
}
|
||||
|
||||
// CreateDeployment records a new deployment event (pending → triggers the deploy workflow).
|
||||
func (h *EnvironmentHandler) CreateDeployment(w http.ResponseWriter, r *http.Request) {
|
||||
env, ok := h.resolveEnv(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
userID, _ := middleware.UserIDFromContext(r.Context())
|
||||
var actor models.User
|
||||
h.db.ID(userID).Cols("username").Get(&actor)
|
||||
|
||||
var body struct {
|
||||
SHA string `json:"sha"`
|
||||
Ref string `json:"ref"`
|
||||
Description string `json:"description"`
|
||||
RunID *int64 `json:"runId"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
jsonError(w, "invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if body.SHA == "" {
|
||||
jsonError(w, "sha is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
deploy := &models.Deployment{
|
||||
EnvID: env.ID,
|
||||
RepoID: env.RepoID,
|
||||
SHA: body.SHA,
|
||||
Ref: body.Ref,
|
||||
Status: models.DeployStatusPending,
|
||||
TriggeredBy: actor.Username,
|
||||
Description: body.Description,
|
||||
RunID: body.RunID,
|
||||
StartedAt: &now,
|
||||
}
|
||||
if _, err := h.db.Insert(deploy); err != nil {
|
||||
jsonError(w, "could not create deployment", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
h.publishDeployEvent("deployment.started", env, deploy)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
json.NewEncoder(w).Encode(deploy) //nolint:errcheck
|
||||
}
|
||||
|
||||
// UpdateDeploymentStatus allows external systems (CI runners, webhooks) to
|
||||
// advance a deployment through its lifecycle states.
|
||||
func (h *EnvironmentHandler) UpdateDeploymentStatus(w http.ResponseWriter, r *http.Request) {
|
||||
env, ok := h.resolveEnv(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
deployID, err := strconv.ParseInt(chi.URLParam(r, "deployID"), 10, 64)
|
||||
if err != nil {
|
||||
jsonError(w, "invalid deployment ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var deploy models.Deployment
|
||||
if found, _ := h.db.Where("id = ? AND env_id = ?", deployID, env.ID).Get(&deploy); !found {
|
||||
jsonError(w, "deployment not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
var body struct {
|
||||
Status string `json:"status"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
jsonError(w, "invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
allowed := map[string]bool{
|
||||
"in_progress": true,
|
||||
"success": true,
|
||||
"failure": true,
|
||||
"cancelled": true,
|
||||
}
|
||||
if !allowed[body.Status] {
|
||||
jsonError(w, "invalid status; must be in_progress, success, failure, or cancelled", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
deploy.Status = models.DeployStatus(body.Status)
|
||||
if body.Description != "" {
|
||||
deploy.Description = body.Description
|
||||
}
|
||||
|
||||
cols := []string{"status", "description"}
|
||||
if deploy.Status == models.DeployStatusSuccess ||
|
||||
deploy.Status == models.DeployStatusFailure ||
|
||||
deploy.Status == models.DeployStatusCancelled {
|
||||
now := time.Now().UTC()
|
||||
deploy.FinishedAt = &now
|
||||
cols = append(cols, "finished_at")
|
||||
}
|
||||
|
||||
if _, err := h.db.ID(deploy.ID).Cols(cols...).Update(&deploy); err != nil {
|
||||
jsonError(w, "could not update deployment status", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
subject := map[string]string{
|
||||
"success": "deployment.succeeded",
|
||||
"failure": "deployment.failed",
|
||||
"cancelled": "deployment.failed",
|
||||
}[body.Status]
|
||||
if subject != "" {
|
||||
h.publishDeployEvent(subject, env, &deploy)
|
||||
}
|
||||
|
||||
jsonOK(w, deploy)
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
func (h *EnvironmentHandler) resolveRepo(w http.ResponseWriter, r *http.Request) (int64, bool) {
|
||||
owner := chi.URLParam(r, "owner")
|
||||
repoName := chi.URLParam(r, "repo")
|
||||
var u models.User
|
||||
if found, _ := h.db.Where("username = ?", owner).Get(&u); !found {
|
||||
jsonError(w, "repository not found", http.StatusNotFound)
|
||||
return 0, false
|
||||
}
|
||||
var repo models.Repository
|
||||
if found, _ := h.db.Where("owner_id = ? AND name = ?", u.ID, repoName).Get(&repo); !found {
|
||||
jsonError(w, "repository not found", http.StatusNotFound)
|
||||
return 0, false
|
||||
}
|
||||
return repo.ID, true
|
||||
}
|
||||
|
||||
func (h *EnvironmentHandler) resolveEnv(w http.ResponseWriter, r *http.Request) (*models.Environment, bool) {
|
||||
repoID, ok := h.resolveRepo(w, r)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
envName := chi.URLParam(r, "envName")
|
||||
var env models.Environment
|
||||
if found, _ := h.db.Where("repo_id = ? AND name = ?", repoID, envName).Get(&env); !found {
|
||||
jsonError(w, "environment not found", http.StatusNotFound)
|
||||
return nil, false
|
||||
}
|
||||
return &env, true
|
||||
}
|
||||
|
||||
type deployEventPayload struct {
|
||||
DeploymentID int64 `json:"deploymentId"`
|
||||
EnvID int64 `json:"envId"`
|
||||
EnvName string `json:"envName"`
|
||||
RepoID int64 `json:"repoId"`
|
||||
SHA string `json:"sha"`
|
||||
Ref string `json:"ref"`
|
||||
Status models.DeployStatus `json:"status"`
|
||||
TriggeredBy string `json:"triggeredBy"`
|
||||
}
|
||||
|
||||
func (h *EnvironmentHandler) publishDeployEvent(subject string, env *models.Environment, d *models.Deployment) {
|
||||
h.bus.Publish(subject, deployEventPayload{ //nolint:errcheck
|
||||
DeploymentID: d.ID,
|
||||
EnvID: env.ID,
|
||||
EnvName: env.Name,
|
||||
RepoID: d.RepoID,
|
||||
SHA: d.SHA,
|
||||
Ref: d.Ref,
|
||||
Status: d.Status,
|
||||
TriggeredBy: d.TriggeredBy,
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user