network/pkg/gateway/handlers/deployments/update_handler.go
2026-01-26 07:53:35 +02:00

280 lines
7.8 KiB
Go

package deployments
import (
"context"
"encoding/json"
"fmt"
"net/http"
"os"
"time"
"github.com/DeBrosOfficial/network/pkg/deployments"
"go.uber.org/zap"
)
// ProcessManager interface for process operations
type ProcessManager interface {
Restart(ctx context.Context, deployment *deployments.Deployment) error
WaitForHealthy(ctx context.Context, deployment *deployments.Deployment, timeout time.Duration) error
}
// UpdateHandler handles deployment updates
type UpdateHandler struct {
service *DeploymentService
staticHandler *StaticDeploymentHandler
nextjsHandler *NextJSHandler
processManager ProcessManager
logger *zap.Logger
}
// NewUpdateHandler creates a new update handler
func NewUpdateHandler(
service *DeploymentService,
staticHandler *StaticDeploymentHandler,
nextjsHandler *NextJSHandler,
processManager ProcessManager,
logger *zap.Logger,
) *UpdateHandler {
return &UpdateHandler{
service: service,
staticHandler: staticHandler,
nextjsHandler: nextjsHandler,
processManager: processManager,
logger: logger,
}
}
// HandleUpdate handles deployment updates
func (h *UpdateHandler) HandleUpdate(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
namespace := getNamespaceFromContext(ctx)
if namespace == "" {
http.Error(w, "Namespace not found in context", http.StatusUnauthorized)
return
}
// Parse multipart form
if err := r.ParseMultipartForm(200 << 20); err != nil {
http.Error(w, "Failed to parse form", http.StatusBadRequest)
return
}
name := r.FormValue("name")
if name == "" {
http.Error(w, "Deployment name is required", http.StatusBadRequest)
return
}
// Get existing deployment
existing, err := h.service.GetDeployment(ctx, namespace, name)
if err != nil {
if err == deployments.ErrDeploymentNotFound {
http.Error(w, "Deployment not found", http.StatusNotFound)
} else {
http.Error(w, "Failed to get deployment", http.StatusInternalServerError)
}
return
}
h.logger.Info("Updating deployment",
zap.String("namespace", namespace),
zap.String("name", name),
zap.Int("current_version", existing.Version),
)
// Handle update based on deployment type
var updated *deployments.Deployment
switch existing.Type {
case deployments.DeploymentTypeStatic, deployments.DeploymentTypeNextJSStatic:
updated, err = h.updateStatic(ctx, existing, r)
case deployments.DeploymentTypeNextJS, deployments.DeploymentTypeNodeJSBackend, deployments.DeploymentTypeGoBackend:
updated, err = h.updateDynamic(ctx, existing, r)
default:
http.Error(w, "Unsupported deployment type", http.StatusBadRequest)
return
}
if err != nil {
h.logger.Error("Update failed", zap.Error(err))
http.Error(w, fmt.Sprintf("Update failed: %v", err), http.StatusInternalServerError)
return
}
// Return response
resp := map[string]interface{}{
"deployment_id": updated.ID,
"name": updated.Name,
"namespace": updated.Namespace,
"status": updated.Status,
"version": updated.Version,
"previous_version": existing.Version,
"content_cid": updated.ContentCID,
"updated_at": updated.UpdatedAt,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
}
// updateStatic updates a static deployment (zero-downtime CID swap)
func (h *UpdateHandler) updateStatic(ctx context.Context, existing *deployments.Deployment, r *http.Request) (*deployments.Deployment, error) {
// Get new tarball
file, header, err := r.FormFile("tarball")
if err != nil {
return nil, fmt.Errorf("tarball file required for update")
}
defer file.Close()
// Upload to IPFS
addResp, err := h.staticHandler.ipfsClient.Add(ctx, file, header.Filename)
if err != nil {
return nil, fmt.Errorf("failed to upload to IPFS: %w", err)
}
cid := addResp.Cid
h.logger.Info("New content uploaded",
zap.String("deployment", existing.Name),
zap.String("old_cid", existing.ContentCID),
zap.String("new_cid", cid),
)
// Atomic CID swap
newVersion := existing.Version + 1
now := time.Now()
query := `
UPDATE deployments
SET content_cid = ?, version = ?, updated_at = ?
WHERE namespace = ? AND name = ?
`
_, err = h.service.db.Exec(ctx, query, cid, newVersion, now, existing.Namespace, existing.Name)
if err != nil {
return nil, fmt.Errorf("failed to update deployment: %w", err)
}
// Record in history
h.service.recordHistory(ctx, existing, "updated")
existing.ContentCID = cid
existing.Version = newVersion
existing.UpdatedAt = now
h.logger.Info("Static deployment updated",
zap.String("deployment", existing.Name),
zap.Int("version", newVersion),
zap.String("cid", cid),
)
return existing, nil
}
// updateDynamic updates a dynamic deployment (graceful restart)
func (h *UpdateHandler) updateDynamic(ctx context.Context, existing *deployments.Deployment, r *http.Request) (*deployments.Deployment, error) {
// Get new tarball
file, header, err := r.FormFile("tarball")
if err != nil {
return nil, fmt.Errorf("tarball file required for update")
}
defer file.Close()
// Upload to IPFS
addResp, err := h.nextjsHandler.ipfsClient.Add(ctx, file, header.Filename)
if err != nil {
return nil, fmt.Errorf("failed to upload to IPFS: %w", err)
}
cid := addResp.Cid
h.logger.Info("New build uploaded",
zap.String("deployment", existing.Name),
zap.String("old_cid", existing.BuildCID),
zap.String("new_cid", cid),
)
// Extract to staging directory
stagingPath := fmt.Sprintf("%s.new", h.nextjsHandler.baseDeployPath+"/"+existing.Namespace+"/"+existing.Name)
if err := h.nextjsHandler.extractFromIPFS(ctx, cid, stagingPath); err != nil {
return nil, fmt.Errorf("failed to extract new build: %w", err)
}
// Atomic swap: rename old to .old, new to current
deployPath := h.nextjsHandler.baseDeployPath + "/" + existing.Namespace + "/" + existing.Name
oldPath := deployPath + ".old"
// Backup current
if err := renameDirectory(deployPath, oldPath); err != nil {
return nil, fmt.Errorf("failed to backup current deployment: %w", err)
}
// Activate new
if err := renameDirectory(stagingPath, deployPath); err != nil {
// Rollback
renameDirectory(oldPath, deployPath)
return nil, fmt.Errorf("failed to activate new deployment: %w", err)
}
// Restart process
if err := h.processManager.Restart(ctx, existing); err != nil {
// Rollback
renameDirectory(deployPath, stagingPath)
renameDirectory(oldPath, deployPath)
h.processManager.Restart(ctx, existing)
return nil, fmt.Errorf("failed to restart process: %w", err)
}
// Wait for healthy
if err := h.processManager.WaitForHealthy(ctx, existing, 60*time.Second); err != nil {
h.logger.Warn("Deployment unhealthy after update, rolling back", zap.Error(err))
// Rollback
renameDirectory(deployPath, stagingPath)
renameDirectory(oldPath, deployPath)
h.processManager.Restart(ctx, existing)
return nil, fmt.Errorf("new deployment failed health check, rolled back: %w", err)
}
// Update database
newVersion := existing.Version + 1
now := time.Now()
query := `
UPDATE deployments
SET build_cid = ?, version = ?, updated_at = ?
WHERE namespace = ? AND name = ?
`
_, err = h.service.db.Exec(ctx, query, cid, newVersion, now, existing.Namespace, existing.Name)
if err != nil {
h.logger.Error("Failed to update database", zap.Error(err))
}
// Record in history
h.service.recordHistory(ctx, existing, "updated")
// Cleanup old
removeDirectory(oldPath)
existing.BuildCID = cid
existing.Version = newVersion
existing.UpdatedAt = now
h.logger.Info("Dynamic deployment updated",
zap.String("deployment", existing.Name),
zap.Int("version", newVersion),
zap.String("cid", cid),
)
return existing, nil
}
// Helper functions for filesystem operations
func renameDirectory(old, new string) error {
return os.Rename(old, new)
}
func removeDirectory(path string) error {
return os.RemoveAll(path)
}