355 lines
10 KiB
Go
355 lines
10 KiB
Go
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) {
|
|
return resolveRepoID(h.db, w, r)
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
func (h *EnvironmentHandler) publishDeployEvent(subject string, env *models.Environment, d *models.Deployment) {
|
|
h.bus.Publish(subject, events.DeploymentEvent{ //nolint:errcheck
|
|
DeploymentID: d.ID,
|
|
EnvID: env.ID,
|
|
EnvName: env.Name,
|
|
RepoID: d.RepoID,
|
|
SHA: d.SHA,
|
|
Ref: d.Ref,
|
|
Status: string(d.Status),
|
|
TriggeredBy: d.TriggeredBy,
|
|
})
|
|
}
|