local_preview.go 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348
  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, registryClient, 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 int) (*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).Format(time.RFC3339),
  69. }
  70. previewID, 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. preview.ID = previewID
  78. // Start async build and deploy locally
  79. go lps.buildAndDeployPreview(context.Background(), preview, app)
  80. return &preview, nil
  81. }
  82. func (lps *LocalPreviewService) buildAndDeployPreview(ctx context.Context, preview models.Preview, app *models.App) {
  83. lps.entry.WithField("preview_id", preview.ID).Info("Starting local preview build and deployment")
  84. // Get all components for the app
  85. lps.entry.WithField("preview_id", preview.ID).Info("Getting app components")
  86. components, err := lps.common.GetAppComponents(ctx, app)
  87. if err != nil {
  88. lps.entry.WithField("preview_id", preview.ID).Errorf("Failed to get app components: %v", err)
  89. lps.common.UpdatePreviewStatus(ctx, preview.ID, models.PreviewStatusFailed, fmt.Sprintf("Failed to get app components: %v", err))
  90. lps.common.GetStore().UpdateAppStatus(ctx, app.ID, models.AppStatusFailed, fmt.Sprintf("Preview creation failed: %v", err))
  91. return
  92. }
  93. lps.entry.WithField("preview_id", preview.ID).WithField("component_count", len(components)).Info("Successfully retrieved app components")
  94. // Step 1: Build Docker images locally
  95. lps.entry.WithField("preview_id", preview.ID).Info("Starting Docker image build phase")
  96. imageNames, buildLogs, err := lps.common.BuildComponentImages(ctx, components)
  97. if err != nil {
  98. lps.entry.WithField("preview_id", preview.ID).Errorf("Failed to build component images: %v", err)
  99. lps.common.UpdatePreviewStatus(ctx, preview.ID, models.PreviewStatusFailed, fmt.Sprintf("Failed to build images: %v", err))
  100. lps.common.UpdatePreviewBuildLogs(ctx, preview.ID, buildLogs)
  101. lps.common.GetStore().UpdateAppStatus(ctx, app.ID, models.AppStatusFailed, fmt.Sprintf("Preview build failed: %v", err))
  102. return
  103. }
  104. lps.entry.WithField("preview_id", preview.ID).WithField("image_count", len(imageNames)).Info("Docker image build phase completed successfully")
  105. lps.common.UpdatePreviewBuildLogs(ctx, preview.ID, buildLogs)
  106. // Step 2: Local deployment setup
  107. lps.entry.WithField("preview_id", preview.ID).Info("Starting local deployment phase")
  108. lps.common.UpdatePreviewStatus(ctx, preview.ID, models.PreviewStatusDeploying, "")
  109. // Generate unique preview ID and URL
  110. previewIDStr := lps.common.GeneratePreviewID()
  111. previewURL := fmt.Sprintf("https://%s.%s", previewIDStr, lps.config.PreviewTLD)
  112. lps.entry.WithField("preview_id", preview.ID).WithField("preview_url", previewURL).WithField("uuid", previewIDStr).Info("Generated local preview URL")
  113. // Update preview with local info
  114. if err := lps.common.GetStore().UpdatePreviewVPS(ctx, preview.ID, vpsID, ipAddress, previewURL); err != nil {
  115. lps.entry.WithField("preview_id", preview.ID).Errorf("Failed to update preview info: %v", err)
  116. }
  117. // Step 3: Deploy locally
  118. lps.entry.WithField("preview_id", preview.ID).Info("Starting local container deployment")
  119. deployLogs, err := lps.deployLocally(ctx, imageNames, app, previewIDStr)
  120. if err != nil {
  121. lps.entry.WithField("preview_id", preview.ID).Errorf("Failed to deploy locally: %v", err)
  122. lps.common.UpdatePreviewStatus(ctx, preview.ID, models.PreviewStatusFailed, fmt.Sprintf("Failed to deploy locally: %v", err))
  123. lps.common.UpdatePreviewDeployLogs(ctx, preview.ID, deployLogs)
  124. lps.common.GetStore().UpdateAppStatus(ctx, app.ID, models.AppStatusFailed, fmt.Sprintf("Local deployment failed: %v", err))
  125. return
  126. }
  127. lps.entry.WithField("preview_id", preview.ID).Info("Local deployment completed successfully")
  128. lps.common.UpdatePreviewDeployLogs(ctx, preview.ID, deployLogs)
  129. lps.common.UpdatePreviewStatus(ctx, preview.ID, models.PreviewStatusRunning, "")
  130. // Update app status to ready with preview info
  131. lps.common.GetStore().UpdateAppPreview(ctx, app.ID, preview.ID, previewURL)
  132. lps.entry.WithField("preview_id", preview.ID).WithField("preview_url", previewURL).Info("Local preview deployment completed successfully")
  133. }
  134. func (lps *LocalPreviewService) deployLocally(ctx context.Context, imageNames []string, app *models.App, previewIDStr string) (string, error) {
  135. var logs strings.Builder
  136. lps.entry.WithField("app_id", app.ID).WithField("app_name", app.Name).Info("Starting local deployment")
  137. logs.WriteString("Starting local deployment...\n")
  138. // Generate docker-compose content
  139. composeContent, err := lps.generatePreviewDockerCompose(ctx, imageNames, app, previewIDStr)
  140. if err != nil {
  141. lps.entry.WithField("app_id", app.ID).Errorf("Failed to generate compose file: %v", err)
  142. if _, ok := err.(models.CustomError); !ok {
  143. err = models.NewErrInternalServer("failed to generate compose file", err)
  144. }
  145. return logs.String(), err
  146. }
  147. // Save docker-compose.yml locally (temp file for execution)
  148. composeFile := filepath.Join(os.TempDir(), fmt.Sprintf("docker-compose-preview-%s.yml", app.Name))
  149. // Also save to a persistent debug location
  150. debugDir := "/tmp/byop-debug"
  151. if err := os.MkdirAll(debugDir, 0755); err != nil {
  152. lps.entry.WithField("app_id", app.ID).Warnf("Failed to create debug directory: %v", err)
  153. }
  154. debugComposeFile := filepath.Join(debugDir, fmt.Sprintf("docker-compose-app-%d-preview-%d.yml", app.ID, time.Now().Unix()))
  155. // Write the temporary file
  156. if err := os.WriteFile(composeFile, []byte(composeContent), 0644); err != nil {
  157. lps.entry.WithField("app_id", app.ID).Errorf("Failed to write compose file: %v", err)
  158. return logs.String(), models.NewErrInternalServer(fmt.Sprintf("failed to write compose file %s", composeFile), err)
  159. }
  160. defer os.Remove(composeFile)
  161. // Write the debug file (persistent)
  162. if err := os.WriteFile(debugComposeFile, []byte(composeContent), 0644); err != nil {
  163. lps.entry.WithField("app_id", app.ID).Warnf("Failed to write debug compose file: %v", err)
  164. } else {
  165. lps.entry.WithField("app_id", app.ID).WithField("debug_file", debugComposeFile).Info("Wrote debug compose file for inspection")
  166. logs.WriteString(fmt.Sprintf("Debug compose file saved to: %s\n", debugComposeFile))
  167. }
  168. logs.WriteString(fmt.Sprintf("Generated compose file: %s\n", composeFile))
  169. logs.WriteString(fmt.Sprintf("Compose content:\n%s\n", composeContent))
  170. // Check if Traefik network exists, create if it doesn't
  171. lps.entry.WithField("app_id", app.ID).Info("Checking/creating Traefik network")
  172. cmdCtx, cancelCmd := context.WithTimeout(ctx, 15*time.Second)
  173. defer cancelCmd()
  174. cmd := exec.CommandContext(cmdCtx, "docker", "network", "create", "traefik")
  175. if err := cmd.Run(); err != nil {
  176. lps.entry.WithField("app_id", app.ID).Warnf("Failed to create traefik network (may already exist): %v", err)
  177. logs.WriteString(fmt.Sprintf("Network creation output: %v (this is normal if network exists)\n", err))
  178. } else {
  179. lps.entry.WithField("app_id", app.ID).Info("Created traefik network")
  180. logs.WriteString("Created traefik network\n")
  181. }
  182. // Start containers using docker-compose
  183. lps.entry.WithField("app_id", app.ID).WithField("compose_file", composeFile).Info("Starting containers with docker-compose")
  184. cmdCtxComposeUp, cancelComposeUp := context.WithTimeout(ctx, 2*time.Minute)
  185. defer cancelComposeUp()
  186. cmd = exec.CommandContext(cmdCtxComposeUp, "docker-compose", "-f", composeFile, "up", "-d")
  187. cmd.Dir = os.TempDir()
  188. output, err := cmd.CombinedOutput()
  189. logs.WriteString(fmt.Sprintf("Docker-compose output:\n%s\n", string(output)))
  190. if err != nil {
  191. lps.entry.WithField("app_id", app.ID).Errorf("Failed to start containers: %v", err)
  192. lps.entry.WithField("app_id", app.ID).Errorf("Docker-compose error output: %s", string(output))
  193. logs.WriteString(fmt.Sprintf("ERROR: Docker-compose failed with: %v\n", err))
  194. return logs.String(), models.NewErrInternalServer(fmt.Sprintf("docker-compose up failed for app %d", app.ID), err)
  195. }
  196. lps.entry.WithField("app_id", app.ID).Info("Successfully started containers")
  197. // Verify containers are running
  198. cmdCtxPs, cancelPs := context.WithTimeout(ctx, 30*time.Second)
  199. defer cancelPs()
  200. cmd = exec.CommandContext(cmdCtxPs, "docker-compose", "-f", composeFile, "ps")
  201. output, err = cmd.CombinedOutput()
  202. if err != nil {
  203. lps.entry.WithField("app_id", app.ID).Warnf("Failed to check container status: %v", err)
  204. logs.WriteString(fmt.Sprintf("Warning: failed to check container status: %v\n", err))
  205. } else {
  206. logs.WriteString(fmt.Sprintf("Container status:\n%s\n", string(output)))
  207. }
  208. logs.WriteString("Local deployment completed successfully\n")
  209. logs.WriteString(fmt.Sprintf("Debug compose file: %s\n", debugComposeFile))
  210. return logs.String(), nil
  211. }
  212. func (lps *LocalPreviewService) generatePreviewDockerCompose(ctx context.Context, imageNames []string, app *models.App, previewIDStr string) (string, error) {
  213. lps.entry.WithField("app_id", app.ID).WithField("image_count", len(imageNames)).Info("Generating docker-compose content")
  214. compose := "services:\n"
  215. for i, imageName := range imageNames {
  216. serviceName := fmt.Sprintf("service-%d", i)
  217. compose += fmt.Sprintf(" %s:\n", serviceName)
  218. compose += fmt.Sprintf(" image: %s\n", imageName)
  219. compose += " restart: unless-stopped\n"
  220. compose += " environment:\n"
  221. compose += " - NODE_ENV=preview\n"
  222. compose += fmt.Sprintf(" - APP_NAME=%s\n", app.Name)
  223. compose += " labels:\n"
  224. compose += " - \"byop.preview=true\"\n"
  225. compose += fmt.Sprintf(" - \"byop.preview.id=%s\"\n", previewIDStr)
  226. compose += fmt.Sprintf(" - \"byop.app.id=%d\"\n", app.ID)
  227. compose += fmt.Sprintf(" - \"byop.app.name=%s\"\n", app.Name)
  228. if i == 0 {
  229. previewDomain := fmt.Sprintf("%s.%s", previewIDStr, lps.config.PreviewTLD)
  230. routerName := fmt.Sprintf("local-preview-%s", previewIDStr)
  231. compose += " - \"traefik.enable=true\"\n"
  232. compose += fmt.Sprintf(" - \"traefik.http.routers.%s.rule=Host(`%s`)\"\n", routerName, previewDomain)
  233. compose += fmt.Sprintf(" - \"traefik.http.routers.%s.entrypoints=websecure\"\n", routerName)
  234. compose += fmt.Sprintf(" - \"traefik.http.routers.%s.tls=true\"\n", routerName)
  235. compose += fmt.Sprintf(" - \"traefik.http.routers.%s.tls.certresolver=tlsresolver\"\n", routerName)
  236. compose += " - \"traefik.docker.network=traefik\"\n"
  237. }
  238. compose += " networks:\n"
  239. compose += " - traefik\n"
  240. compose += "\n"
  241. }
  242. compose += "networks:\n"
  243. compose += " traefik:\n"
  244. compose += " external: true\n"
  245. return compose, nil
  246. }
  247. // DeletePreview deletes a local preview
  248. func (lps *LocalPreviewService) DeletePreview(ctx context.Context, appID int) error {
  249. preview, err := lps.common.GetStore().GetPreviewByAppID(ctx, appID)
  250. if err != nil {
  251. if models.IsErrNotFound(err) {
  252. return models.NewErrNotFound(fmt.Sprintf("preview for app ID %d not found for deletion", appID), err)
  253. }
  254. return models.NewErrInternalServer(fmt.Sprintf("failed to get preview by app ID %d for deletion", appID), err)
  255. }
  256. if preview == nil {
  257. return models.NewErrNotFound(fmt.Sprintf("preview with app ID %d not found for deletion (unexpected nil)", appID), nil)
  258. }
  259. lps.entry.WithField("preview_id", preview.ID).Info("Deleting local preview")
  260. lps.common.CleanupByAppID(ctx, appID)
  261. if err := lps.common.GetStore().DeletePreview(ctx, preview.ID); err != nil {
  262. if models.IsErrNotFound(err) {
  263. return models.NewErrNotFound(fmt.Sprintf("preview %d not found for deletion from DB", preview.ID), err)
  264. }
  265. return models.NewErrInternalServer(fmt.Sprintf("failed to delete preview %d from database", preview.ID), err)
  266. }
  267. lps.entry.WithField("preview_id", preview.ID).Info("Successfully deleted local preview")
  268. return nil
  269. }
  270. // StopPreview stops a local preview
  271. func (lps *LocalPreviewService) StopPreview(ctx context.Context, previewID int) error {
  272. preview, err := lps.common.GetStore().GetPreviewByID(ctx, previewID)
  273. if err != nil {
  274. if models.IsErrNotFound(err) {
  275. return models.NewErrNotFound(fmt.Sprintf("preview with ID %d not found for stopping", previewID), err)
  276. }
  277. return models.NewErrInternalServer(fmt.Sprintf("failed to get preview by ID %d for stopping", previewID), err)
  278. }
  279. if preview == nil {
  280. return models.NewErrNotFound(fmt.Sprintf("preview with ID %d not found for stopping (unexpected nil)", previewID), nil)
  281. }
  282. lps.common.CleanupByAppID(ctx, preview.AppID)
  283. err = lps.common.GetStore().UpdatePreviewStatus(ctx, previewID, models.PreviewStatusStopped, "")
  284. if err != nil {
  285. if models.IsErrNotFound(err) {
  286. return models.NewErrNotFound(fmt.Sprintf("preview %d not found for status update to stopped", previewID), err)
  287. }
  288. return models.NewErrInternalServer(fmt.Sprintf("failed to update preview %d status to stopped", previewID), err)
  289. }
  290. return nil
  291. }