1f81ca9eb0
Security: - Move config export behind auth middleware - Validate OIDC callback token before storing in localStorage - Use constant-time comparison for webhook secret - Encrypt OIDC client secret at rest (like registry tokens) Performance: - Make TriggerDeploy async from HTTP handlers (return deploy ID immediately, run pipeline in background goroutine) Robustness: - Wrap api.ts res.json() in try/catch for non-JSON responses i18n: - Replace ~20 hardcoded English validation messages with $t() calls - Localize ConfirmDialog cancel button, InstanceCard confirm titles, ProjectCard instance/instances pluralization - Add validation keys to both en.json and ru.json
195 lines
5.6 KiB
Go
195 lines
5.6 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"net/http"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
|
|
"github.com/alexei/docker-watcher/internal/store"
|
|
)
|
|
|
|
// listInstances handles GET /api/projects/{id}/stages/{stage}/instances.
|
|
func (s *Server) listInstances(w http.ResponseWriter, r *http.Request) {
|
|
stageID := chi.URLParam(r, "stage")
|
|
|
|
// Verify stage exists.
|
|
if _, err := s.store.GetStageByID(stageID); err != nil {
|
|
if errors.Is(err, store.ErrNotFound) {
|
|
respondNotFound(w, "stage")
|
|
return
|
|
}
|
|
respondError(w, http.StatusInternalServerError, "failed to get stage: "+err.Error())
|
|
return
|
|
}
|
|
|
|
instances, err := s.store.GetInstancesByStageID(stageID)
|
|
if err != nil {
|
|
respondError(w, http.StatusInternalServerError, "failed to list instances: "+err.Error())
|
|
return
|
|
}
|
|
respondJSON(w, http.StatusOK, instances)
|
|
}
|
|
|
|
// deployRequest is the expected JSON body for triggering a deploy.
|
|
type deployRequest struct {
|
|
ImageTag string `json:"image_tag"`
|
|
}
|
|
|
|
// deployInstance handles POST /api/projects/{id}/stages/{stage}/instances (trigger deploy).
|
|
func (s *Server) deployInstance(w http.ResponseWriter, r *http.Request) {
|
|
projectID := chi.URLParam(r, "id")
|
|
stageID := chi.URLParam(r, "stage")
|
|
|
|
// Verify project exists.
|
|
if _, err := s.store.GetProjectByID(projectID); err != nil {
|
|
if errors.Is(err, store.ErrNotFound) {
|
|
respondNotFound(w, "project")
|
|
return
|
|
}
|
|
respondError(w, http.StatusInternalServerError, "failed to get project: "+err.Error())
|
|
return
|
|
}
|
|
|
|
// Verify stage exists.
|
|
if _, err := s.store.GetStageByID(stageID); err != nil {
|
|
if errors.Is(err, store.ErrNotFound) {
|
|
respondNotFound(w, "stage")
|
|
return
|
|
}
|
|
respondError(w, http.StatusInternalServerError, "failed to get stage: "+err.Error())
|
|
return
|
|
}
|
|
|
|
var req deployRequest
|
|
if !decodeJSON(w, r, &req) {
|
|
return
|
|
}
|
|
|
|
if req.ImageTag == "" {
|
|
respondError(w, http.StatusBadRequest, "image_tag is required")
|
|
return
|
|
}
|
|
|
|
deployID, err := s.deployer.AsyncTriggerDeploy(r.Context(), projectID, stageID, req.ImageTag)
|
|
if err != nil {
|
|
respondError(w, http.StatusInternalServerError, "failed to trigger deploy: "+err.Error())
|
|
return
|
|
}
|
|
respondJSON(w, http.StatusAccepted, map[string]string{
|
|
"status": "deploying",
|
|
"deploy_id": deployID,
|
|
"project_id": projectID,
|
|
"stage_id": stageID,
|
|
"image_tag": req.ImageTag,
|
|
})
|
|
}
|
|
|
|
// removeInstance handles DELETE /api/projects/{id}/stages/{stage}/instances/{iid}.
|
|
func (s *Server) removeInstance(w http.ResponseWriter, r *http.Request) {
|
|
instanceID := chi.URLParam(r, "iid")
|
|
|
|
inst, err := s.store.GetInstanceByID(instanceID)
|
|
if err != nil {
|
|
if errors.Is(err, store.ErrNotFound) {
|
|
respondNotFound(w, "instance")
|
|
return
|
|
}
|
|
respondError(w, http.StatusInternalServerError, "failed to get instance: "+err.Error())
|
|
return
|
|
}
|
|
|
|
// Remove the Docker container if it has one.
|
|
if inst.ContainerID != "" {
|
|
if err := s.docker.RemoveContainer(r.Context(), inst.ContainerID, true); err != nil {
|
|
slog.Error("remove container", "container_id", inst.ContainerID, "error", err)
|
|
}
|
|
}
|
|
|
|
// Delete instance record.
|
|
if err := s.store.DeleteInstance(instanceID); err != nil {
|
|
respondError(w, http.StatusInternalServerError, "failed to delete instance: "+err.Error())
|
|
return
|
|
}
|
|
respondJSON(w, http.StatusOK, map[string]string{"deleted": instanceID})
|
|
}
|
|
|
|
// stopInstance handles POST /api/projects/{id}/stages/{stage}/instances/{iid}/stop.
|
|
func (s *Server) stopInstance(w http.ResponseWriter, r *http.Request) {
|
|
s.controlInstance(w, r, "stop")
|
|
}
|
|
|
|
// startInstance handles POST /api/projects/{id}/stages/{stage}/instances/{iid}/start.
|
|
func (s *Server) startInstance(w http.ResponseWriter, r *http.Request) {
|
|
s.controlInstance(w, r, "start")
|
|
}
|
|
|
|
// restartInstance handles POST /api/projects/{id}/stages/{stage}/instances/{iid}/restart.
|
|
func (s *Server) restartInstance(w http.ResponseWriter, r *http.Request) {
|
|
s.controlInstance(w, r, "restart")
|
|
}
|
|
|
|
// controlInstance performs a stop/start/restart action on an instance's container.
|
|
func (s *Server) controlInstance(w http.ResponseWriter, r *http.Request, action string) {
|
|
instanceID := chi.URLParam(r, "iid")
|
|
|
|
inst, err := s.store.GetInstanceByID(instanceID)
|
|
if err != nil {
|
|
if errors.Is(err, store.ErrNotFound) {
|
|
respondNotFound(w, "instance")
|
|
return
|
|
}
|
|
respondError(w, http.StatusInternalServerError, "failed to get instance: "+err.Error())
|
|
return
|
|
}
|
|
|
|
if inst.ContainerID == "" {
|
|
respondError(w, http.StatusBadRequest, "instance has no container")
|
|
return
|
|
}
|
|
|
|
ctx := r.Context()
|
|
var controlErr error
|
|
var newStatus string
|
|
|
|
switch action {
|
|
case "stop":
|
|
controlErr = s.docker.StopContainer(ctx, inst.ContainerID, 10)
|
|
newStatus = "stopped"
|
|
case "start":
|
|
controlErr = s.docker.StartContainer(ctx, inst.ContainerID)
|
|
newStatus = "running"
|
|
case "restart":
|
|
controlErr = s.docker.RestartContainer(ctx, inst.ContainerID, 10)
|
|
newStatus = "running"
|
|
default:
|
|
respondError(w, http.StatusBadRequest, fmt.Sprintf("unknown action: %s", action))
|
|
return
|
|
}
|
|
|
|
if controlErr != nil {
|
|
respondError(w, http.StatusInternalServerError, fmt.Sprintf("failed to %s instance: %v", action, controlErr))
|
|
return
|
|
}
|
|
|
|
// Update status in store.
|
|
if err := s.store.UpdateInstanceStatus(instanceID, newStatus); err != nil {
|
|
slog.Error("update instance status", "instance_id", instanceID, "status", newStatus, "error", err)
|
|
}
|
|
|
|
respondJSON(w, http.StatusOK, map[string]string{
|
|
"instance_id": instanceID,
|
|
"action": action,
|
|
"status": newStatus,
|
|
})
|
|
}
|
|
|
|
// DeployTriggerer is the interface for triggering deployments.
|
|
type DeployTriggerer interface {
|
|
TriggerDeploy(ctx context.Context, projectID, stageID, imageTag string) error
|
|
AsyncTriggerDeploy(ctx context.Context, projectID, stageID, imageTag string) (string, error)
|
|
}
|