apps.go 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594
  1. package handlers
  2. import (
  3. "context" // Ensure context is imported
  4. "encoding/json"
  5. "errors" // Added for errors.As
  6. "fmt"
  7. "net/http"
  8. "strconv"
  9. "git.linuxforward.com/byop/byop-engine/dbstore"
  10. "git.linuxforward.com/byop/byop-engine/models"
  11. "git.linuxforward.com/byop/byop-engine/services"
  12. "github.com/gin-gonic/gin"
  13. "github.com/sirupsen/logrus"
  14. )
  15. // AppsHandler handles app-related operations and contains integrated service logic
  16. type AppsHandler struct {
  17. store *dbstore.SQLiteStore
  18. entry *logrus.Entry
  19. previewService services.PreviewService
  20. appImporter *services.AppImporter
  21. }
  22. // NewAppsHandler creates a new AppsHandler
  23. func NewAppsHandler(store *dbstore.SQLiteStore, previewService services.PreviewService, appImporter *services.AppImporter) *AppsHandler {
  24. return &AppsHandler{
  25. store: store,
  26. entry: logrus.WithField("component", "AppsHandler"),
  27. previewService: previewService,
  28. appImporter: appImporter,
  29. }
  30. }
  31. // ListApps returns all apps with optional filtering
  32. func (h *AppsHandler) ListApps(c *gin.Context) {
  33. ctx := c.Request.Context() // Get context
  34. filter := make(map[string]interface{})
  35. // Attempt to bind query parameters, but allow empty filters
  36. if err := c.ShouldBindQuery(&filter); err != nil && len(filter) > 0 {
  37. models.RespondWithError(c, models.NewErrValidation("Invalid query parameters", nil, err))
  38. return
  39. }
  40. // Get apps directly from store
  41. apps, err := h.store.GetAllApps(ctx) // Pass context
  42. if err != nil {
  43. models.RespondWithError(c, err) // Pass db error directly
  44. return
  45. }
  46. // If empty, return an empty list
  47. if len(apps) == 0 {
  48. c.JSON(http.StatusOK, []*models.App{}) // Return empty slice of pointers
  49. return
  50. }
  51. c.JSON(http.StatusOK, apps)
  52. }
  53. // CreateApp creates a new deployment app
  54. func (h *AppsHandler) CreateApp(c *gin.Context) {
  55. ctx := c.Request.Context() // Get context
  56. app := &models.App{}
  57. if err := c.ShouldBindJSON(&app); err != nil {
  58. h.entry.WithField("error", err).Error("Failed to bind JSON to App struct")
  59. models.RespondWithError(c, models.NewErrValidation("Invalid request body", nil, err))
  60. return
  61. }
  62. // Get the user ID from the context (set by auth middleware)
  63. userIDInterface, exists := c.Get("user_id")
  64. if !exists {
  65. models.RespondWithError(c, models.NewErrUnauthorized("User ID not found in context", nil))
  66. return
  67. }
  68. // Convert user_id to int - it might be a string from JWT
  69. var userID int
  70. switch v := userIDInterface.(type) {
  71. case string:
  72. if parsedID, err := strconv.Atoi(v); err == nil {
  73. userID = parsedID
  74. } else {
  75. h.entry.Warnf("Failed to parse user_id string '%s' to int, defaulting to 1. Error: %v", v, err)
  76. userID = 1
  77. }
  78. case int:
  79. userID = v
  80. case int64:
  81. userID = int(v)
  82. default:
  83. h.entry.Warnf("User_id in context is of unexpected type %T, defaulting to 1.", v)
  84. userID = 1
  85. }
  86. // Set the user ID on the app
  87. app.UserID = uint(userID)
  88. h.entry.WithField("app", app).Info("JSON binding successful, starting validation")
  89. // Validate app configuration
  90. if err := h.validateAppConfig(ctx, app.Components); err != nil { // Pass context
  91. h.entry.WithField("error", err).Error("App configuration validation failed")
  92. // Check if the error from validateAppConfig is already a CustomError
  93. var customErr models.CustomError
  94. if errors.As(err, &customErr) {
  95. models.RespondWithError(c, customErr)
  96. } else {
  97. models.RespondWithError(c, models.NewErrValidation(fmt.Sprintf("Invalid app configuration: %v", err), nil, err))
  98. }
  99. return
  100. }
  101. h.entry.Info("App configuration validation passed")
  102. // Set initial status
  103. app.Status = "building"
  104. h.entry.WithField("app", app).Info("About to create app in database")
  105. // Create the app
  106. err := h.store.CreateApp(ctx, app) // Pass context
  107. if err != nil {
  108. h.entry.WithField("error", err).Error("Failed to create app in database")
  109. models.RespondWithError(c, err) // Pass db error directly
  110. return
  111. }
  112. // Automatically create preview - this happens async
  113. h.entry.WithField("app_id", app.ID).Info("Starting automatic preview creation")
  114. go h.createAppPreviewAsync(context.Background(), app.ID) // Use background context for async operation
  115. c.JSON(http.StatusCreated, app)
  116. }
  117. // GetApp returns a specific app
  118. func (h *AppsHandler) GetApp(c *gin.Context) {
  119. ctx := c.Request.Context() // Get context
  120. idStr := c.Param("id")
  121. parsedID, err := strconv.ParseUint(idStr, 10, 32)
  122. if err != nil {
  123. models.RespondWithError(c, models.NewErrValidation("Invalid app ID format", nil, err))
  124. return
  125. }
  126. id := uint(parsedID)
  127. // Get app directly from store
  128. app, err := h.store.GetAppByID(ctx, id) // Pass context and use uint id
  129. if err != nil {
  130. models.RespondWithError(c, err) // Pass db error directly (handles NotFound)
  131. return
  132. }
  133. // GetAppByID now returns models.NewErrNotFound, so this check might be redundant if RespondWithError handles nil correctly
  134. // However, GetAppByID returns (*models.App, error), so if err is nil, app could still be nil (though current db logic prevents this for NotFound)
  135. if app == nil && err == nil { // Explicitly handle if GetAppByID somehow returns nil, nil without specific error
  136. models.RespondWithError(c, models.NewErrNotFound(fmt.Sprintf("App with ID %d not found", id), nil))
  137. return
  138. }
  139. c.JSON(http.StatusOK, app)
  140. }
  141. // Updated UpdateApp method
  142. func (h *AppsHandler) UpdateApp(c *gin.Context) {
  143. ctx := c.Request.Context() // Get context
  144. id, err := parseUintID(c, "id")
  145. if err != nil {
  146. models.RespondWithError(c, err)
  147. return
  148. }
  149. updatedApp := &models.App{}
  150. if err := c.ShouldBindJSON(updatedApp); err != nil {
  151. models.RespondWithError(c, models.NewErrValidation("Invalid request body", nil, err))
  152. return
  153. }
  154. // Ensure the ID matches the URL parameter
  155. updatedApp.ID = id
  156. // Validate app data
  157. if err := h.validateAppConfig(ctx, updatedApp.Components); err != nil { // Pass context
  158. var customErr models.CustomError
  159. if errors.As(err, &customErr) {
  160. models.RespondWithError(c, customErr)
  161. } else {
  162. models.RespondWithError(c, models.NewErrValidation(fmt.Sprintf("Invalid app data: %v", err), nil, err))
  163. }
  164. return
  165. }
  166. // Check if app exists (though UpdateApp in store will also do this and return ErrNotFound)
  167. // This is more for a clear early exit if needed, but store.UpdateApp handles it.
  168. // For consistency, we can rely on store.UpdateApp's error.
  169. // Validate all components exist and are valid
  170. var componentIDs []uint
  171. if updatedApp.Components != "" {
  172. if err := json.Unmarshal([]byte(updatedApp.Components), &componentIDs); err != nil {
  173. models.RespondWithError(c, models.NewErrValidation("Invalid components JSON format", nil, err))
  174. return
  175. }
  176. }
  177. for _, componentID := range componentIDs {
  178. component, err := h.store.GetComponentByID(ctx, componentID) // Pass context
  179. if err != nil {
  180. models.RespondWithError(c, err) // Pass db error directly (handles NotFound)
  181. return
  182. }
  183. // GetComponentByID now returns models.NewErrNotFound, so this check might be redundant
  184. if component == nil && err == nil { // Explicitly handle if GetComponentByID somehow returns nil, nil
  185. models.RespondWithError(c, models.NewErrValidation(fmt.Sprintf("Component %d not found during app update validation", componentID), nil, nil))
  186. return
  187. }
  188. if component.Status != "valid" {
  189. models.RespondWithError(c, models.NewErrValidation(fmt.Sprintf("Component %d is not valid (status: %s)", componentID, component.Status), nil, nil))
  190. return
  191. }
  192. }
  193. // Set status to building (will be updated by preview creation)
  194. updatedApp.Status = "building"
  195. // Update the app
  196. if err := h.store.UpdateApp(ctx, updatedApp); err != nil { // Pass context
  197. models.RespondWithError(c, err) // Pass db error directly (handles NotFound)
  198. return
  199. }
  200. // Stop any existing previews for this app
  201. go h.stopExistingPreviews(context.Background(), updatedApp.ID) // Use background context
  202. // Automatically create new preview
  203. h.entry.WithField("app_id", updatedApp.ID).Info("Starting automatic preview creation after update")
  204. go h.createAppPreviewAsync(context.Background(), updatedApp.ID) // Use background context
  205. c.JSON(http.StatusOK, updatedApp)
  206. }
  207. // DeleteApp deletes an app
  208. func (h *AppsHandler) DeleteApp(c *gin.Context) {
  209. ctx := c.Request.Context() // Get context
  210. id, err := parseUintID(c, "id")
  211. if err != nil {
  212. models.RespondWithError(c, err)
  213. return
  214. }
  215. // Call the store delete method
  216. if err := h.store.DeleteApp(ctx, id); err != nil { // Pass context
  217. models.RespondWithError(c, err) // Pass db error directly (handles NotFound, Conflict)
  218. return
  219. }
  220. c.JSON(http.StatusOK, gin.H{"message": "App deleted successfully"})
  221. }
  222. // GetAppDeployments returns all deployments for an app
  223. func (h *AppsHandler) GetAppDeployments(c *gin.Context) {
  224. ctx := c.Request.Context() // Get context
  225. id, err := parseUintID(c, "id")
  226. if err != nil {
  227. models.RespondWithError(c, err)
  228. return
  229. }
  230. // Check if app exists first
  231. app, err := h.store.GetAppByID(ctx, id) // Pass context
  232. if err != nil {
  233. models.RespondWithError(c, err) // Pass db error directly (handles NotFound)
  234. return
  235. }
  236. if app == nil && err == nil { // Should be caught by GetAppByID's error handling
  237. models.RespondWithError(c, models.NewErrNotFound(fmt.Sprintf("App with ID %d not found", id), nil))
  238. return
  239. }
  240. // Get deployments for this app
  241. deployments, err := h.store.GetDeploymentsByAppID(ctx, id) // Pass context
  242. if err != nil {
  243. models.RespondWithError(c, err) // Pass db error directly
  244. return
  245. }
  246. c.JSON(http.StatusOK, deployments)
  247. }
  248. // GetAppByVersion handles retrieval of an app by name and version
  249. func (h *AppsHandler) GetAppByVersion(c *gin.Context) {
  250. ctx := c.Request.Context() // Get context
  251. name := c.Query("name")
  252. version := c.Query("version")
  253. if name == "" || version == "" {
  254. models.RespondWithError(c, models.NewErrValidation("Both name and version query parameters are required", nil, nil))
  255. return
  256. }
  257. // Get app by name and version directly from store
  258. app, err := h.getAppByNameAndVersion(ctx, name, version) // Pass context
  259. if err != nil {
  260. var customErr models.CustomError
  261. if errors.As(err, &customErr) {
  262. models.RespondWithError(c, customErr)
  263. } else {
  264. models.RespondWithError(c, models.NewErrInternalServer(fmt.Sprintf("Failed to fetch app by name '%s' and version '%s'", name, version), err))
  265. }
  266. return
  267. }
  268. if app == nil {
  269. models.RespondWithError(c, models.NewErrNotFound(fmt.Sprintf("App with name '%s' and version '%s' not found", name, version), nil))
  270. return
  271. }
  272. c.JSON(http.StatusOK, app)
  273. }
  274. // validateAppConfig checks if the app configuration is valid
  275. func (h *AppsHandler) validateAppConfig(ctx context.Context, componentsJSON string) error { // Added context
  276. if componentsJSON == "" {
  277. return models.NewErrValidation("App must have at least one component", nil, nil) // Return custom error
  278. }
  279. // Parse the JSON string of component IDs
  280. var componentIds []uint
  281. if err := json.Unmarshal([]byte(componentsJSON), &componentIds); err != nil {
  282. return models.NewErrValidation("Invalid components JSON format", nil, err)
  283. }
  284. if len(componentIds) == 0 {
  285. return models.NewErrValidation("App must have at least one component", nil, nil) // Return custom error
  286. }
  287. for _, compId := range componentIds {
  288. if compId == 0 {
  289. return models.NewErrValidation("Component ID cannot be zero", nil, nil) // Return custom error
  290. }
  291. comp, err := h.store.GetComponentByID(ctx, compId) // Pass context
  292. if err != nil {
  293. return err
  294. }
  295. if comp == nil && err == nil {
  296. return models.NewErrValidation(fmt.Sprintf("Component with ID %d does not exist (validation check)", compId), nil, nil)
  297. }
  298. if comp.Name == "" {
  299. return models.NewErrValidation(fmt.Sprintf("Component with ID %d has an empty name", compId), nil, nil) // Return custom error
  300. }
  301. if comp.Type == "" {
  302. return models.NewErrValidation(fmt.Sprintf("Component with ID %d has an empty type", compId), nil, nil) // Return custom error
  303. }
  304. }
  305. return nil
  306. }
  307. // getAppByNameAndVersion retrieves an app by name and version from the store
  308. func (h *AppsHandler) getAppByNameAndVersion(ctx context.Context, name, version string) (*models.App, error) { // Added context
  309. apps, err := h.store.GetAllApps(ctx) // Pass context
  310. if err != nil {
  311. return nil, err
  312. }
  313. for _, app := range apps {
  314. if app.Name == name && app.Description == version { // Using description as version for now
  315. return app, nil
  316. }
  317. }
  318. return nil, models.NewErrNotFound(fmt.Sprintf("App with name '%s' and version '%s' not found (helper)", name, version), nil) // Return custom error
  319. }
  320. // createAppPreviewAsync creates a preview automatically and updates app status
  321. func (h *AppsHandler) createAppPreviewAsync(ctx context.Context, appId uint) { // Added context
  322. preview, err := h.previewService.CreatePreview(ctx, appId) // Pass context
  323. if err != nil {
  324. h.entry.WithField("app_id", appId).Errorf("Failed to create preview: %v", err)
  325. if updateErr := h.store.UpdateAppStatus(ctx, appId, "failed", fmt.Sprintf("Failed to create preview: %v", err)); updateErr != nil {
  326. h.entry.WithField("app_id", appId).Errorf("Additionally failed to update app status after preview failure: %v", updateErr)
  327. }
  328. return
  329. }
  330. if err := h.store.UpdateAppPreview(ctx, appId, preview.ID, preview.URL); err != nil { // Pass context
  331. h.entry.WithField("app_id", appId).Errorf("Failed to update app with preview info: %v", err)
  332. }
  333. h.entry.WithField("app_id", appId).WithField("preview_id", preview.ID).Info("Automatic preview creation initiated")
  334. }
  335. // stopExistingPreviews stops any running previews for an app
  336. func (h *AppsHandler) stopExistingPreviews(ctx context.Context, appID uint) { // Added context
  337. previews, err := h.store.GetPreviewsByAppID(ctx, appID) // Pass context
  338. if err != nil {
  339. h.entry.WithField("app_id", appID).Errorf("Failed to get existing previews: %v", err)
  340. return
  341. }
  342. for _, preview := range previews {
  343. if preview.Status == "running" {
  344. if err := h.previewService.StopPreview(ctx, preview.ID); err != nil { // Pass context
  345. h.entry.WithField("preview_id", preview.ID).Errorf("Failed to stop existing preview: %v", err)
  346. }
  347. }
  348. }
  349. }
  350. // CreateAppPreview creates a new preview for an app
  351. func (h *AppsHandler) CreateAppPreview(c *gin.Context) {
  352. ctx := c.Request.Context() // Get context
  353. id, err := parseUintID(c, "id")
  354. if err != nil {
  355. models.RespondWithError(c, err)
  356. return
  357. }
  358. app, err := h.store.GetAppByID(ctx, id) // Pass context
  359. if err != nil {
  360. models.RespondWithError(c, err)
  361. return
  362. }
  363. if app == nil {
  364. models.RespondWithError(c, models.NewErrNotFound(fmt.Sprintf("App with ID %d not found", id), nil))
  365. return
  366. }
  367. h.entry.WithField("app_id", app.ID).Info("Creating manual preview for app")
  368. go h.createAppPreviewAsync(context.Background(), app.ID) // Use background context for async operation
  369. c.JSON(http.StatusOK, gin.H{"message": "Preview creation started"})
  370. }
  371. // GetAppPreview returns the preview for a specific app
  372. func (h *AppsHandler) GetAppPreview(c *gin.Context) {
  373. ctx := c.Request.Context() // Get context
  374. id, err := parseUintID(c, "id")
  375. if err != nil {
  376. models.RespondWithError(c, err)
  377. return
  378. }
  379. app, err := h.store.GetAppByID(ctx, id) // Pass context
  380. if err != nil {
  381. models.RespondWithError(c, err)
  382. return
  383. }
  384. if app == nil {
  385. models.RespondWithError(c, models.NewErrNotFound(fmt.Sprintf("App with ID %d not found", id), nil))
  386. return
  387. }
  388. preview, err := h.store.GetPreviewByAppID(ctx, app.ID) // Pass context
  389. if err != nil {
  390. models.RespondWithError(c, err)
  391. return
  392. }
  393. if preview == nil {
  394. models.RespondWithError(c, models.NewErrNotFound(fmt.Sprintf("Preview for app ID %d not found", id), nil))
  395. return
  396. }
  397. c.JSON(http.StatusOK, preview)
  398. }
  399. // DeleteAppPreview deletes the preview for a specific app
  400. func (h *AppsHandler) DeleteAppPreview(c *gin.Context) {
  401. ctx := c.Request.Context() // Get context
  402. id, err := parseUintID(c, "id")
  403. if err != nil {
  404. models.RespondWithError(c, err)
  405. return
  406. }
  407. app, err := h.store.GetAppByID(ctx, id) // Pass context
  408. if err != nil {
  409. models.RespondWithError(c, err)
  410. return
  411. }
  412. if app == nil {
  413. models.RespondWithError(c, models.NewErrNotFound(fmt.Sprintf("App with ID %d not found", id), nil))
  414. return
  415. }
  416. if err := h.previewService.DeletePreview(ctx, app.ID); err != nil { // Pass context
  417. models.RespondWithError(c, err)
  418. return
  419. }
  420. c.JSON(http.StatusOK, gin.H{"message": "Preview deleted successfully"})
  421. }
  422. // ImportReview reviews a docker-compose file for importing an app
  423. func (h *AppsHandler) ImportReview(c *gin.Context) {
  424. ctx := c.Request.Context()
  425. var req models.AppImportRequest
  426. if err := c.ShouldBindJSON(&req); err != nil {
  427. h.entry.WithField("error", err).Error("Failed to bind JSON to AppImportRequest struct")
  428. models.RespondWithError(c, models.NewErrValidation("Invalid request body", nil, err))
  429. return
  430. }
  431. // Set default branch if not provided
  432. if req.Branch == "" {
  433. req.Branch = "main"
  434. }
  435. h.entry.Infof("Reviewing compose import from %s (branch: %s)", req.SourceURL, req.Branch)
  436. review, err := h.appImporter.ReviewComposeFile(ctx, req)
  437. if err != nil {
  438. h.entry.WithField("error", err).Error("Failed to review compose file")
  439. models.RespondWithError(c, models.NewErrInternalServer("Failed to review compose file", err))
  440. return
  441. }
  442. c.JSON(http.StatusOK, review)
  443. }
  444. // ImportCreate creates an app and components from a docker-compose file
  445. func (h *AppsHandler) ImportCreate(c *gin.Context) {
  446. ctx := c.Request.Context()
  447. var req models.AppImportCreateRequest
  448. if err := c.ShouldBindJSON(&req); err != nil {
  449. h.entry.WithField("error", err).Error("Failed to bind JSON to AppImportCreateRequest struct")
  450. models.RespondWithError(c, models.NewErrValidation("Invalid request body", nil, err))
  451. return
  452. }
  453. // Set default branch if not provided
  454. if req.Branch == "" {
  455. req.Branch = "main"
  456. }
  457. // Get the user ID from the context (set by auth middleware)
  458. userIDInterface, exists := c.Get("user_id")
  459. if !exists {
  460. models.RespondWithError(c, models.NewErrUnauthorized("User ID not found in context", nil))
  461. return
  462. }
  463. // Convert user_id to uint - it might be a string from JWT
  464. var userID uint
  465. switch v := userIDInterface.(type) {
  466. case string:
  467. if parsedID, err := strconv.ParseUint(v, 10, 32); err == nil {
  468. userID = uint(parsedID)
  469. } else {
  470. h.entry.Warnf("Failed to parse user_id string '%s' to uint, defaulting to 1. Error: %v", v, err)
  471. userID = 1
  472. }
  473. case int:
  474. userID = uint(v)
  475. case int64:
  476. userID = uint(v)
  477. case uint:
  478. userID = v
  479. default:
  480. h.entry.Warnf("User_id in context is of unexpected type %T, defaulting to 1.", v)
  481. userID = 1
  482. }
  483. h.entry.Infof("Creating app '%s' from compose import for user %d", req.ConfirmedAppName, userID)
  484. app, err := h.appImporter.CreateAppFromCompose(ctx, req, userID)
  485. if err != nil {
  486. h.entry.WithField("error", err).Error("Failed to create app from compose")
  487. models.RespondWithError(c, models.NewErrInternalServer("Failed to create app from compose", err))
  488. return
  489. }
  490. c.JSON(http.StatusCreated, app)
  491. }
  492. // parseUintID parses a string ID parameter to uint
  493. func parseUintID(c *gin.Context, paramName string) (uint, error) {
  494. idStr := c.Param(paramName)
  495. parsedID, err := strconv.ParseUint(idStr, 10, 32)
  496. if err != nil {
  497. return 0, models.NewErrValidation(fmt.Sprintf("Invalid %s ID format", paramName), nil, err)
  498. }
  499. return uint(parsedID), nil
  500. }