feat(docker-watcher): phase 13 - volumes & environment
Per-stage env var overrides with encryption for secrets.
Volume mounts with shared/isolated modes (isolated appends
/{stage}-{tag}/ to source path). Store CRUD, API endpoints,
and frontend editors for both. Env merge during deploy.
This commit is contained in:
@@ -118,6 +118,12 @@ func (s *Server) Router() chi.Router {
|
||||
r.Put("/stages/{stage}", s.updateStage)
|
||||
r.Delete("/stages/{stage}", s.deleteStage)
|
||||
|
||||
// Stage env override endpoints.
|
||||
r.Get("/stages/{stage}/env", s.listStageEnv)
|
||||
r.Post("/stages/{stage}/env", s.createStageEnv)
|
||||
r.Put("/stages/{stage}/env/{envId}", s.updateStageEnv)
|
||||
r.Delete("/stages/{stage}/env/{envId}", s.deleteStageEnv)
|
||||
|
||||
// Instance endpoints.
|
||||
r.Get("/stages/{stage}/instances", s.listInstances)
|
||||
r.Post("/stages/{stage}/instances", s.deployInstance)
|
||||
@@ -127,6 +133,12 @@ func (s *Server) Router() chi.Router {
|
||||
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)
|
||||
|
||||
// Volume endpoints.
|
||||
r.Get("/volumes", s.listVolumes)
|
||||
r.Post("/volumes", s.createVolume)
|
||||
r.Put("/volumes/{volId}", s.updateVolume)
|
||||
r.Delete("/volumes/{volId}", s.deleteVolume)
|
||||
})
|
||||
|
||||
// Deploy endpoints.
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
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/store"
|
||||
)
|
||||
|
||||
// stageEnvRequest is the expected JSON body for creating/updating a stage env override.
|
||||
type stageEnvRequest struct {
|
||||
Key string `json:"key"`
|
||||
Value string `json:"value"`
|
||||
Encrypted *bool `json:"encrypted"`
|
||||
}
|
||||
|
||||
// listStageEnv handles GET /api/projects/{id}/stages/{stage}/env.
|
||||
func (s *Server) listStageEnv(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
|
||||
}
|
||||
|
||||
envs, err := s.store.GetStageEnvByStageID(stageID)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to list stage env: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Mask encrypted values in the response.
|
||||
masked := make([]store.StageEnv, len(envs))
|
||||
for i, env := range envs {
|
||||
masked[i] = env
|
||||
if env.Encrypted {
|
||||
masked[i].Value = "••••••••"
|
||||
}
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusOK, masked)
|
||||
}
|
||||
|
||||
// createStageEnv handles POST /api/projects/{id}/stages/{stage}/env.
|
||||
func (s *Server) createStageEnv(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
|
||||
}
|
||||
|
||||
var req stageEnvRequest
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
|
||||
if req.Key == "" {
|
||||
respondError(w, http.StatusBadRequest, "key is required")
|
||||
return
|
||||
}
|
||||
|
||||
encrypted := false
|
||||
if req.Encrypted != nil {
|
||||
encrypted = *req.Encrypted
|
||||
}
|
||||
|
||||
value := req.Value
|
||||
if encrypted && value != "" {
|
||||
enc, err := crypto.Encrypt(s.encKey, value)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to encrypt value: "+err.Error())
|
||||
return
|
||||
}
|
||||
value = enc
|
||||
}
|
||||
|
||||
env, err := s.store.CreateStageEnv(store.StageEnv{
|
||||
StageID: stageID,
|
||||
Key: req.Key,
|
||||
Value: value,
|
||||
Encrypted: encrypted,
|
||||
})
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to create stage env: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Mask encrypted value in the response.
|
||||
if env.Encrypted {
|
||||
env.Value = "••••••••"
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusCreated, env)
|
||||
}
|
||||
|
||||
// updateStageEnv handles PUT /api/projects/{id}/stages/{stage}/env/{envId}.
|
||||
func (s *Server) updateStageEnv(w http.ResponseWriter, r *http.Request) {
|
||||
envID := chi.URLParam(r, "envId")
|
||||
|
||||
existing, err := s.store.GetStageEnvByID(envID)
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
respondNotFound(w, "stage env")
|
||||
return
|
||||
}
|
||||
respondError(w, http.StatusInternalServerError, "failed to get stage env: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
var req stageEnvRequest
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
|
||||
updated := existing
|
||||
if req.Key != "" {
|
||||
updated.Key = req.Key
|
||||
}
|
||||
if req.Encrypted != nil {
|
||||
updated.Encrypted = *req.Encrypted
|
||||
}
|
||||
|
||||
// Only update value if provided (allows updating key/encrypted without changing the value).
|
||||
if req.Value != "" {
|
||||
value := req.Value
|
||||
if updated.Encrypted {
|
||||
enc, err := crypto.Encrypt(s.encKey, value)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to encrypt value: "+err.Error())
|
||||
return
|
||||
}
|
||||
value = enc
|
||||
}
|
||||
updated.Value = value
|
||||
}
|
||||
|
||||
if err := s.store.UpdateStageEnv(updated); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to update stage env: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Mask encrypted value in the response.
|
||||
if updated.Encrypted {
|
||||
updated.Value = "••••••••"
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusOK, updated)
|
||||
}
|
||||
|
||||
// deleteStageEnv handles DELETE /api/projects/{id}/stages/{stage}/env/{envId}.
|
||||
func (s *Server) deleteStageEnv(w http.ResponseWriter, r *http.Request) {
|
||||
envID := chi.URLParam(r, "envId")
|
||||
if err := s.store.DeleteStageEnv(envID); err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
respondNotFound(w, "stage env")
|
||||
return
|
||||
}
|
||||
respondError(w, http.StatusInternalServerError, "failed to delete stage env: "+err.Error())
|
||||
return
|
||||
}
|
||||
respondJSON(w, http.StatusOK, map[string]string{"deleted": envID})
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"github.com/alexei/docker-watcher/internal/store"
|
||||
)
|
||||
|
||||
// volumeRequest is the expected JSON body for creating/updating a volume.
|
||||
type volumeRequest struct {
|
||||
Source string `json:"source"`
|
||||
Target string `json:"target"`
|
||||
Mode string `json:"mode"`
|
||||
}
|
||||
|
||||
// listVolumes handles GET /api/projects/{id}/volumes.
|
||||
func (s *Server) listVolumes(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
|
||||
}
|
||||
|
||||
vols, err := s.store.GetVolumesByProjectID(projectID)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to list volumes: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusOK, vols)
|
||||
}
|
||||
|
||||
// createVolume handles POST /api/projects/{id}/volumes.
|
||||
func (s *Server) createVolume(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 volumeRequest
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
|
||||
if req.Source == "" {
|
||||
respondError(w, http.StatusBadRequest, "source is required")
|
||||
return
|
||||
}
|
||||
if req.Target == "" {
|
||||
respondError(w, http.StatusBadRequest, "target is required")
|
||||
return
|
||||
}
|
||||
if req.Mode == "" {
|
||||
req.Mode = "shared"
|
||||
}
|
||||
if req.Mode != "shared" && req.Mode != "isolated" {
|
||||
respondError(w, http.StatusBadRequest, "mode must be 'shared' or 'isolated'")
|
||||
return
|
||||
}
|
||||
|
||||
vol, err := s.store.CreateVolume(store.Volume{
|
||||
ProjectID: projectID,
|
||||
Source: req.Source,
|
||||
Target: req.Target,
|
||||
Mode: req.Mode,
|
||||
})
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to create volume: "+err.Error())
|
||||
return
|
||||
}
|
||||
respondJSON(w, http.StatusCreated, vol)
|
||||
}
|
||||
|
||||
// updateVolume handles PUT /api/projects/{id}/volumes/{volId}.
|
||||
func (s *Server) updateVolume(w http.ResponseWriter, r *http.Request) {
|
||||
volID := chi.URLParam(r, "volId")
|
||||
|
||||
existing, err := s.store.GetVolumeByID(volID)
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
respondNotFound(w, "volume")
|
||||
return
|
||||
}
|
||||
respondError(w, http.StatusInternalServerError, "failed to get volume: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
var req volumeRequest
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
|
||||
updated := existing
|
||||
if req.Source != "" {
|
||||
updated.Source = req.Source
|
||||
}
|
||||
if req.Target != "" {
|
||||
updated.Target = req.Target
|
||||
}
|
||||
if req.Mode != "" {
|
||||
if req.Mode != "shared" && req.Mode != "isolated" {
|
||||
respondError(w, http.StatusBadRequest, "mode must be 'shared' or 'isolated'")
|
||||
return
|
||||
}
|
||||
updated.Mode = req.Mode
|
||||
}
|
||||
|
||||
if err := s.store.UpdateVolume(updated); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to update volume: "+err.Error())
|
||||
return
|
||||
}
|
||||
respondJSON(w, http.StatusOK, updated)
|
||||
}
|
||||
|
||||
// deleteVolume handles DELETE /api/projects/{id}/volumes/{volId}.
|
||||
func (s *Server) deleteVolume(w http.ResponseWriter, r *http.Request) {
|
||||
volID := chi.URLParam(r, "volId")
|
||||
if err := s.store.DeleteVolume(volID); err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
respondNotFound(w, "volume")
|
||||
return
|
||||
}
|
||||
respondError(w, http.StatusInternalServerError, "failed to delete volume: "+err.Error())
|
||||
return
|
||||
}
|
||||
respondJSON(w, http.StatusOK, map[string]string{"deleted": volID})
|
||||
}
|
||||
Reference in New Issue
Block a user