This commit changes how projects are handled internally so that projects can be renamed. This commit also fixes some bugs, and removes redundant code.
580 lines
14 KiB
Go
580 lines
14 KiB
Go
package server
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"mime/multipart"
|
|
"net/http"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
|
|
"github.com/docker/docker/pkg/namesgenerator"
|
|
"github.com/google/uuid"
|
|
"github.com/joho/godotenv"
|
|
"github.com/juls0730/flux/pkg"
|
|
"go.uber.org/zap"
|
|
)
|
|
|
|
var (
|
|
appInsertStmt *sql.Stmt
|
|
)
|
|
|
|
type DeployRequest struct {
|
|
Id uuid.UUID `form:"id"`
|
|
Config pkg.ProjectConfig `form:"config"`
|
|
Code multipart.File `form:"code"`
|
|
}
|
|
|
|
type DeploymentLock struct {
|
|
mu sync.Mutex
|
|
deployed map[uuid.UUID]context.CancelFunc
|
|
}
|
|
|
|
func NewDeploymentLock() *DeploymentLock {
|
|
return &DeploymentLock{
|
|
deployed: make(map[uuid.UUID]context.CancelFunc),
|
|
}
|
|
}
|
|
|
|
// This function will lock a deployment based on an app name so that the same app cannot be deployed twice simultaneously
|
|
func (dt *DeploymentLock) LockDeployment(appId uuid.UUID, ctx context.Context) (context.Context, error) {
|
|
dt.mu.Lock()
|
|
defer dt.mu.Unlock()
|
|
|
|
// Check if the app is already being deployed
|
|
if _, exists := dt.deployed[appId]; exists {
|
|
return nil, fmt.Errorf("app is already being deployed")
|
|
}
|
|
|
|
// Create a context that can be cancelled
|
|
ctx, cancel := context.WithCancel(ctx)
|
|
|
|
// Store the cancel function
|
|
dt.deployed[appId] = cancel
|
|
|
|
return ctx, nil
|
|
}
|
|
|
|
// This function will unlock a deployment based on an app name so that the same app can be deployed again (you would call this after a deployment has completed)
|
|
func (dt *DeploymentLock) UnlockDeployment(appId uuid.UUID) {
|
|
dt.mu.Lock()
|
|
defer dt.mu.Unlock()
|
|
|
|
// Remove the app from deployed tracking
|
|
if cancel, exists := dt.deployed[appId]; exists {
|
|
// Cancel the context
|
|
cancel()
|
|
// Remove from map
|
|
delete(dt.deployed, appId)
|
|
}
|
|
}
|
|
|
|
var deploymentLock = NewDeploymentLock()
|
|
|
|
type DeploymentEvent struct {
|
|
Stage string `json:"stage"`
|
|
Message interface{} `json:"message"`
|
|
StatusCode int `json:"status,omitempty"`
|
|
}
|
|
|
|
func (flux *FluxServer) DeployHandler(w http.ResponseWriter, r *http.Request) {
|
|
if flux.appManager == nil {
|
|
panic("App manager is nil")
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "test/event-stream")
|
|
w.Header().Set("Cache-Control", "no-cache")
|
|
w.Header().Set("Connection", "keep-alive")
|
|
|
|
err := r.ParseMultipartForm(10 << 30) // 10 GiB
|
|
if err != nil {
|
|
logger.Errorw("Failed to parse multipart form", zap.Error(err))
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
var deployRequest DeployRequest
|
|
projectConfig := new(pkg.ProjectConfig)
|
|
if err := json.Unmarshal([]byte(r.FormValue("config")), &projectConfig); err != nil {
|
|
logger.Errorw("Failed to decode config", zap.Error(err))
|
|
|
|
http.Error(w, "Invalid flux.json", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
deployRequest.Config = *projectConfig
|
|
idStr := r.FormValue("id")
|
|
|
|
if idStr == "" {
|
|
id, err := uuid.NewRandom()
|
|
if err != nil {
|
|
logger.Errorw("Failed to generate uuid", zap.Error(err))
|
|
http.Error(w, "Failed to generate uuid", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
deployRequest.Id = id
|
|
} else {
|
|
deployRequest.Id, err = uuid.Parse(idStr)
|
|
if err != nil {
|
|
logger.Errorw("Failed to parse uuid", zap.Error(err))
|
|
http.Error(w, "Failed to parse uuid", http.StatusBadRequest)
|
|
return
|
|
}
|
|
}
|
|
|
|
ctx, err := deploymentLock.LockDeployment(deployRequest.Id, r.Context())
|
|
if err != nil {
|
|
// This will happen if the app is already being deployed
|
|
http.Error(w, err.Error(), http.StatusConflict)
|
|
return
|
|
}
|
|
|
|
go func() {
|
|
<-ctx.Done()
|
|
deploymentLock.UnlockDeployment(deployRequest.Id)
|
|
}()
|
|
|
|
flusher, ok := w.(http.Flusher)
|
|
if !ok {
|
|
http.Error(w, "Streaming unsupported!", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
w.WriteHeader(http.StatusMultiStatus)
|
|
|
|
eventChannel := make(chan DeploymentEvent, 10)
|
|
defer close(eventChannel)
|
|
|
|
var wg sync.WaitGroup
|
|
defer wg.Wait()
|
|
|
|
wg.Add(1)
|
|
go func(w http.ResponseWriter, flusher http.Flusher) {
|
|
defer wg.Done()
|
|
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case event, ok := <-eventChannel:
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
ev := pkg.DeploymentEvent{
|
|
Message: event.Message,
|
|
}
|
|
|
|
eventJSON, err := json.Marshal(ev)
|
|
if err != nil {
|
|
// Write error directly to ResponseWriter
|
|
jsonErr := json.NewEncoder(w).Encode(err)
|
|
if jsonErr != nil {
|
|
fmt.Fprint(w, "data: {\"message\": \"Error encoding error\"}\n\n")
|
|
return
|
|
}
|
|
|
|
fmt.Fprintf(w, "data: %s\n\n", err.Error())
|
|
if flusher != nil {
|
|
flusher.Flush()
|
|
}
|
|
return
|
|
}
|
|
|
|
fmt.Fprintf(w, "event: %s\n", event.Stage)
|
|
fmt.Fprintf(w, "data: %s\n\n", eventJSON)
|
|
if flusher != nil {
|
|
flusher.Flush()
|
|
}
|
|
|
|
if event.Stage == "error" || event.Stage == "complete" {
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}(w, flusher)
|
|
|
|
eventChannel <- DeploymentEvent{
|
|
Stage: "start",
|
|
Message: "Uploading code",
|
|
}
|
|
|
|
deployRequest.Code, _, err = r.FormFile("code")
|
|
if err != nil {
|
|
eventChannel <- DeploymentEvent{
|
|
Stage: "error",
|
|
Message: "No code archive found",
|
|
StatusCode: http.StatusBadRequest,
|
|
}
|
|
return
|
|
}
|
|
defer deployRequest.Code.Close()
|
|
|
|
if projectConfig.Name == "" || projectConfig.Url == "" || projectConfig.Port == 0 {
|
|
eventChannel <- DeploymentEvent{
|
|
Stage: "error",
|
|
Message: "Invalid flux.json, a name, url, and port must be specified",
|
|
StatusCode: http.StatusBadRequest,
|
|
}
|
|
return
|
|
}
|
|
|
|
logger.Infow("Deploying project", zap.String("name", projectConfig.Name), zap.String("url", projectConfig.Url), zap.String("id", deployRequest.Id.String()))
|
|
|
|
projectPath, err := flux.UploadAppCode(deployRequest.Code, deployRequest.Id)
|
|
if err != nil {
|
|
logger.Infow("Failed to upload code", zap.Error(err))
|
|
eventChannel <- DeploymentEvent{
|
|
Stage: "error",
|
|
Message: fmt.Sprintf("Failed to upload code: %s", err),
|
|
StatusCode: http.StatusInternalServerError,
|
|
}
|
|
return
|
|
}
|
|
|
|
// We need to pre-process EnvFile since docker has no concept of where the file is, or anything like that, so we have to read from it,
|
|
// and place all of it's content into the environment field so that docker can find it later
|
|
if projectConfig.EnvFile != "" {
|
|
envPath := filepath.Join(projectPath, projectConfig.EnvFile)
|
|
// prevent path traversal
|
|
realEnvPath, err := filepath.EvalSymlinks(envPath)
|
|
if err != nil {
|
|
logger.Errorw("Failed to eval symlinks", zap.Error(err))
|
|
eventChannel <- DeploymentEvent{
|
|
Stage: "error",
|
|
Message: fmt.Sprintf("Failed to eval symlinks: %s", err),
|
|
StatusCode: http.StatusInternalServerError,
|
|
}
|
|
return
|
|
}
|
|
|
|
if !strings.HasPrefix(realEnvPath, projectPath) {
|
|
logger.Errorw("Env file is not in project directory", zap.String("env_file", projectConfig.EnvFile))
|
|
eventChannel <- DeploymentEvent{
|
|
Stage: "error",
|
|
Message: fmt.Sprintf("Env file is not in project directory: %s", projectConfig.EnvFile),
|
|
StatusCode: http.StatusBadRequest,
|
|
}
|
|
return
|
|
}
|
|
|
|
envBytes, err := os.Open(realEnvPath)
|
|
if err != nil {
|
|
logger.Errorw("Failed to open env file", zap.Error(err))
|
|
eventChannel <- DeploymentEvent{
|
|
Stage: "error",
|
|
Message: fmt.Sprintf("Failed to open env file: %v", err),
|
|
StatusCode: http.StatusInternalServerError,
|
|
}
|
|
return
|
|
}
|
|
defer envBytes.Close()
|
|
|
|
envVars, err := godotenv.Parse(envBytes)
|
|
if err != nil {
|
|
logger.Errorw("Failed to parse env file", zap.Error(err))
|
|
eventChannel <- DeploymentEvent{
|
|
Stage: "error",
|
|
Message: fmt.Sprintf("Failed to parse env file: %v", err),
|
|
StatusCode: http.StatusInternalServerError,
|
|
}
|
|
return
|
|
}
|
|
|
|
for key, value := range envVars {
|
|
projectConfig.Environment = append(projectConfig.Environment, fmt.Sprintf("%s=%s", key, value))
|
|
}
|
|
}
|
|
|
|
pipeGroup := sync.WaitGroup{}
|
|
streamPipe := func(pipe io.ReadCloser) {
|
|
pipeGroup.Add(1)
|
|
defer pipeGroup.Done()
|
|
defer pipe.Close()
|
|
|
|
scanner := bufio.NewScanner(pipe)
|
|
for scanner.Scan() {
|
|
line := scanner.Text()
|
|
eventChannel <- DeploymentEvent{
|
|
Stage: "cmd_output",
|
|
Message: line,
|
|
}
|
|
}
|
|
|
|
if err := scanner.Err(); err != nil {
|
|
eventChannel <- DeploymentEvent{
|
|
Stage: "error",
|
|
Message: fmt.Sprintf("Failed to read pipe: %s", err),
|
|
}
|
|
logger.Errorw("Error reading pipe", zap.Error(err))
|
|
}
|
|
}
|
|
|
|
logger.Debugw("Preparing project", zap.String("name", projectConfig.Name), zap.String("id", deployRequest.Id.String()))
|
|
eventChannel <- DeploymentEvent{
|
|
Stage: "preparing",
|
|
Message: "Preparing project",
|
|
}
|
|
|
|
// redirect stdout and stderr to the event channel
|
|
reader, writer := io.Pipe()
|
|
prepareCmd := exec.Command("go", "generate")
|
|
prepareCmd.Dir = projectPath
|
|
prepareCmd.Stdout = writer
|
|
prepareCmd.Stderr = writer
|
|
|
|
err = prepareCmd.Start()
|
|
if err != nil {
|
|
logger.Errorw("Failed to prepare project", zap.Error(err))
|
|
eventChannel <- DeploymentEvent{
|
|
Stage: "error",
|
|
Message: fmt.Sprintf("Failed to prepare project: %s", err),
|
|
StatusCode: http.StatusInternalServerError,
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
go streamPipe(reader)
|
|
|
|
pipeGroup.Wait()
|
|
|
|
err = prepareCmd.Wait()
|
|
if err != nil {
|
|
logger.Errorw("Failed to prepare project", zap.Error(err))
|
|
eventChannel <- DeploymentEvent{
|
|
Stage: "error",
|
|
Message: fmt.Sprintf("Failed to prepare project: %s", err),
|
|
StatusCode: http.StatusInternalServerError,
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
writer.Close()
|
|
|
|
eventChannel <- DeploymentEvent{
|
|
Stage: "building",
|
|
Message: "Building project image",
|
|
}
|
|
|
|
reader, writer = io.Pipe()
|
|
logger.Debugw("Building image for project", zap.String("name", projectConfig.Name))
|
|
imageName := fmt.Sprintf("fluxi-%s", namesgenerator.GetRandomName(0))
|
|
buildCmd := exec.Command("pack", "build", imageName, "--builder", flux.config.Builder)
|
|
buildCmd.Dir = projectPath
|
|
buildCmd.Stdout = writer
|
|
buildCmd.Stderr = writer
|
|
|
|
err = buildCmd.Start()
|
|
if err != nil {
|
|
logger.Errorw("Failed to build image", zap.Error(err))
|
|
eventChannel <- DeploymentEvent{
|
|
Stage: "error",
|
|
Message: fmt.Sprintf("Failed to build image: %s", err),
|
|
StatusCode: http.StatusInternalServerError,
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
go streamPipe(reader)
|
|
|
|
pipeGroup.Wait()
|
|
|
|
err = buildCmd.Wait()
|
|
if err != nil {
|
|
logger.Errorw("Failed to build image", zap.Error(err))
|
|
eventChannel <- DeploymentEvent{
|
|
Stage: "error",
|
|
Message: fmt.Sprintf("Failed to build image: %s", err),
|
|
StatusCode: http.StatusInternalServerError,
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
app := flux.appManager.GetApp(deployRequest.Id)
|
|
|
|
eventChannel <- DeploymentEvent{
|
|
Stage: "creating",
|
|
Message: "Creating deployment",
|
|
}
|
|
|
|
if app == nil {
|
|
app, err = flux.CreateApp(ctx, imageName, projectPath, projectConfig, deployRequest.Id)
|
|
} else {
|
|
err = app.Upgrade(ctx, imageName, projectPath, projectConfig)
|
|
}
|
|
|
|
if err != nil {
|
|
logger.Errorw("Failed to deploy app", zap.Error(err))
|
|
eventChannel <- DeploymentEvent{
|
|
Stage: "error",
|
|
Message: fmt.Sprintf("Failed to upgrade app: %s", err),
|
|
StatusCode: http.StatusInternalServerError,
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
var extApp pkg.App
|
|
extApp.Id = app.Id
|
|
extApp.Name = app.Name
|
|
extApp.DeploymentID = app.DeploymentID
|
|
|
|
eventChannel <- DeploymentEvent{
|
|
Stage: "complete",
|
|
Message: extApp,
|
|
}
|
|
|
|
logger.Infow("App deployed successfully", zap.String("id", app.Id.String()))
|
|
}
|
|
|
|
func (flux *FluxServer) StartDeployHandler(w http.ResponseWriter, r *http.Request) {
|
|
appId, err := uuid.Parse(r.PathValue("id"))
|
|
if err != nil {
|
|
http.Error(w, "Invalid app id", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
app := flux.appManager.GetApp(appId)
|
|
if app == nil {
|
|
http.Error(w, "App not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
status, err := app.Deployment.Status(r.Context())
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
if status == "running" {
|
|
http.Error(w, "App is already running", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
err = app.Deployment.Start(r.Context())
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
if app.Deployment.Proxy == nil {
|
|
app.Deployment.Proxy, _ = app.Deployment.NewDeploymentProxy()
|
|
}
|
|
|
|
w.WriteHeader(http.StatusOK)
|
|
}
|
|
|
|
func (s *FluxServer) StopDeployHandler(w http.ResponseWriter, r *http.Request) {
|
|
appId, err := uuid.Parse(r.PathValue("id"))
|
|
if err != nil {
|
|
http.Error(w, "Invalid app id", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
app := Flux.appManager.GetApp(appId)
|
|
if app == nil {
|
|
http.Error(w, "App not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
status, err := app.Deployment.Status(r.Context())
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
if status == "stopped" || status == "failed" {
|
|
http.Error(w, "App is already stopped", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
err = app.Deployment.Stop(r.Context())
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
w.WriteHeader(http.StatusOK)
|
|
}
|
|
|
|
func (s *FluxServer) DeleteDeployHandler(w http.ResponseWriter, r *http.Request) {
|
|
appId, err := uuid.Parse(r.PathValue("id"))
|
|
if err != nil {
|
|
http.Error(w, "Invalid app id", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
logger.Debugw("Deleting deployment", zap.String("id", appId.String()))
|
|
|
|
err = Flux.appManager.DeleteApp(appId)
|
|
|
|
if err != nil {
|
|
logger.Errorw("Failed to delete app", zap.Error(err))
|
|
http.Error(w, err.Error(), http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
w.WriteHeader(http.StatusOK)
|
|
}
|
|
|
|
func (s *FluxServer) DeleteAllDeploymentsHandler(w http.ResponseWriter, r *http.Request) {
|
|
if s.config.DisableDeleteAll {
|
|
http.Error(w, "Delete all is disabled", http.StatusForbidden)
|
|
return
|
|
}
|
|
|
|
for _, app := range Flux.appManager.GetAllApps() {
|
|
err := Flux.appManager.DeleteApp(app.Id)
|
|
if err != nil {
|
|
logger.Errorw("Failed to remove app", zap.Error(err))
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
}
|
|
|
|
w.WriteHeader(http.StatusOK)
|
|
}
|
|
|
|
func (s *FluxServer) ListAppsHandler(w http.ResponseWriter, r *http.Request) {
|
|
// for each app, get the deployment status
|
|
var apps []pkg.App
|
|
for _, app := range Flux.appManager.GetAllApps() {
|
|
var extApp pkg.App
|
|
deploymentStatus, err := app.Deployment.Status(r.Context())
|
|
if err != nil {
|
|
logger.Errorw("Failed to get deployment status", zap.Error(err))
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
extApp.Id = app.Id
|
|
extApp.Name = app.Name
|
|
extApp.DeploymentID = app.DeploymentID
|
|
extApp.DeploymentStatus = deploymentStatus
|
|
apps = append(apps, extApp)
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(apps)
|
|
}
|
|
|
|
func (s *FluxServer) DaemonInfoHandler(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(pkg.Info{
|
|
Compression: s.config.Compression,
|
|
Version: pkg.Version,
|
|
})
|
|
}
|