local_preview.go 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427
  1. package services
  2. import (
  3. "context"
  4. "fmt"
  5. "os"
  6. "os/exec"
  7. "path/filepath"
  8. "strings"
  9. "time"
  10. "git.linuxforward.com/byop/byop-engine/clients"
  11. "git.linuxforward.com/byop/byop-engine/config"
  12. "git.linuxforward.com/byop/byop-engine/dbstore"
  13. "git.linuxforward.com/byop/byop-engine/models"
  14. "github.com/sirupsen/logrus"
  15. )
  16. // vpsID and ipAddress are used for local previews
  17. const vpsID = "byop.local"
  18. const ipAddress = "127.0.0.1"
  19. // LocalPreviewService handles local preview deployments using Docker Compose
  20. //
  21. // IMPORTANT: This service is intended for development and testing purposes only.
  22. // For production environments, use the RemotePreviewService which deploys to VPS instances
  23. // for proper isolation, security, and scalability.
  24. //
  25. // Local previews use:
  26. // - Docker Compose for container orchestration
  27. // - Local Traefik instance for routing
  28. // - Host Docker daemon (shared with development environment)
  29. // - localhost/127.0.0.1 networking
  30. type LocalPreviewService struct {
  31. common *PreviewCommon
  32. entry *logrus.Entry
  33. config *config.Config
  34. }
  35. // NewLocalPreviewService creates a new LocalPreviewService
  36. func NewLocalPreviewService(store *dbstore.SQLiteStore, cfg *config.Config, registryClient clients.RegistryClient, registryURL, registryUser, registryPass string) *LocalPreviewService {
  37. entry := logrus.WithField("service", "LocalPreviewService")
  38. entry.Warn("LocalPreviewService initialized - this is for development/testing only, not for production use")
  39. return &LocalPreviewService{
  40. common: NewPreviewCommon(store, registryURL, registryUser, registryPass),
  41. entry: entry,
  42. config: cfg,
  43. }
  44. }
  45. // Close cleans up resources
  46. func (lps *LocalPreviewService) Close(ctx context.Context) {
  47. lps.entry.Info("Cleaning up local preview service...")
  48. lps.common.CleanupAllPreviewContainers(ctx)
  49. lps.common.Close()
  50. }
  51. // CreatePreview creates a local preview environment
  52. func (lps *LocalPreviewService) CreatePreview(ctx context.Context, appId uint) (*models.Preview, error) {
  53. // Get app details
  54. app, err := lps.common.GetStore().GetAppByID(ctx, appId)
  55. if err != nil {
  56. if models.IsErrNotFound(err) {
  57. return nil, models.NewErrNotFound(fmt.Sprintf("app with ID %d not found for preview creation", appId), err)
  58. }
  59. return nil, models.NewErrInternalServer(fmt.Sprintf("failed to get app by ID %d", appId), err)
  60. }
  61. if app == nil {
  62. return nil, models.NewErrNotFound(fmt.Sprintf("app with ID %d not found (unexpected nil)", appId), nil)
  63. }
  64. // Create preview record
  65. preview := models.Preview{
  66. AppID: app.ID,
  67. Status: models.PreviewStatusBuilding,
  68. ExpiresAt: time.Now().Add(24 * time.Hour),
  69. }
  70. err = lps.common.GetStore().CreatePreview(ctx, &preview)
  71. if err != nil {
  72. if _, ok := err.(models.CustomError); !ok {
  73. return nil, models.NewErrInternalServer("failed to create preview record in db", err)
  74. }
  75. return nil, err
  76. }
  77. // Start async build and deploy locally
  78. go lps.buildAndDeployPreview(context.Background(), preview, app)
  79. return &preview, nil
  80. }
  81. func (lps *LocalPreviewService) buildAndDeployPreview(ctx context.Context, preview models.Preview, app *models.App) {
  82. lps.entry.WithField("preview_id", preview.ID).Info("Starting local preview build and deployment")
  83. // Get all components for the app
  84. lps.entry.WithField("preview_id", preview.ID).Info("Getting app components")
  85. components, err := lps.common.GetAppComponents(ctx, app)
  86. if err != nil {
  87. lps.entry.WithField("preview_id", preview.ID).Errorf("Failed to get app components: %v", err)
  88. lps.common.UpdatePreviewStatus(ctx, preview.ID, models.PreviewStatusFailed, fmt.Sprintf("Failed to get app components: %v", err))
  89. lps.common.GetStore().UpdateAppStatus(ctx, app.ID, models.AppStatusFailed, fmt.Sprintf("Preview creation failed: %v", err))
  90. return
  91. }
  92. lps.entry.WithField("preview_id", preview.ID).WithField("component_count", len(components)).Info("Successfully retrieved app components")
  93. // Step 1: Build Docker images locally
  94. lps.entry.WithField("preview_id", preview.ID).Info("Starting Docker image build phase")
  95. imageNames, buildLogs, err := lps.common.BuildComponentImages(ctx, components)
  96. if err != nil {
  97. lps.entry.WithField("preview_id", preview.ID).Errorf("Failed to build component images: %v", err)
  98. lps.common.UpdatePreviewStatus(ctx, preview.ID, models.PreviewStatusFailed, fmt.Sprintf("Failed to build images: %v", err))
  99. lps.common.UpdatePreviewBuildLogs(ctx, preview.ID, buildLogs)
  100. lps.common.GetStore().UpdateAppStatus(ctx, app.ID, models.AppStatusFailed, fmt.Sprintf("Preview build failed: %v", err))
  101. return
  102. }
  103. lps.entry.WithField("preview_id", preview.ID).WithField("image_count", len(imageNames)).Info("Docker image build phase completed successfully")
  104. lps.common.UpdatePreviewBuildLogs(ctx, preview.ID, buildLogs)
  105. // Step 2: Local deployment setup
  106. lps.entry.WithField("preview_id", preview.ID).Info("Starting local deployment phase")
  107. lps.common.UpdatePreviewStatus(ctx, preview.ID, models.PreviewStatusDeploying, "")
  108. // Generate unique preview ID and URL
  109. previewIDStr := lps.common.GeneratePreviewID()
  110. previewURL := fmt.Sprintf("https://%s.%s", previewIDStr, lps.config.PreviewTLD)
  111. lps.entry.WithField("preview_id", preview.ID).WithField("preview_url", previewURL).WithField("uuid", previewIDStr).Info("Generated local preview URL")
  112. // Update preview with local info
  113. if err := lps.common.GetStore().UpdatePreviewVPS(ctx, preview.ID, vpsID, ipAddress, previewURL); err != nil {
  114. lps.entry.WithField("preview_id", preview.ID).Errorf("Failed to update preview info: %v", err)
  115. }
  116. // Step 3: Deploy locally
  117. lps.entry.WithField("preview_id", preview.ID).Info("Starting local container deployment")
  118. deployLogs, err := lps.deployLocally(ctx, imageNames, app, previewIDStr)
  119. if err != nil {
  120. lps.entry.WithField("preview_id", preview.ID).Errorf("Failed to deploy locally: %v", err)
  121. lps.common.UpdatePreviewStatus(ctx, preview.ID, models.PreviewStatusFailed, fmt.Sprintf("Failed to deploy locally: %v", err))
  122. lps.common.UpdatePreviewDeployLogs(ctx, preview.ID, deployLogs)
  123. lps.common.GetStore().UpdateAppStatus(ctx, app.ID, models.AppStatusFailed, fmt.Sprintf("Local deployment failed: %v", err))
  124. return
  125. }
  126. lps.entry.WithField("preview_id", preview.ID).Info("Local deployment completed successfully")
  127. lps.common.UpdatePreviewDeployLogs(ctx, preview.ID, deployLogs)
  128. lps.common.UpdatePreviewStatus(ctx, preview.ID, models.PreviewStatusRunning, "")
  129. // Update app status to ready with preview info
  130. lps.common.GetStore().UpdateAppPreview(ctx, app.ID, preview.ID, previewURL)
  131. lps.entry.WithField("preview_id", preview.ID).WithField("preview_url", previewURL).Info("Local preview deployment completed successfully")
  132. }
  133. func (lps *LocalPreviewService) deployLocally(ctx context.Context, imageNames []string, app *models.App, previewIDStr string) (string, error) {
  134. var logs strings.Builder
  135. lps.entry.WithField("app_id", app.ID).WithField("app_name", app.Name).Info("Starting local deployment")
  136. logs.WriteString("Starting local deployment...\n")
  137. // Generate docker-compose content
  138. composeContent, err := lps.generatePreviewDockerCompose(ctx, imageNames, app, previewIDStr)
  139. if err != nil {
  140. lps.entry.WithField("app_id", app.ID).Errorf("Failed to generate compose file: %v", err)
  141. if _, ok := err.(models.CustomError); !ok {
  142. err = models.NewErrInternalServer("failed to generate compose file", err)
  143. }
  144. return logs.String(), err
  145. }
  146. // Save docker-compose.yml locally (temp file for execution)
  147. composeFile := filepath.Join(os.TempDir(), fmt.Sprintf("docker-compose-preview-%s.yml", app.Name))
  148. // Also save to a persistent debug location
  149. debugDir := "/tmp/byop-debug"
  150. if err := os.MkdirAll(debugDir, 0755); err != nil {
  151. lps.entry.WithField("app_id", app.ID).Warnf("Failed to create debug directory: %v", err)
  152. }
  153. debugComposeFile := filepath.Join(debugDir, fmt.Sprintf("docker-compose-app-%d-preview-%d.yml", app.ID, time.Now().Unix()))
  154. // Write the temporary file
  155. if err := os.WriteFile(composeFile, []byte(composeContent), 0644); err != nil {
  156. lps.entry.WithField("app_id", app.ID).Errorf("Failed to write compose file: %v", err)
  157. return logs.String(), models.NewErrInternalServer(fmt.Sprintf("failed to write compose file %s", composeFile), err)
  158. }
  159. defer os.Remove(composeFile)
  160. // Write the debug file (persistent)
  161. if err := os.WriteFile(debugComposeFile, []byte(composeContent), 0644); err != nil {
  162. lps.entry.WithField("app_id", app.ID).Warnf("Failed to write debug compose file: %v", err)
  163. } else {
  164. lps.entry.WithField("app_id", app.ID).WithField("debug_file", debugComposeFile).Info("Wrote debug compose file for inspection")
  165. logs.WriteString(fmt.Sprintf("Debug compose file saved to: %s\n", debugComposeFile))
  166. }
  167. logs.WriteString(fmt.Sprintf("Generated compose file: %s\n", composeFile))
  168. logs.WriteString(fmt.Sprintf("Compose content:\n%s\n", composeContent))
  169. // Check if Traefik network exists, create if it doesn't
  170. lps.entry.WithField("app_id", app.ID).Info("Checking/creating Traefik network")
  171. cmdCtx, cancelCmd := context.WithTimeout(ctx, 15*time.Second)
  172. defer cancelCmd()
  173. cmd := exec.CommandContext(cmdCtx, "docker", "network", "create", "traefik")
  174. if err := cmd.Run(); err != nil {
  175. lps.entry.WithField("app_id", app.ID).Warnf("Failed to create traefik network (may already exist): %v", err)
  176. logs.WriteString(fmt.Sprintf("Network creation output: %v (this is normal if network exists)\n", err))
  177. } else {
  178. lps.entry.WithField("app_id", app.ID).Info("Created traefik network")
  179. logs.WriteString("Created traefik network\n")
  180. }
  181. // Start containers using docker-compose
  182. lps.entry.WithField("app_id", app.ID).WithField("compose_file", composeFile).Info("Starting containers with docker-compose")
  183. cmdCtxComposeUp, cancelComposeUp := context.WithTimeout(ctx, 2*time.Minute)
  184. defer cancelComposeUp()
  185. cmd = exec.CommandContext(cmdCtxComposeUp, "docker-compose", "-f", composeFile, "up", "-d")
  186. cmd.Dir = os.TempDir()
  187. output, err := cmd.CombinedOutput()
  188. logs.WriteString(fmt.Sprintf("Docker-compose output:\n%s\n", string(output)))
  189. if err != nil {
  190. lps.entry.WithField("app_id", app.ID).Errorf("Failed to start containers: %v", err)
  191. lps.entry.WithField("app_id", app.ID).Errorf("Docker-compose error output: %s", string(output))
  192. logs.WriteString(fmt.Sprintf("ERROR: Docker-compose failed with: %v\n", err))
  193. return logs.String(), models.NewErrInternalServer(fmt.Sprintf("docker-compose up failed for app %d", app.ID), err)
  194. }
  195. lps.entry.WithField("app_id", app.ID).Info("Successfully started containers")
  196. // Verify containers are running
  197. cmdCtxPs, cancelPs := context.WithTimeout(ctx, 30*time.Second)
  198. defer cancelPs()
  199. cmd = exec.CommandContext(cmdCtxPs, "docker-compose", "-f", composeFile, "ps")
  200. output, err = cmd.CombinedOutput()
  201. if err != nil {
  202. lps.entry.WithField("app_id", app.ID).Warnf("Failed to check container status: %v", err)
  203. logs.WriteString(fmt.Sprintf("Warning: failed to check container status: %v\n", err))
  204. } else {
  205. logs.WriteString(fmt.Sprintf("Container status:\n%s\n", string(output)))
  206. }
  207. logs.WriteString("Local deployment completed successfully\n")
  208. logs.WriteString(fmt.Sprintf("Debug compose file: %s\n", debugComposeFile))
  209. return logs.String(), nil
  210. }
  211. func (lps *LocalPreviewService) generatePreviewDockerCompose(ctx context.Context, imageNames []string, app *models.App, previewIDStr string) (string, error) {
  212. lps.entry.WithField("app_id", app.ID).WithField("image_count", len(imageNames)).Info("Generating docker-compose content")
  213. // Get app components to check if this is a docker-compose imported app
  214. components, err := lps.common.GetAppComponents(ctx, app)
  215. if err != nil {
  216. return "", fmt.Errorf("failed to get app components: %w", err)
  217. }
  218. // Check if this is a docker-compose imported app
  219. isDockerComposeApp := false
  220. for _, component := range components {
  221. if component.SourceType == "docker-compose" {
  222. isDockerComposeApp = true
  223. break
  224. }
  225. }
  226. if isDockerComposeApp {
  227. return lps.generateDockerComposeFromComponents(ctx, imageNames, components, app, previewIDStr)
  228. } else {
  229. return lps.generateGenericDockerCompose(ctx, imageNames, app, previewIDStr)
  230. }
  231. }
  232. // generateDockerComposeFromComponents generates a docker-compose file based on the original component configuration
  233. func (lps *LocalPreviewService) generateDockerComposeFromComponents(ctx context.Context, imageNames []string, components []models.Component, app *models.App, previewIDStr string) (string, error) {
  234. lps.entry.WithField("app_id", app.ID).Info("Generating docker-compose from component configuration")
  235. compose := "services:\n"
  236. for i, component := range components {
  237. if i >= len(imageNames) {
  238. continue // Skip if we don't have a corresponding image
  239. }
  240. imageName := imageNames[i]
  241. serviceName := component.ServiceName
  242. if serviceName == "" {
  243. serviceName = component.Name
  244. }
  245. compose += fmt.Sprintf(" %s:\n", serviceName)
  246. compose += fmt.Sprintf(" image: %s\n", imageName)
  247. compose += " restart: unless-stopped\n"
  248. // Add environment variables
  249. compose += " environment:\n"
  250. compose += " - NODE_ENV=preview\n"
  251. compose += fmt.Sprintf(" - APP_NAME=%s\n", app.Name)
  252. // Add labels for tracking
  253. compose += " labels:\n"
  254. compose += " - \"byop.preview=true\"\n"
  255. compose += fmt.Sprintf(" - \"byop.preview.id=%s\"\n", previewIDStr)
  256. compose += fmt.Sprintf(" - \"byop.app.id=%d\"\n", app.ID)
  257. compose += fmt.Sprintf(" - \"byop.app.name=%s\"\n", app.Name)
  258. // Add Traefik configuration for web components
  259. if component.Type == "web" {
  260. previewDomain := fmt.Sprintf("%s.%s", previewIDStr, lps.config.PreviewTLD)
  261. routerName := fmt.Sprintf("local-preview-%s-%s", previewIDStr, serviceName)
  262. compose += " - \"traefik.enable=true\"\n"
  263. compose += fmt.Sprintf(" - \"traefik.http.routers.%s.rule=Host(`%s`)\"\n", routerName, previewDomain)
  264. compose += fmt.Sprintf(" - \"traefik.http.routers.%s.entrypoints=websecure\"\n", routerName)
  265. compose += fmt.Sprintf(" - \"traefik.http.routers.%s.tls=true\"\n", routerName)
  266. compose += fmt.Sprintf(" - \"traefik.http.routers.%s.tls.certresolver=tlsresolver\"\n", routerName)
  267. compose += " - \"traefik.docker.network=traefik\"\n"
  268. }
  269. compose += " networks:\n"
  270. compose += " - traefik\n"
  271. compose += "\n"
  272. }
  273. compose += "networks:\n"
  274. compose += " traefik:\n"
  275. compose += " external: true\n"
  276. return compose, nil
  277. }
  278. // generateGenericDockerCompose generates a generic docker-compose file for non-docker-compose apps
  279. func (lps *LocalPreviewService) generateGenericDockerCompose(ctx context.Context, imageNames []string, app *models.App, previewIDStr string) (string, error) {
  280. compose := "services:\n"
  281. for i, imageName := range imageNames {
  282. serviceName := fmt.Sprintf("service-%d", i)
  283. compose += fmt.Sprintf(" %s:\n", serviceName)
  284. compose += fmt.Sprintf(" image: %s\n", imageName)
  285. compose += " restart: unless-stopped\n"
  286. compose += " environment:\n"
  287. compose += " - NODE_ENV=preview\n"
  288. compose += fmt.Sprintf(" - APP_NAME=%s\n", app.Name)
  289. compose += " labels:\n"
  290. compose += " - \"byop.preview=true\"\n"
  291. compose += fmt.Sprintf(" - \"byop.preview.id=%s\"\n", previewIDStr)
  292. compose += fmt.Sprintf(" - \"byop.app.id=%d\"\n", app.ID)
  293. compose += fmt.Sprintf(" - \"byop.app.name=%s\"\n", app.Name)
  294. if i == 0 {
  295. previewDomain := fmt.Sprintf("%s.%s", previewIDStr, lps.config.PreviewTLD)
  296. routerName := fmt.Sprintf("local-preview-%s", previewIDStr)
  297. compose += " - \"traefik.enable=true\"\n"
  298. compose += fmt.Sprintf(" - \"traefik.http.routers.%s.rule=Host(`%s`)\"\n", routerName, previewDomain)
  299. compose += fmt.Sprintf(" - \"traefik.http.routers.%s.entrypoints=websecure\"\n", routerName)
  300. compose += fmt.Sprintf(" - \"traefik.http.routers.%s.tls=true\"\n", routerName)
  301. compose += fmt.Sprintf(" - \"traefik.http.routers.%s.tls.certresolver=tlsresolver\"\n", routerName)
  302. compose += " - \"traefik.docker.network=traefik\"\n"
  303. }
  304. compose += " networks:\n"
  305. compose += " - traefik\n"
  306. compose += "\n"
  307. }
  308. compose += "networks:\n"
  309. compose += " traefik:\n"
  310. compose += " external: true\n"
  311. return compose, nil
  312. }
  313. // DeletePreview deletes a local preview
  314. func (lps *LocalPreviewService) DeletePreview(ctx context.Context, appID uint) error {
  315. preview, err := lps.common.GetStore().GetPreviewByAppID(ctx, appID)
  316. if err != nil {
  317. if models.IsErrNotFound(err) {
  318. return models.NewErrNotFound(fmt.Sprintf("preview for app ID %d not found for deletion", appID), err)
  319. }
  320. return models.NewErrInternalServer(fmt.Sprintf("failed to get preview by app ID %d for deletion", appID), err)
  321. }
  322. if preview == nil {
  323. return models.NewErrNotFound(fmt.Sprintf("preview with app ID %d not found for deletion (unexpected nil)", appID), nil)
  324. }
  325. lps.entry.WithField("preview_id", preview.ID).Info("Deleting local preview")
  326. lps.common.CleanupByAppID(ctx, appID)
  327. if err := lps.common.GetStore().DeletePreview(ctx, preview.ID); err != nil {
  328. if models.IsErrNotFound(err) {
  329. return models.NewErrNotFound(fmt.Sprintf("preview %d not found for deletion from DB", preview.ID), err)
  330. }
  331. return models.NewErrInternalServer(fmt.Sprintf("failed to delete preview %d from database", preview.ID), err)
  332. }
  333. lps.entry.WithField("preview_id", preview.ID).Info("Successfully deleted local preview")
  334. return nil
  335. }
  336. // StopPreview stops a local preview
  337. func (lps *LocalPreviewService) StopPreview(ctx context.Context, previewID uint) error {
  338. preview, err := lps.common.GetStore().GetPreviewByID(ctx, previewID)
  339. if err != nil {
  340. if models.IsErrNotFound(err) {
  341. return models.NewErrNotFound(fmt.Sprintf("preview with ID %d not found for stopping", previewID), err)
  342. }
  343. return models.NewErrInternalServer(fmt.Sprintf("failed to get preview by ID %d for stopping", previewID), err)
  344. }
  345. if preview == nil {
  346. return models.NewErrNotFound(fmt.Sprintf("preview with ID %d not found for stopping (unexpected nil)", previewID), nil)
  347. }
  348. lps.common.CleanupByAppID(ctx, preview.AppID)
  349. err = lps.common.GetStore().UpdatePreviewStatus(ctx, previewID, models.PreviewStatusStopped, "")
  350. if err != nil {
  351. if models.IsErrNotFound(err) {
  352. return models.NewErrNotFound(fmt.Sprintf("preview %d not found for status update to stopped", previewID), err)
  353. }
  354. return models.NewErrInternalServer(fmt.Sprintf("failed to update preview %d status to stopped", previewID), err)
  355. }
  356. return nil
  357. }