123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594 |
- package handlers
- import (
- "context" // Ensure context is imported
- "encoding/json"
- "errors" // Added for errors.As
- "fmt"
- "net/http"
- "strconv"
- "git.linuxforward.com/byop/byop-engine/dbstore"
- "git.linuxforward.com/byop/byop-engine/models"
- "git.linuxforward.com/byop/byop-engine/services"
- "github.com/gin-gonic/gin"
- "github.com/sirupsen/logrus"
- )
- // AppsHandler handles app-related operations and contains integrated service logic
- type AppsHandler struct {
- store *dbstore.SQLiteStore
- entry *logrus.Entry
- previewService services.PreviewService
- appImporter *services.AppImporter
- }
- // NewAppsHandler creates a new AppsHandler
- func NewAppsHandler(store *dbstore.SQLiteStore, previewService services.PreviewService, appImporter *services.AppImporter) *AppsHandler {
- return &AppsHandler{
- store: store,
- entry: logrus.WithField("component", "AppsHandler"),
- previewService: previewService,
- appImporter: appImporter,
- }
- }
- // ListApps returns all apps with optional filtering
- func (h *AppsHandler) ListApps(c *gin.Context) {
- ctx := c.Request.Context() // Get context
- filter := make(map[string]interface{})
- // Attempt to bind query parameters, but allow empty filters
- if err := c.ShouldBindQuery(&filter); err != nil && len(filter) > 0 {
- models.RespondWithError(c, models.NewErrValidation("Invalid query parameters", nil, err))
- return
- }
- // Get apps directly from store
- apps, err := h.store.GetAllApps(ctx) // Pass context
- if err != nil {
- models.RespondWithError(c, err) // Pass db error directly
- return
- }
- // If empty, return an empty list
- if len(apps) == 0 {
- c.JSON(http.StatusOK, []*models.App{}) // Return empty slice of pointers
- return
- }
- c.JSON(http.StatusOK, apps)
- }
- // CreateApp creates a new deployment app
- func (h *AppsHandler) CreateApp(c *gin.Context) {
- ctx := c.Request.Context() // Get context
- app := &models.App{}
- if err := c.ShouldBindJSON(&app); err != nil {
- h.entry.WithField("error", err).Error("Failed to bind JSON to App struct")
- models.RespondWithError(c, models.NewErrValidation("Invalid request body", nil, err))
- return
- }
- // Get the user ID from the context (set by auth middleware)
- userIDInterface, exists := c.Get("user_id")
- if !exists {
- models.RespondWithError(c, models.NewErrUnauthorized("User ID not found in context", nil))
- return
- }
- // Convert user_id to int - it might be a string from JWT
- var userID int
- switch v := userIDInterface.(type) {
- case string:
- if parsedID, err := strconv.Atoi(v); err == nil {
- userID = parsedID
- } else {
- h.entry.Warnf("Failed to parse user_id string '%s' to int, defaulting to 1. Error: %v", v, err)
- userID = 1
- }
- case int:
- userID = v
- case int64:
- userID = int(v)
- default:
- h.entry.Warnf("User_id in context is of unexpected type %T, defaulting to 1.", v)
- userID = 1
- }
- // Set the user ID on the app
- app.UserID = uint(userID)
- h.entry.WithField("app", app).Info("JSON binding successful, starting validation")
- // Validate app configuration
- if err := h.validateAppConfig(ctx, app.Components); err != nil { // Pass context
- h.entry.WithField("error", err).Error("App configuration validation failed")
- // Check if the error from validateAppConfig is already a CustomError
- var customErr models.CustomError
- if errors.As(err, &customErr) {
- models.RespondWithError(c, customErr)
- } else {
- models.RespondWithError(c, models.NewErrValidation(fmt.Sprintf("Invalid app configuration: %v", err), nil, err))
- }
- return
- }
- h.entry.Info("App configuration validation passed")
- // Set initial status
- app.Status = "building"
- h.entry.WithField("app", app).Info("About to create app in database")
- // Create the app
- err := h.store.CreateApp(ctx, app) // Pass context
- if err != nil {
- h.entry.WithField("error", err).Error("Failed to create app in database")
- models.RespondWithError(c, err) // Pass db error directly
- return
- }
- // Automatically create preview - this happens async
- h.entry.WithField("app_id", app.ID).Info("Starting automatic preview creation")
- go h.createAppPreviewAsync(context.Background(), app.ID) // Use background context for async operation
- c.JSON(http.StatusCreated, app)
- }
- // GetApp returns a specific app
- func (h *AppsHandler) GetApp(c *gin.Context) {
- ctx := c.Request.Context() // Get context
- idStr := c.Param("id")
- parsedID, err := strconv.ParseUint(idStr, 10, 32)
- if err != nil {
- models.RespondWithError(c, models.NewErrValidation("Invalid app ID format", nil, err))
- return
- }
- id := uint(parsedID)
- // Get app directly from store
- app, err := h.store.GetAppByID(ctx, id) // Pass context and use uint id
- if err != nil {
- models.RespondWithError(c, err) // Pass db error directly (handles NotFound)
- return
- }
- // GetAppByID now returns models.NewErrNotFound, so this check might be redundant if RespondWithError handles nil correctly
- // However, GetAppByID returns (*models.App, error), so if err is nil, app could still be nil (though current db logic prevents this for NotFound)
- if app == nil && err == nil { // Explicitly handle if GetAppByID somehow returns nil, nil without specific error
- models.RespondWithError(c, models.NewErrNotFound(fmt.Sprintf("App with ID %d not found", id), nil))
- return
- }
- c.JSON(http.StatusOK, app)
- }
- // Updated UpdateApp method
- func (h *AppsHandler) UpdateApp(c *gin.Context) {
- ctx := c.Request.Context() // Get context
- id, err := parseUintID(c, "id")
- if err != nil {
- models.RespondWithError(c, err)
- return
- }
- updatedApp := &models.App{}
- if err := c.ShouldBindJSON(updatedApp); err != nil {
- models.RespondWithError(c, models.NewErrValidation("Invalid request body", nil, err))
- return
- }
- // Ensure the ID matches the URL parameter
- updatedApp.ID = id
- // Validate app data
- if err := h.validateAppConfig(ctx, updatedApp.Components); err != nil { // Pass context
- var customErr models.CustomError
- if errors.As(err, &customErr) {
- models.RespondWithError(c, customErr)
- } else {
- models.RespondWithError(c, models.NewErrValidation(fmt.Sprintf("Invalid app data: %v", err), nil, err))
- }
- return
- }
- // Check if app exists (though UpdateApp in store will also do this and return ErrNotFound)
- // This is more for a clear early exit if needed, but store.UpdateApp handles it.
- // For consistency, we can rely on store.UpdateApp's error.
- // Validate all components exist and are valid
- var componentIDs []uint
- if updatedApp.Components != "" {
- if err := json.Unmarshal([]byte(updatedApp.Components), &componentIDs); err != nil {
- models.RespondWithError(c, models.NewErrValidation("Invalid components JSON format", nil, err))
- return
- }
- }
- for _, componentID := range componentIDs {
- component, err := h.store.GetComponentByID(ctx, componentID) // Pass context
- if err != nil {
- models.RespondWithError(c, err) // Pass db error directly (handles NotFound)
- return
- }
- // GetComponentByID now returns models.NewErrNotFound, so this check might be redundant
- if component == nil && err == nil { // Explicitly handle if GetComponentByID somehow returns nil, nil
- models.RespondWithError(c, models.NewErrValidation(fmt.Sprintf("Component %d not found during app update validation", componentID), nil, nil))
- return
- }
- if component.Status != "valid" {
- models.RespondWithError(c, models.NewErrValidation(fmt.Sprintf("Component %d is not valid (status: %s)", componentID, component.Status), nil, nil))
- return
- }
- }
- // Set status to building (will be updated by preview creation)
- updatedApp.Status = "building"
- // Update the app
- if err := h.store.UpdateApp(ctx, updatedApp); err != nil { // Pass context
- models.RespondWithError(c, err) // Pass db error directly (handles NotFound)
- return
- }
- // Stop any existing previews for this app
- go h.stopExistingPreviews(context.Background(), updatedApp.ID) // Use background context
- // Automatically create new preview
- h.entry.WithField("app_id", updatedApp.ID).Info("Starting automatic preview creation after update")
- go h.createAppPreviewAsync(context.Background(), updatedApp.ID) // Use background context
- c.JSON(http.StatusOK, updatedApp)
- }
- // DeleteApp deletes an app
- func (h *AppsHandler) DeleteApp(c *gin.Context) {
- ctx := c.Request.Context() // Get context
- id, err := parseUintID(c, "id")
- if err != nil {
- models.RespondWithError(c, err)
- return
- }
- // Call the store delete method
- if err := h.store.DeleteApp(ctx, id); err != nil { // Pass context
- models.RespondWithError(c, err) // Pass db error directly (handles NotFound, Conflict)
- return
- }
- c.JSON(http.StatusOK, gin.H{"message": "App deleted successfully"})
- }
- // GetAppDeployments returns all deployments for an app
- func (h *AppsHandler) GetAppDeployments(c *gin.Context) {
- ctx := c.Request.Context() // Get context
- id, err := parseUintID(c, "id")
- if err != nil {
- models.RespondWithError(c, err)
- return
- }
- // Check if app exists first
- app, err := h.store.GetAppByID(ctx, id) // Pass context
- if err != nil {
- models.RespondWithError(c, err) // Pass db error directly (handles NotFound)
- return
- }
- if app == nil && err == nil { // Should be caught by GetAppByID's error handling
- models.RespondWithError(c, models.NewErrNotFound(fmt.Sprintf("App with ID %d not found", id), nil))
- return
- }
- // Get deployments for this app
- deployments, err := h.store.GetDeploymentsByAppID(ctx, id) // Pass context
- if err != nil {
- models.RespondWithError(c, err) // Pass db error directly
- return
- }
- c.JSON(http.StatusOK, deployments)
- }
- // GetAppByVersion handles retrieval of an app by name and version
- func (h *AppsHandler) GetAppByVersion(c *gin.Context) {
- ctx := c.Request.Context() // Get context
- name := c.Query("name")
- version := c.Query("version")
- if name == "" || version == "" {
- models.RespondWithError(c, models.NewErrValidation("Both name and version query parameters are required", nil, nil))
- return
- }
- // Get app by name and version directly from store
- app, err := h.getAppByNameAndVersion(ctx, name, version) // Pass context
- if err != nil {
- var customErr models.CustomError
- if errors.As(err, &customErr) {
- models.RespondWithError(c, customErr)
- } else {
- models.RespondWithError(c, models.NewErrInternalServer(fmt.Sprintf("Failed to fetch app by name '%s' and version '%s'", name, version), err))
- }
- return
- }
- if app == nil {
- models.RespondWithError(c, models.NewErrNotFound(fmt.Sprintf("App with name '%s' and version '%s' not found", name, version), nil))
- return
- }
- c.JSON(http.StatusOK, app)
- }
- // validateAppConfig checks if the app configuration is valid
- func (h *AppsHandler) validateAppConfig(ctx context.Context, componentsJSON string) error { // Added context
- if componentsJSON == "" {
- return models.NewErrValidation("App must have at least one component", nil, nil) // Return custom error
- }
- // Parse the JSON string of component IDs
- var componentIds []uint
- if err := json.Unmarshal([]byte(componentsJSON), &componentIds); err != nil {
- return models.NewErrValidation("Invalid components JSON format", nil, err)
- }
- if len(componentIds) == 0 {
- return models.NewErrValidation("App must have at least one component", nil, nil) // Return custom error
- }
- for _, compId := range componentIds {
- if compId == 0 {
- return models.NewErrValidation("Component ID cannot be zero", nil, nil) // Return custom error
- }
- comp, err := h.store.GetComponentByID(ctx, compId) // Pass context
- if err != nil {
- return err
- }
- if comp == nil && err == nil {
- return models.NewErrValidation(fmt.Sprintf("Component with ID %d does not exist (validation check)", compId), nil, nil)
- }
- if comp.Name == "" {
- return models.NewErrValidation(fmt.Sprintf("Component with ID %d has an empty name", compId), nil, nil) // Return custom error
- }
- if comp.Type == "" {
- return models.NewErrValidation(fmt.Sprintf("Component with ID %d has an empty type", compId), nil, nil) // Return custom error
- }
- }
- return nil
- }
- // getAppByNameAndVersion retrieves an app by name and version from the store
- func (h *AppsHandler) getAppByNameAndVersion(ctx context.Context, name, version string) (*models.App, error) { // Added context
- apps, err := h.store.GetAllApps(ctx) // Pass context
- if err != nil {
- return nil, err
- }
- for _, app := range apps {
- if app.Name == name && app.Description == version { // Using description as version for now
- return app, nil
- }
- }
- return nil, models.NewErrNotFound(fmt.Sprintf("App with name '%s' and version '%s' not found (helper)", name, version), nil) // Return custom error
- }
- // createAppPreviewAsync creates a preview automatically and updates app status
- func (h *AppsHandler) createAppPreviewAsync(ctx context.Context, appId uint) { // Added context
- preview, err := h.previewService.CreatePreview(ctx, appId) // Pass context
- if err != nil {
- h.entry.WithField("app_id", appId).Errorf("Failed to create preview: %v", err)
- if updateErr := h.store.UpdateAppStatus(ctx, appId, "failed", fmt.Sprintf("Failed to create preview: %v", err)); updateErr != nil {
- h.entry.WithField("app_id", appId).Errorf("Additionally failed to update app status after preview failure: %v", updateErr)
- }
- return
- }
- if err := h.store.UpdateAppPreview(ctx, appId, preview.ID, preview.URL); err != nil { // Pass context
- h.entry.WithField("app_id", appId).Errorf("Failed to update app with preview info: %v", err)
- }
- h.entry.WithField("app_id", appId).WithField("preview_id", preview.ID).Info("Automatic preview creation initiated")
- }
- // stopExistingPreviews stops any running previews for an app
- func (h *AppsHandler) stopExistingPreviews(ctx context.Context, appID uint) { // Added context
- previews, err := h.store.GetPreviewsByAppID(ctx, appID) // Pass context
- if err != nil {
- h.entry.WithField("app_id", appID).Errorf("Failed to get existing previews: %v", err)
- return
- }
- for _, preview := range previews {
- if preview.Status == "running" {
- if err := h.previewService.StopPreview(ctx, preview.ID); err != nil { // Pass context
- h.entry.WithField("preview_id", preview.ID).Errorf("Failed to stop existing preview: %v", err)
- }
- }
- }
- }
- // CreateAppPreview creates a new preview for an app
- func (h *AppsHandler) CreateAppPreview(c *gin.Context) {
- ctx := c.Request.Context() // Get context
- id, err := parseUintID(c, "id")
- if err != nil {
- models.RespondWithError(c, err)
- return
- }
- app, err := h.store.GetAppByID(ctx, id) // Pass context
- if err != nil {
- models.RespondWithError(c, err)
- return
- }
- if app == nil {
- models.RespondWithError(c, models.NewErrNotFound(fmt.Sprintf("App with ID %d not found", id), nil))
- return
- }
- h.entry.WithField("app_id", app.ID).Info("Creating manual preview for app")
- go h.createAppPreviewAsync(context.Background(), app.ID) // Use background context for async operation
- c.JSON(http.StatusOK, gin.H{"message": "Preview creation started"})
- }
- // GetAppPreview returns the preview for a specific app
- func (h *AppsHandler) GetAppPreview(c *gin.Context) {
- ctx := c.Request.Context() // Get context
- id, err := parseUintID(c, "id")
- if err != nil {
- models.RespondWithError(c, err)
- return
- }
- app, err := h.store.GetAppByID(ctx, id) // Pass context
- if err != nil {
- models.RespondWithError(c, err)
- return
- }
- if app == nil {
- models.RespondWithError(c, models.NewErrNotFound(fmt.Sprintf("App with ID %d not found", id), nil))
- return
- }
- preview, err := h.store.GetPreviewByAppID(ctx, app.ID) // Pass context
- if err != nil {
- models.RespondWithError(c, err)
- return
- }
- if preview == nil {
- models.RespondWithError(c, models.NewErrNotFound(fmt.Sprintf("Preview for app ID %d not found", id), nil))
- return
- }
- c.JSON(http.StatusOK, preview)
- }
- // DeleteAppPreview deletes the preview for a specific app
- func (h *AppsHandler) DeleteAppPreview(c *gin.Context) {
- ctx := c.Request.Context() // Get context
- id, err := parseUintID(c, "id")
- if err != nil {
- models.RespondWithError(c, err)
- return
- }
- app, err := h.store.GetAppByID(ctx, id) // Pass context
- if err != nil {
- models.RespondWithError(c, err)
- return
- }
- if app == nil {
- models.RespondWithError(c, models.NewErrNotFound(fmt.Sprintf("App with ID %d not found", id), nil))
- return
- }
- if err := h.previewService.DeletePreview(ctx, app.ID); err != nil { // Pass context
- models.RespondWithError(c, err)
- return
- }
- c.JSON(http.StatusOK, gin.H{"message": "Preview deleted successfully"})
- }
- // ImportReview reviews a docker-compose file for importing an app
- func (h *AppsHandler) ImportReview(c *gin.Context) {
- ctx := c.Request.Context()
- var req models.AppImportRequest
- if err := c.ShouldBindJSON(&req); err != nil {
- h.entry.WithField("error", err).Error("Failed to bind JSON to AppImportRequest struct")
- models.RespondWithError(c, models.NewErrValidation("Invalid request body", nil, err))
- return
- }
- // Set default branch if not provided
- if req.Branch == "" {
- req.Branch = "main"
- }
- h.entry.Infof("Reviewing compose import from %s (branch: %s)", req.SourceURL, req.Branch)
- review, err := h.appImporter.ReviewComposeFile(ctx, req)
- if err != nil {
- h.entry.WithField("error", err).Error("Failed to review compose file")
- models.RespondWithError(c, models.NewErrInternalServer("Failed to review compose file", err))
- return
- }
- c.JSON(http.StatusOK, review)
- }
- // ImportCreate creates an app and components from a docker-compose file
- func (h *AppsHandler) ImportCreate(c *gin.Context) {
- ctx := c.Request.Context()
- var req models.AppImportCreateRequest
- if err := c.ShouldBindJSON(&req); err != nil {
- h.entry.WithField("error", err).Error("Failed to bind JSON to AppImportCreateRequest struct")
- models.RespondWithError(c, models.NewErrValidation("Invalid request body", nil, err))
- return
- }
- // Set default branch if not provided
- if req.Branch == "" {
- req.Branch = "main"
- }
- // Get the user ID from the context (set by auth middleware)
- userIDInterface, exists := c.Get("user_id")
- if !exists {
- models.RespondWithError(c, models.NewErrUnauthorized("User ID not found in context", nil))
- return
- }
- // Convert user_id to uint - it might be a string from JWT
- var userID uint
- switch v := userIDInterface.(type) {
- case string:
- if parsedID, err := strconv.ParseUint(v, 10, 32); err == nil {
- userID = uint(parsedID)
- } else {
- h.entry.Warnf("Failed to parse user_id string '%s' to uint, defaulting to 1. Error: %v", v, err)
- userID = 1
- }
- case int:
- userID = uint(v)
- case int64:
- userID = uint(v)
- case uint:
- userID = v
- default:
- h.entry.Warnf("User_id in context is of unexpected type %T, defaulting to 1.", v)
- userID = 1
- }
- h.entry.Infof("Creating app '%s' from compose import for user %d", req.ConfirmedAppName, userID)
- app, err := h.appImporter.CreateAppFromCompose(ctx, req, userID)
- if err != nil {
- h.entry.WithField("error", err).Error("Failed to create app from compose")
- models.RespondWithError(c, models.NewErrInternalServer("Failed to create app from compose", err))
- return
- }
- c.JSON(http.StatusCreated, app)
- }
- // parseUintID parses a string ID parameter to uint
- func parseUintID(c *gin.Context, paramName string) (uint, error) {
- idStr := c.Param(paramName)
- parsedID, err := strconv.ParseUint(idStr, 10, 32)
- if err != nil {
- return 0, models.NewErrValidation(fmt.Sprintf("Invalid %s ID format", paramName), nil, err)
- }
- return uint(parsedID), nil
- }
|