feat(docker-watcher): phase 8 - REST API layer

All REST endpoints wired with chi router: projects, stages, instances,
deploys, registries, settings, quick deploy, webhook. Full main.go
wiring with graceful shutdown. Consistent JSON envelope responses.
Sensitive fields stripped from API responses.
This commit is contained in:
2026-03-27 22:06:57 +03:00
parent bbcc4f55f0
commit 97d4243cfe
9 changed files with 1211 additions and 25 deletions
+202
View File
@@ -0,0 +1,202 @@
package api
import (
"errors"
"log"
"net/http"
"strconv"
"strings"
"github.com/go-chi/chi/v5"
"github.com/alexei/docker-watcher/internal/store"
)
// listDeploys handles GET /api/deploys.
func (s *Server) listDeploys(w http.ResponseWriter, r *http.Request) {
limitStr := r.URL.Query().Get("limit")
limit := 50
if limitStr != "" {
if parsed, err := strconv.Atoi(limitStr); err == nil && parsed > 0 {
limit = parsed
}
}
deploys, err := s.store.GetRecentDeploys(limit)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to list deploys: "+err.Error())
return
}
respondJSON(w, http.StatusOK, deploys)
}
// getDeployLogs handles GET /api/deploys/{id}/logs.
// This is an SSE stub that returns logs as JSON for now.
// Real SSE streaming will be implemented in Phase 11.
func (s *Server) getDeployLogs(w http.ResponseWriter, r *http.Request) {
deployID := chi.URLParam(r, "id")
// Verify deploy exists.
if _, err := s.store.GetDeployByID(deployID); err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "deploy")
return
}
respondError(w, http.StatusInternalServerError, "failed to get deploy: "+err.Error())
return
}
logs, err := s.store.GetDeployLogs(deployID)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to get deploy logs: "+err.Error())
return
}
respondJSON(w, http.StatusOK, logs)
}
// inspectRequest is the expected JSON body for POST /api/deploy/inspect.
type inspectRequest struct {
Image string `json:"image"`
}
// inspectResponse is the response body for POST /api/deploy/inspect.
type inspectResponse struct {
Image string `json:"image"`
Port int `json:"port"`
Healthcheck string `json:"healthcheck"`
}
// inspectImage handles POST /api/deploy/inspect.
// Pulls the image and inspects it for EXPOSE ports and healthcheck config.
func (s *Server) inspectImage(w http.ResponseWriter, r *http.Request) {
var req inspectRequest
if !decodeJSON(w, r, &req) {
return
}
if req.Image == "" {
respondError(w, http.StatusBadRequest, "image is required")
return
}
ctx := r.Context()
// Pull the image first so it's available locally for inspection.
// Split image:tag for the pull call.
imageRef, tag := splitImageTag(req.Image)
if err := s.docker.PullImage(ctx, imageRef, tag, ""); err != nil {
log.Printf("[api] pull image %s for inspect: %v", req.Image, err)
// Try to inspect anyway in case the image is already local.
}
info, err := s.docker.InspectImage(ctx, req.Image)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to inspect image: "+err.Error())
return
}
port := extractPort(info.ExposedPorts)
respondJSON(w, http.StatusOK, inspectResponse{
Image: req.Image,
Port: port,
Healthcheck: info.Healthcheck,
})
}
// quickDeployRequest is the expected JSON body for POST /api/deploy/quick.
type quickDeployRequest struct {
Name string `json:"name"`
Image string `json:"image"`
Tag string `json:"tag"`
Registry string `json:"registry"`
Port int `json:"port"`
}
// quickDeploy handles POST /api/deploy/quick.
// Creates a project, a default stage, and triggers a deploy in one call.
func (s *Server) quickDeploy(w http.ResponseWriter, r *http.Request) {
var req quickDeployRequest
if !decodeJSON(w, r, &req) {
return
}
if req.Image == "" {
respondError(w, http.StatusBadRequest, "image is required")
return
}
if req.Tag == "" {
req.Tag = "latest"
}
if req.Name == "" {
// Derive name from image.
parts := strings.Split(req.Image, "/")
req.Name = parts[len(parts)-1]
}
// Create project.
project, err := s.store.CreateProject(store.Project{
Name: req.Name,
Image: req.Image,
Registry: req.Registry,
Port: req.Port,
Env: "{}",
Volumes: "{}",
})
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to create project: "+err.Error())
return
}
// Create default stage.
stage, err := s.store.CreateStage(store.Stage{
ProjectID: project.ID,
Name: "dev",
TagPattern: "*",
AutoDeploy: true,
MaxInstances: 1,
})
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to create stage: "+err.Error())
return
}
// Trigger deploy.
if err := s.deployer.TriggerDeploy(r.Context(), project.ID, stage.ID, req.Tag); err != nil {
respondError(w, http.StatusInternalServerError, "failed to trigger deploy: "+err.Error())
return
}
respondJSON(w, http.StatusAccepted, map[string]any{
"project": project,
"stage": stage,
"tag": req.Tag,
"status": "deploying",
})
}
// splitImageTag splits "image:tag" into image and tag parts.
// Returns the full string and empty tag if no colon separator is found.
func splitImageTag(ref string) (string, string) {
if idx := strings.LastIndex(ref, ":"); idx != -1 {
afterColon := ref[idx+1:]
if !strings.Contains(afterColon, "/") {
return ref[:idx], afterColon
}
}
return ref, ""
}
// extractPort parses the first exposed port from Docker EXPOSE entries.
// Entries are in the form "8080/tcp" or "8080". Returns 0 if none found.
func extractPort(exposedPorts []string) int {
if len(exposedPorts) == 0 {
return 0
}
raw := exposedPorts[0]
if idx := strings.Index(raw, "/"); idx != -1 {
raw = raw[:idx]
}
port, _ := strconv.Atoi(raw)
return port
}
+191
View File
@@ -0,0 +1,191 @@
package api
import (
"context"
"errors"
"fmt"
"log"
"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
}
if err := s.deployer.TriggerDeploy(r.Context(), projectID, stageID, req.ImageTag); err != nil {
respondError(w, http.StatusInternalServerError, "failed to trigger deploy: "+err.Error())
return
}
respondJSON(w, http.StatusAccepted, map[string]string{
"status": "deploying",
"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 {
log.Printf("[api] remove container %s: %v", inst.ContainerID, 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 {
log.Printf("[api] update instance %s status to %s: %v", instanceID, newStatus, 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
}
+261
View File
@@ -0,0 +1,261 @@
package api
import (
"errors"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/alexei/docker-watcher/internal/crypto"
"github.com/alexei/docker-watcher/internal/registry"
"github.com/alexei/docker-watcher/internal/store"
)
// registryRequest is the expected JSON body for creating/updating a registry.
type registryRequest struct {
Name string `json:"name"`
URL string `json:"url"`
Type string `json:"type"`
Token string `json:"token"`
}
// listRegistries handles GET /api/registries.
func (s *Server) listRegistries(w http.ResponseWriter, r *http.Request) {
registries, err := s.store.GetAllRegistries()
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to list registries: "+err.Error())
return
}
// Strip tokens from response for security.
type safeRegistry struct {
ID string `json:"id"`
Name string `json:"name"`
URL string `json:"url"`
Type string `json:"type"`
HasToken bool `json:"has_token"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
safe := make([]safeRegistry, len(registries))
for i, reg := range registries {
safe[i] = safeRegistry{
ID: reg.ID,
Name: reg.Name,
URL: reg.URL,
Type: reg.Type,
HasToken: reg.Token != "",
CreatedAt: reg.CreatedAt,
UpdatedAt: reg.UpdatedAt,
}
}
respondJSON(w, http.StatusOK, safe)
}
// createRegistry handles POST /api/registries.
func (s *Server) createRegistry(w http.ResponseWriter, r *http.Request) {
var req registryRequest
if !decodeJSON(w, r, &req) {
return
}
if req.Name == "" {
respondError(w, http.StatusBadRequest, "name is required")
return
}
if req.URL == "" {
respondError(w, http.StatusBadRequest, "url is required")
return
}
if req.Type == "" {
req.Type = "generic"
}
// Encrypt the token if provided.
encToken, err := crypto.EncryptIfNotEmpty(s.encKey, req.Token)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to encrypt token: "+err.Error())
return
}
reg, err := s.store.CreateRegistry(store.Registry{
Name: req.Name,
URL: req.URL,
Type: req.Type,
Token: encToken,
})
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to create registry: "+err.Error())
return
}
respondJSON(w, http.StatusCreated, map[string]string{
"id": reg.ID,
"name": reg.Name,
})
}
// updateRegistry handles PUT /api/registries/{id}.
func (s *Server) updateRegistry(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
existing, err := s.store.GetRegistryByID(id)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "registry")
return
}
respondError(w, http.StatusInternalServerError, "failed to get registry: "+err.Error())
return
}
var req registryRequest
if !decodeJSON(w, r, &req) {
return
}
updated := existing
if req.Name != "" {
updated.Name = req.Name
}
if req.URL != "" {
updated.URL = req.URL
}
if req.Type != "" {
updated.Type = req.Type
}
// Only re-encrypt if a new token is provided.
if req.Token != "" {
encToken, err := crypto.EncryptIfNotEmpty(s.encKey, req.Token)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to encrypt token: "+err.Error())
return
}
updated.Token = encToken
}
if err := s.store.UpdateRegistry(updated); err != nil {
respondError(w, http.StatusInternalServerError, "failed to update registry: "+err.Error())
return
}
respondJSON(w, http.StatusOK, map[string]string{
"id": updated.ID,
"name": updated.Name,
})
}
// deleteRegistry handles DELETE /api/registries/{id}.
func (s *Server) deleteRegistry(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if err := s.store.DeleteRegistry(id); err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "registry")
return
}
respondError(w, http.StatusInternalServerError, "failed to delete registry: "+err.Error())
return
}
respondJSON(w, http.StatusOK, map[string]string{"deleted": id})
}
// testRegistryRequest is the expected JSON body for POST /api/registries/{id}/test.
type testRegistryRequest struct {
Image string `json:"image"`
}
// testRegistry handles POST /api/registries/{id}/test.
// Creates a temp registry client and attempts to list tags.
func (s *Server) testRegistry(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
reg, err := s.store.GetRegistryByID(id)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "registry")
return
}
respondError(w, http.StatusInternalServerError, "failed to get registry: "+err.Error())
return
}
var req testRegistryRequest
if !decodeJSON(w, r, &req) {
return
}
if req.Image == "" {
respondError(w, http.StatusBadRequest, "image is required for testing")
return
}
// Decrypt the token.
token := reg.Token
if token != "" {
decrypted, err := crypto.Decrypt(s.encKey, token)
if err != nil {
token = reg.Token // Fall back to raw token.
} else {
token = decrypted
}
}
client, err := registry.NewClient(reg.Type, reg.URL, token)
if err != nil {
respondError(w, http.StatusBadRequest, "unsupported registry type: "+reg.Type)
return
}
tags, err := client.ListTags(r.Context(), req.Image)
if err != nil {
respondError(w, http.StatusBadGateway, "registry test failed: "+err.Error())
return
}
respondJSON(w, http.StatusOK, map[string]any{
"success": true,
"tags": len(tags),
})
}
// listRegistryTags handles GET /api/registries/{id}/tags/{image}.
func (s *Server) listRegistryTags(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
image := chi.URLParam(r, "*")
reg, err := s.store.GetRegistryByID(id)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "registry")
return
}
respondError(w, http.StatusInternalServerError, "failed to get registry: "+err.Error())
return
}
// Decrypt the token.
token := reg.Token
if token != "" {
decrypted, err := crypto.Decrypt(s.encKey, token)
if err != nil {
token = reg.Token
} else {
token = decrypted
}
}
client, err := registry.NewClient(reg.Type, reg.URL, token)
if err != nil {
respondError(w, http.StatusBadRequest, "unsupported registry type: "+reg.Type)
return
}
tags, err := client.ListTags(r.Context(), image)
if err != nil {
respondError(w, http.StatusBadGateway, "failed to list tags: "+err.Error())
return
}
respondJSON(w, http.StatusOK, tags)
}
+101
View File
@@ -0,0 +1,101 @@
package api
import (
"github.com/go-chi/chi/v5"
"github.com/alexei/docker-watcher/internal/docker"
"github.com/alexei/docker-watcher/internal/store"
"github.com/alexei/docker-watcher/internal/webhook"
)
// Server holds all dependencies for the API layer.
type Server struct {
store *store.Store
docker *docker.Client
deployer DeployTriggerer
webhook *webhook.Handler
encKey [32]byte
}
// NewServer creates a new API Server with all required dependencies.
func NewServer(
st *store.Store,
dockerClient *docker.Client,
deployer DeployTriggerer,
webhookHandler *webhook.Handler,
encKey [32]byte,
) *Server {
return &Server{
store: st,
docker: dockerClient,
deployer: deployer,
webhook: webhookHandler,
encKey: encKey,
}
}
// Router returns a chi router with all API routes mounted.
func (s *Server) Router() chi.Router {
r := chi.NewRouter()
// Global middleware.
r.Use(recovery)
r.Use(logging)
r.Use(cors)
r.Use(jsonContentType)
r.Route("/api", func(r chi.Router) {
// Project endpoints.
r.Get("/projects", s.listProjects)
r.Post("/projects", s.createProject)
r.Route("/projects/{id}", func(r chi.Router) {
r.Get("/", s.getProject)
r.Put("/", s.updateProject)
r.Delete("/", s.deleteProject)
// Stage endpoints.
r.Post("/stages", s.createStage)
r.Put("/stages/{stage}", s.updateStage)
r.Delete("/stages/{stage}", s.deleteStage)
// Instance endpoints.
r.Get("/stages/{stage}/instances", s.listInstances)
r.Post("/stages/{stage}/instances", s.deployInstance)
r.Delete("/stages/{stage}/instances/{iid}", s.removeInstance)
// Instance control endpoints.
r.Post("/stages/{stage}/instances/{iid}/stop", s.stopInstance)
r.Post("/stages/{stage}/instances/{iid}/start", s.startInstance)
r.Post("/stages/{stage}/instances/{iid}/restart", s.restartInstance)
})
// Deploy endpoints.
r.Get("/deploys", s.listDeploys)
r.Get("/deploys/{id}/logs", s.getDeployLogs)
// Quick deploy endpoints.
r.Post("/deploy/inspect", s.inspectImage)
r.Post("/deploy/quick", s.quickDeploy)
// Registry endpoints.
r.Get("/registries", s.listRegistries)
r.Post("/registries", s.createRegistry)
r.Route("/registries/{id}", func(r chi.Router) {
r.Put("/", s.updateRegistry)
r.Delete("/", s.deleteRegistry)
r.Post("/test", s.testRegistry)
r.Get("/tags/*", s.listRegistryTags)
})
// Settings endpoints.
r.Get("/settings", s.getSettings)
r.Put("/settings", s.updateSettings)
r.Get("/settings/webhook-url", s.getWebhookURL)
r.Post("/settings/regenerate", s.regenerateWebhookSecret)
// Webhook handler (from webhook package).
r.Mount("/webhook", s.webhook.Route())
})
return r
}
+143
View File
@@ -0,0 +1,143 @@
package api
import (
"fmt"
"net/http"
"github.com/alexei/docker-watcher/internal/crypto"
"github.com/alexei/docker-watcher/internal/webhook"
)
// settingsRequest is the expected JSON body for updating settings.
type settingsRequest struct {
Domain string `json:"domain"`
ServerIP string `json:"server_ip"`
Network string `json:"network"`
SubdomainPattern string `json:"subdomain_pattern"`
NotificationURL string `json:"notification_url"`
NpmURL string `json:"npm_url"`
NpmEmail string `json:"npm_email"`
NpmPassword string `json:"npm_password"`
PollingInterval string `json:"polling_interval"`
}
// getSettings handles GET /api/settings.
func (s *Server) getSettings(w http.ResponseWriter, r *http.Request) {
settings, err := s.store.GetSettings()
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to get settings: "+err.Error())
return
}
// Return settings without sensitive fields.
respondJSON(w, http.StatusOK, map[string]any{
"domain": settings.Domain,
"server_ip": settings.ServerIP,
"network": settings.Network,
"subdomain_pattern": settings.SubdomainPattern,
"notification_url": settings.NotificationURL,
"npm_url": settings.NpmURL,
"npm_email": settings.NpmEmail,
"has_npm_password": settings.NpmPassword != "",
"polling_interval": settings.PollingInterval,
"updated_at": settings.UpdatedAt,
})
}
// updateSettings handles PUT /api/settings.
func (s *Server) updateSettings(w http.ResponseWriter, r *http.Request) {
var req settingsRequest
if !decodeJSON(w, r, &req) {
return
}
existing, err := s.store.GetSettings()
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to get settings: "+err.Error())
return
}
updated := existing
if req.Domain != "" {
updated.Domain = req.Domain
}
if req.ServerIP != "" {
updated.ServerIP = req.ServerIP
}
if req.Network != "" {
updated.Network = req.Network
}
if req.SubdomainPattern != "" {
updated.SubdomainPattern = req.SubdomainPattern
}
// Allow clearing notification URL.
updated.NotificationURL = req.NotificationURL
if req.NpmURL != "" {
updated.NpmURL = req.NpmURL
}
if req.NpmEmail != "" {
updated.NpmEmail = req.NpmEmail
}
if req.NpmPassword != "" {
encPassword, err := crypto.Encrypt(s.encKey, req.NpmPassword)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to encrypt npm password: "+err.Error())
return
}
updated.NpmPassword = encPassword
}
if req.PollingInterval != "" {
updated.PollingInterval = req.PollingInterval
}
if err := s.store.UpdateSettings(updated); err != nil {
respondError(w, http.StatusInternalServerError, "failed to update settings: "+err.Error())
return
}
respondJSON(w, http.StatusOK, map[string]string{"status": "updated"})
}
// getWebhookURL handles GET /api/settings/webhook-url.
func (s *Server) getWebhookURL(w http.ResponseWriter, r *http.Request) {
settings, err := s.store.GetSettings()
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to get settings: "+err.Error())
return
}
webhookURL := ""
if settings.WebhookSecret != "" && settings.Domain != "" {
webhookURL = fmt.Sprintf("https://%s/api/webhook/%s", settings.Domain, settings.WebhookSecret)
}
respondJSON(w, http.StatusOK, map[string]string{
"webhook_url": webhookURL,
"webhook_secret": settings.WebhookSecret,
})
}
// regenerateWebhookSecret handles POST /api/settings/regenerate.
func (s *Server) regenerateWebhookSecret(w http.ResponseWriter, r *http.Request) {
secret, err := webhook.RegenerateWebhookSecret(s.store)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to regenerate webhook secret: "+err.Error())
return
}
settings, err := s.store.GetSettings()
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to get settings: "+err.Error())
return
}
webhookURL := ""
if settings.Domain != "" {
webhookURL = fmt.Sprintf("https://%s/api/webhook/%s", settings.Domain, secret)
}
respondJSON(w, http.StatusOK, map[string]string{
"webhook_url": webhookURL,
"webhook_secret": secret,
})
}
+137
View File
@@ -0,0 +1,137 @@
package api
import (
"errors"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/alexei/docker-watcher/internal/store"
)
// stageRequest is the expected JSON body for creating/updating a stage.
type stageRequest struct {
Name string `json:"name"`
TagPattern string `json:"tag_pattern"`
AutoDeploy *bool `json:"auto_deploy"`
MaxInstances *int `json:"max_instances"`
Confirm *bool `json:"confirm"`
PromoteFrom string `json:"promote_from"`
Subdomain string `json:"subdomain"`
}
// createStage handles POST /api/projects/{id}/stages.
func (s *Server) createStage(w http.ResponseWriter, r *http.Request) {
projectID := chi.URLParam(r, "id")
// 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
}
var req stageRequest
if !decodeJSON(w, r, &req) {
return
}
if req.Name == "" {
respondError(w, http.StatusBadRequest, "name is required")
return
}
if req.TagPattern == "" {
req.TagPattern = "*"
}
autoDeploy := false
if req.AutoDeploy != nil {
autoDeploy = *req.AutoDeploy
}
maxInstances := 1
if req.MaxInstances != nil {
maxInstances = *req.MaxInstances
}
confirm := false
if req.Confirm != nil {
confirm = *req.Confirm
}
stage, err := s.store.CreateStage(store.Stage{
ProjectID: projectID,
Name: req.Name,
TagPattern: req.TagPattern,
AutoDeploy: autoDeploy,
MaxInstances: maxInstances,
Confirm: confirm,
PromoteFrom: req.PromoteFrom,
Subdomain: req.Subdomain,
})
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to create stage: "+err.Error())
return
}
respondJSON(w, http.StatusCreated, stage)
}
// updateStage handles PUT /api/projects/{id}/stages/{stage}.
func (s *Server) updateStage(w http.ResponseWriter, r *http.Request) {
stageID := chi.URLParam(r, "stage")
existing, err := s.store.GetStageByID(stageID)
if 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 stageRequest
if !decodeJSON(w, r, &req) {
return
}
updated := existing
if req.Name != "" {
updated.Name = req.Name
}
if req.TagPattern != "" {
updated.TagPattern = req.TagPattern
}
if req.AutoDeploy != nil {
updated.AutoDeploy = *req.AutoDeploy
}
if req.MaxInstances != nil {
updated.MaxInstances = *req.MaxInstances
}
if req.Confirm != nil {
updated.Confirm = *req.Confirm
}
updated.PromoteFrom = req.PromoteFrom
updated.Subdomain = req.Subdomain
if err := s.store.UpdateStage(updated); err != nil {
respondError(w, http.StatusInternalServerError, "failed to update stage: "+err.Error())
return
}
respondJSON(w, http.StatusOK, updated)
}
// deleteStage handles DELETE /api/projects/{id}/stages/{stage}.
func (s *Server) deleteStage(w http.ResponseWriter, r *http.Request) {
stageID := chi.URLParam(r, "stage")
if err := s.store.DeleteStage(stageID); err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "stage")
return
}
respondError(w, http.StatusInternalServerError, "failed to delete stage: "+err.Error())
return
}
respondJSON(w, http.StatusOK, map[string]string{"deleted": stageID})
}