remote_preview.go 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584
  1. package services
  2. import (
  3. "context"
  4. "fmt"
  5. "os"
  6. "os/exec"
  7. "strings"
  8. "sync"
  9. "time"
  10. "git.linuxforward.com/byop/byop-engine/clients"
  11. "git.linuxforward.com/byop/byop-engine/cloud"
  12. "git.linuxforward.com/byop/byop-engine/config"
  13. "git.linuxforward.com/byop/byop-engine/dbstore"
  14. "git.linuxforward.com/byop/byop-engine/models"
  15. "github.com/sirupsen/logrus"
  16. )
  17. // RemotePreviewService handles remote VPS preview deployments
  18. type RemotePreviewService struct {
  19. common *PreviewCommon
  20. entry *logrus.Entry
  21. ovhProvider cloud.Provider
  22. config *config.Config
  23. }
  24. // NewRemotePreviewService creates a new RemotePreviewService
  25. func NewRemotePreviewService(store *dbstore.SQLiteStore, ovhProvider cloud.Provider, cfg *config.Config, registryClient clients.RegistryClient, registryURL, registryUser, registryPass string) *RemotePreviewService {
  26. return &RemotePreviewService{
  27. common: NewPreviewCommon(store, registryClient, registryURL, registryUser, registryPass),
  28. entry: logrus.WithField("service", "RemotePreviewService"),
  29. ovhProvider: ovhProvider,
  30. config: cfg,
  31. }
  32. }
  33. // Close cleans up resources
  34. func (rps *RemotePreviewService) Close(ctx context.Context) {
  35. rps.entry.Info("Cleaning up remote preview service...")
  36. rps.cleanupAllPreviewVPS(ctx)
  37. rps.common.Close()
  38. }
  39. // cleanupAllPreviewVPS cleans up all preview VPS instances on server shutdown
  40. func (rps *RemotePreviewService) cleanupAllPreviewVPS(ctx context.Context) {
  41. rps.entry.Info("Starting cleanup of all preview VPS instances")
  42. // Get all VPS instances
  43. instances, err := rps.ovhProvider.ListInstances(ctx)
  44. if err != nil {
  45. rps.entry.WithError(err).Error("Failed to list VPS instances during cleanup")
  46. return
  47. }
  48. var wg sync.WaitGroup
  49. cleanupSemaphore := make(chan struct{}, 3) // Limit concurrent cleanup operations
  50. for _, instance := range instances {
  51. // Only clean up preview VPS instances
  52. if strings.Contains(instance.Name, "preview.byop.fr") {
  53. wg.Add(1)
  54. go func(inst cloud.Instance) {
  55. defer wg.Done()
  56. cleanupSemaphore <- struct{}{} // Acquire semaphore
  57. defer func() { <-cleanupSemaphore }() // Release semaphore
  58. taskCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
  59. defer cancel()
  60. rps.entry.WithField("vps_id", inst.ID).WithField("vps_name", inst.Name).Info("Cleaning up preview VPS")
  61. // Stop all containers and clean up Docker images
  62. if inst.IPAddress != "" {
  63. rps.cleanupVPSResources(taskCtx, inst.IPAddress, inst.ID)
  64. }
  65. // Reset/destroy the VPS instance
  66. if err := rps.ovhProvider.ResetInstance(taskCtx, inst.ID); err != nil {
  67. rps.entry.WithField("vps_id", inst.ID).WithError(err).Error("Failed to reset preview VPS")
  68. } else {
  69. rps.entry.WithField("vps_id", inst.ID).Info("Successfully reset preview VPS")
  70. }
  71. }(instance)
  72. }
  73. }
  74. // Wait for all cleanup operations to complete with a timeout
  75. done := make(chan struct{})
  76. go func() {
  77. wg.Wait()
  78. close(done)
  79. }()
  80. select {
  81. case <-done:
  82. rps.entry.Info("Successfully completed cleanup of all preview VPS instances")
  83. case <-time.After(60 * time.Second):
  84. rps.entry.Warn("Timeout waiting for preview VPS cleanup to complete")
  85. }
  86. }
  87. // cleanupVPSResources cleans up all Docker resources on a VPS
  88. func (rps *RemotePreviewService) cleanupVPSResources(ctx context.Context, ipAddress, vpsID string) {
  89. rps.entry.WithField("vps_id", vpsID).WithField("ip_address", ipAddress).Info("Cleaning up Docker resources on VPS")
  90. // Stop all preview containers
  91. stopAllCmd := "docker ps -q --filter 'label=byop.preview=true' | xargs -r docker stop"
  92. if err := rps.common.executeSSHCommand(ctx, ipAddress, stopAllCmd); err != nil {
  93. rps.entry.WithField("vps_id", vpsID).WithError(err).Warn("Failed to stop preview containers")
  94. }
  95. // Remove all preview containers
  96. removeAllCmd := "docker ps -aq --filter 'label=byop.preview=true' | xargs -r docker rm -f"
  97. if err := rps.common.executeSSHCommand(ctx, ipAddress, removeAllCmd); err != nil {
  98. rps.entry.WithField("vps_id", vpsID).WithError(err).Warn("Failed to remove preview containers")
  99. }
  100. // Remove all BYOP preview images
  101. removeImagesCmd := "docker images --filter 'reference=byop-preview-*' -q | xargs -r docker rmi -f"
  102. if err := rps.common.executeSSHCommand(ctx, ipAddress, removeImagesCmd); err != nil {
  103. rps.entry.WithField("vps_id", vpsID).WithError(err).Warn("Failed to remove preview images")
  104. }
  105. // Clean up all project directories
  106. cleanupDirsCmd := "rm -rf /home/debian/preview-*"
  107. if err := rps.common.executeSSHCommand(ctx, ipAddress, cleanupDirsCmd); err != nil {
  108. rps.entry.WithField("vps_id", vpsID).WithError(err).Warn("Failed to clean up project directories")
  109. }
  110. // Clean up temporary tar files
  111. cleanupTarCmd := "rm -f /tmp/byop-preview-*.tar"
  112. if err := rps.common.executeSSHCommand(ctx, ipAddress, cleanupTarCmd); err != nil {
  113. rps.entry.WithField("vps_id", vpsID).WithError(err).Warn("Failed to clean up tar files")
  114. }
  115. // Final cleanup: remove dangling images and volumes
  116. pruneCmd := "docker system prune -af --volumes"
  117. if err := rps.common.executeSSHCommand(ctx, ipAddress, pruneCmd); err != nil {
  118. rps.entry.WithField("vps_id", vpsID).WithError(err).Warn("Failed to prune Docker system")
  119. }
  120. rps.entry.WithField("vps_id", vpsID).Info("Completed Docker resource cleanup on VPS")
  121. }
  122. // CreatePreview creates a remote preview environment on a VPS
  123. func (rps *RemotePreviewService) CreatePreview(ctx context.Context, appId int) (*models.Preview, error) {
  124. // Get app details
  125. app, err := rps.common.GetStore().GetAppByID(ctx, appId)
  126. if err != nil {
  127. return nil, fmt.Errorf("failed to get app by ID %d: %v", appId, err)
  128. }
  129. // Create preview record
  130. preview := models.Preview{
  131. AppID: app.ID,
  132. Status: "building",
  133. ExpiresAt: time.Now().Add(24 * time.Hour).Format(time.RFC3339), // 24h expiry
  134. }
  135. previewID, err := rps.common.GetStore().CreatePreview(ctx, &preview)
  136. if err != nil {
  137. return nil, fmt.Errorf("failed to create preview record: %v", err)
  138. }
  139. preview.ID = previewID
  140. // Start async build and deploy to VPS
  141. go rps.buildAndDeployPreview(ctx, preview, app)
  142. return &preview, nil
  143. }
  144. func (rps *RemotePreviewService) buildAndDeployPreview(ctx context.Context, preview models.Preview, app *models.App) {
  145. rps.entry.WithField("preview_id", preview.ID).Info("Starting remote preview build and deployment")
  146. // Get all components for the app
  147. rps.entry.WithField("preview_id", preview.ID).Info("Getting app components")
  148. components, err := rps.common.GetAppComponents(ctx, app)
  149. if err != nil {
  150. rps.entry.WithField("preview_id", preview.ID).Errorf("Failed to get app components: %v", err)
  151. rps.common.UpdatePreviewStatus(ctx, preview.ID, "failed", fmt.Sprintf("Failed to get app components: %v", err))
  152. rps.common.GetStore().UpdateAppStatus(ctx, app.ID, "failed", fmt.Sprintf("Preview creation failed: %v", err))
  153. return
  154. }
  155. rps.entry.WithField("preview_id", preview.ID).WithField("component_count", len(components)).Info("Successfully retrieved app components")
  156. // Step 1: Build Docker images locally
  157. rps.entry.WithField("preview_id", preview.ID).Info("Starting Docker image build phase")
  158. imageNames, buildLogs, err := rps.common.BuildComponentImages(ctx, components)
  159. if err != nil {
  160. rps.entry.WithField("preview_id", preview.ID).Errorf("Failed to build component images: %v", err)
  161. rps.common.UpdatePreviewStatus(ctx, preview.ID, "failed", fmt.Sprintf("Failed to build images: %v", err))
  162. rps.common.UpdatePreviewBuildLogs(ctx, preview.ID, buildLogs)
  163. rps.common.GetStore().UpdateAppStatus(ctx, app.ID, "failed", fmt.Sprintf("Preview build failed: %v", err))
  164. return
  165. }
  166. rps.entry.WithField("preview_id", preview.ID).WithField("image_count", len(imageNames)).Info("Docker image build phase completed successfully")
  167. rps.common.UpdatePreviewBuildLogs(ctx, preview.ID, buildLogs)
  168. // Step 2: Provision preview VPS
  169. rps.entry.WithField("preview_id", preview.ID).Info("Starting VPS provisioning phase")
  170. rps.common.UpdatePreviewStatus(ctx, preview.ID, "deploying", "")
  171. vps, err := rps.findAvailablePreviewVPS(ctx)
  172. if err != nil {
  173. rps.entry.WithField("preview_id", preview.ID).Errorf("Failed to find available VPS: %v", err)
  174. rps.common.UpdatePreviewStatus(ctx, preview.ID, "failed", fmt.Sprintf("Failed to find available VPS: %v", err))
  175. rps.common.GetStore().UpdateAppStatus(ctx, app.ID, "failed", fmt.Sprintf("Preview VPS provisioning failed: %v", err))
  176. return
  177. }
  178. vpsID := vps.ID
  179. ipAddress := vps.IPAddress
  180. rps.entry.WithField("preview_id", preview.ID).WithField("vps_id", vpsID).WithField("ip_address", ipAddress).Info("VPS provisioning completed")
  181. // Generate preview URL with UUID
  182. previewUUID := rps.common.GeneratePreviewID()
  183. previewTLD := rps.config.PreviewTLD
  184. previewURL := fmt.Sprintf("https://%s.%s", previewUUID, previewTLD)
  185. rps.entry.WithField("preview_id", preview.ID).WithField("preview_uuid", previewUUID).WithField("preview_url", previewURL).Info("Generated remote preview URL")
  186. // Update preview with VPS info
  187. if err := rps.common.GetStore().UpdatePreviewVPS(ctx, preview.ID, vpsID, ipAddress, previewURL); err != nil {
  188. rps.entry.WithField("preview_id", preview.ID).Errorf("Failed to update preview VPS info: %v", err)
  189. }
  190. // Step 3: Deploy to VPS
  191. rps.entry.WithField("preview_id", preview.ID).Info("Starting remote deployment phase")
  192. deployLogs, err := rps.deployToVPS(ctx, ipAddress, imageNames, app, preview.ID, previewUUID)
  193. if err != nil {
  194. rps.entry.WithField("preview_id", preview.ID).Errorf("Failed to deploy to VPS: %v", err)
  195. rps.common.UpdatePreviewStatus(ctx, preview.ID, "failed", fmt.Sprintf("Failed to deploy to VPS: %v", err))
  196. rps.common.UpdatePreviewDeployLogs(ctx, preview.ID, deployLogs)
  197. rps.common.GetStore().UpdateAppStatus(ctx, app.ID, "failed", fmt.Sprintf("Remote deployment failed: %v", err))
  198. return
  199. }
  200. rps.entry.WithField("preview_id", preview.ID).Info("Remote deployment completed successfully")
  201. rps.common.UpdatePreviewDeployLogs(ctx, preview.ID, deployLogs)
  202. rps.common.UpdatePreviewStatus(ctx, preview.ID, "running", "")
  203. // Update app status to ready with preview info
  204. rps.common.GetStore().UpdateAppPreview(ctx, app.ID, preview.ID, previewURL)
  205. rps.entry.WithField("preview_id", preview.ID).WithField("vps_id", vpsID).WithField("preview_url", previewURL).Info("Remote preview deployment completed successfully")
  206. }
  207. // findAvailablePreviewVPS finds an existing VPS that can accommodate more previews
  208. func (rps *RemotePreviewService) findAvailablePreviewVPS(ctx context.Context) (*cloud.Instance, error) {
  209. // Get all VPS instances
  210. instances, err := rps.ovhProvider.ListInstances(ctx)
  211. if err != nil {
  212. return nil, fmt.Errorf("failed to list VPS instances: %v", err)
  213. }
  214. // Count previews per VPS by checking all preview records
  215. vpsUsage := make(map[string]int)
  216. // Get all preview instances from database and count usage per VPS
  217. for _, instance := range instances {
  218. if strings.Contains(instance.Name, "preview.byop.fr") {
  219. vpsUsage[instance.ID] = 0 // Initialize to 0, will be updated below
  220. }
  221. }
  222. // Simple approach: look for any preview VPS that exists and return it
  223. maxPreviewsPerVPS := 5
  224. // Check if any existing VPS has capacity
  225. for _, instance := range instances {
  226. if strings.Contains(instance.Name, "preview.byop.fr") {
  227. currentCount := vpsUsage[instance.ID]
  228. if currentCount < maxPreviewsPerVPS {
  229. rps.entry.WithField("vps_id", instance.ID).WithField("current_previews", currentCount).Info("Found VPS with available capacity")
  230. return &instance, nil
  231. }
  232. }
  233. }
  234. return nil, fmt.Errorf("no available VPS with capacity found")
  235. }
  236. func (rps *RemotePreviewService) deployToVPS(ctx context.Context, ipAddress string, imageNames []string, app *models.App, previewID int, previewUUID string) (string, error) {
  237. var logs strings.Builder
  238. rps.entry.WithField("ip_address", ipAddress).WithField("app_name", app.Name).WithField("preview_id", previewID).Info("Starting deployment to VPS")
  239. // Generate docker-compose.yml for the preview
  240. rps.entry.Info("Generating docker-compose.yml for preview")
  241. composeContent, err := rps.generatePreviewDockerCompose(imageNames, app, previewID, previewUUID)
  242. if err != nil {
  243. rps.entry.WithError(err).Error("Failed to generate docker-compose.yml")
  244. return logs.String(), err
  245. }
  246. logs.WriteString("Generated docker-compose.yml\n")
  247. // Save images to tar files and transfer to VPS
  248. for _, imageName := range imageNames {
  249. tarFile := fmt.Sprintf("/tmp/%s.tar", strings.ReplaceAll(imageName, ":", "_"))
  250. // Save Docker image
  251. rps.entry.WithField("image_name", imageName).WithField("tar_file", tarFile).Info("Saving Docker image to tar file")
  252. cmd := exec.CommandContext(ctx, "docker", "save", "-o", tarFile, imageName)
  253. if err := cmd.Run(); err != nil {
  254. rps.entry.WithField("image_name", imageName).WithError(err).Error("Failed to save image to tar")
  255. logs.WriteString(fmt.Sprintf("Failed to save image %s: %v\n", imageName, err))
  256. return logs.String(), err
  257. }
  258. rps.entry.WithField("image_name", imageName).Info("Successfully saved image to tar")
  259. // Transfer to VPS
  260. rps.entry.WithField("image_name", imageName).WithField("ip_address", ipAddress).Info("Transferring image to VPS")
  261. if err := rps.transferFile(ctx, tarFile, fmt.Sprintf("%s:/tmp/", ipAddress)); err != nil {
  262. rps.entry.WithField("image_name", imageName).WithError(err).Error("Failed to transfer image to VPS")
  263. logs.WriteString(fmt.Sprintf("Failed to transfer image %s: %v\n", imageName, err))
  264. return logs.String(), err
  265. }
  266. rps.entry.WithField("image_name", imageName).Info("Successfully transferred image to VPS")
  267. // Load image on VPS
  268. loadCmd := fmt.Sprintf("docker load -i /tmp/%s.tar", strings.ReplaceAll(imageName, ":", "_"))
  269. rps.entry.WithField("image_name", imageName).WithField("command", loadCmd).Info("Loading image on VPS")
  270. if err := rps.common.executeSSHCommand(ctx, ipAddress, loadCmd); err != nil {
  271. rps.entry.WithField("image_name", imageName).WithError(err).Error("Failed to load image on VPS")
  272. logs.WriteString(fmt.Sprintf("Failed to load image %s on VPS: %v\n", imageName, err))
  273. return logs.String(), err
  274. }
  275. rps.entry.WithField("image_name", imageName).Info("Successfully loaded image on VPS")
  276. // Clean up local tar file
  277. os.Remove(tarFile)
  278. rps.entry.WithField("tar_file", tarFile).Info("Cleaned up local tar file")
  279. }
  280. // Create project-specific directory and transfer docker-compose.yml
  281. projectName := fmt.Sprintf("preview-%d", previewID)
  282. projectDir := fmt.Sprintf("/home/debian/%s", projectName)
  283. // Create project directory on VPS
  284. rps.entry.WithField("project_dir", projectDir).Info("Creating project directory on VPS")
  285. if err := rps.common.executeSSHCommand(ctx, ipAddress, fmt.Sprintf("mkdir -p %s", projectDir)); err != nil {
  286. rps.entry.WithError(err).Error("Failed to create project directory on VPS")
  287. return logs.String(), err
  288. }
  289. composeFile := "/tmp/docker-compose-preview.yml"
  290. rps.entry.WithField("compose_file", composeFile).Info("Writing docker-compose.yml to temporary file")
  291. if err := os.WriteFile(composeFile, []byte(composeContent), 0644); err != nil {
  292. rps.entry.WithError(err).Error("Failed to write docker-compose.yml")
  293. return logs.String(), err
  294. }
  295. rps.entry.WithField("ip_address", ipAddress).WithField("project_dir", projectDir).Info("Transferring docker-compose.yml to VPS")
  296. if err := rps.transferFile(ctx, composeFile, fmt.Sprintf("%s:%s/docker-compose.yml", ipAddress, projectDir)); err != nil {
  297. rps.entry.WithError(err).Error("Failed to transfer docker-compose.yml to VPS")
  298. logs.WriteString(fmt.Sprintf("Failed to transfer docker-compose.yml: %v\n", err))
  299. return logs.String(), err
  300. }
  301. rps.entry.Info("Successfully transferred docker-compose.yml to VPS")
  302. // Start services on VPS with project-specific naming
  303. rps.entry.WithField("ip_address", ipAddress).WithField("project_name", projectName).Info("Starting services on VPS with docker-compose")
  304. startCmd := fmt.Sprintf("cd %s && docker compose -p %s up -d", projectDir, projectName)
  305. if err := rps.common.executeSSHCommand(ctx, ipAddress, startCmd); err != nil {
  306. rps.entry.WithError(err).Error("Failed to start services on VPS")
  307. logs.WriteString(fmt.Sprintf("Failed to start services: %v\n", err))
  308. return logs.String(), err
  309. }
  310. rps.entry.Info("Successfully started services on VPS")
  311. // Validate DNS and certificate setup for debugging
  312. previewDomain := fmt.Sprintf("%s.%s", previewUUID, rps.config.PreviewTLD)
  313. rps.validateDNSAndCertificate(ctx, previewDomain, ipAddress)
  314. rps.entry.WithField("ip_address", ipAddress).WithField("project_name", projectName).Info("Remote preview deployment completed successfully")
  315. logs.WriteString("Remote preview deployment completed successfully\n")
  316. return logs.String(), nil
  317. }
  318. func (rps *RemotePreviewService) generatePreviewDockerCompose(imageNames []string, app *models.App, previewID int, previewUUID string) (string, error) {
  319. rps.entry.WithField("app_id", app.ID).WithField("preview_id", previewID).WithField("image_count", len(imageNames)).Info("Generating docker-compose content for remote deployment")
  320. compose := "services:\n"
  321. for i, imageName := range imageNames {
  322. serviceName := fmt.Sprintf("service-%d", i)
  323. compose += fmt.Sprintf(" %s:\n", serviceName)
  324. compose += fmt.Sprintf(" image: %s\n", imageName)
  325. compose += " restart: unless-stopped\n"
  326. compose += " environment:\n"
  327. compose += " - NODE_ENV=preview\n"
  328. compose += fmt.Sprintf(" - APP_NAME=%s\n", app.Name)
  329. // Add BYOP preview labels for tracking
  330. compose += " labels:\n"
  331. compose += " - \"byop.preview=true\"\n"
  332. compose += fmt.Sprintf(" - \"byop.app.id=%d\"\n", app.ID)
  333. compose += fmt.Sprintf(" - \"byop.app.name=%s\"\n", app.Name)
  334. compose += fmt.Sprintf(" - \"byop.preview.id=%d\"\n", previewID)
  335. // Add Traefik labels for the first service (main entry point)
  336. if i == 0 {
  337. previewDomain := fmt.Sprintf("%s.%s", previewUUID, rps.config.PreviewTLD)
  338. routerName := fmt.Sprintf("preview-%d-%s", previewID, previewUUID)
  339. compose += " - \"traefik.enable=true\"\n"
  340. compose += fmt.Sprintf(" - \"traefik.http.routers.%s.rule=Host(`%s`)\"\n", routerName, previewDomain)
  341. compose += fmt.Sprintf(" - \"traefik.http.routers.%s.entrypoints=websecure\"\n", routerName)
  342. compose += fmt.Sprintf(" - \"traefik.http.routers.%s.tls=true\"\n", routerName)
  343. compose += fmt.Sprintf(" - \"traefik.http.routers.%s.tls.certresolver=tlsresolver\"\n", routerName)
  344. compose += " - \"traefik.docker.network=traefik\"\n"
  345. }
  346. compose += " networks:\n"
  347. compose += " - traefik\n"
  348. compose += "\n"
  349. }
  350. // Add networks section
  351. compose += "networks:\n"
  352. compose += " traefik:\n"
  353. compose += " external: true\n"
  354. return compose, nil
  355. }
  356. // DeletePreview deletes a remote preview
  357. func (rps *RemotePreviewService) DeletePreview(ctx context.Context, appID int) error {
  358. // Get the preview to ensure it exists
  359. preview, err := rps.common.GetStore().GetPreviewByAppID(ctx, appID)
  360. if err != nil {
  361. return fmt.Errorf("failed to get preview by app ID %d: %v", appID, err)
  362. }
  363. if preview == nil {
  364. return fmt.Errorf("preview with app ID %d not found", appID)
  365. }
  366. rps.entry.WithField("preview_id", preview.ID).Info("Deleting remote preview")
  367. // Stop and remove containers on VPS using project-specific naming
  368. if preview.IPAddress != "" && preview.IPAddress != "127.0.0.1" {
  369. projectName := fmt.Sprintf("preview-%d", preview.ID)
  370. projectDir := fmt.Sprintf("/home/debian/%s", projectName)
  371. // Stop containers with project-specific naming
  372. stopCmd := fmt.Sprintf("cd %s && docker compose -p %s down --remove-orphans", projectDir, projectName)
  373. rps.common.executeSSHCommand(ctx, preview.IPAddress, stopCmd)
  374. // Remove project directory
  375. rmCmd := fmt.Sprintf("rm -rf %s", projectDir)
  376. rps.common.executeSSHCommand(ctx, preview.IPAddress, rmCmd)
  377. // Cleanup Docker images
  378. if err := rps.common.CleanupPreviewImagesForApp(ctx, appID, true, preview.IPAddress); err != nil {
  379. rps.entry.WithField("preview_id", preview.ID).WithError(err).Warn("Failed to clean up Docker images")
  380. }
  381. }
  382. // Don't remove the VPS instance - it might be hosting other previews
  383. // Only clean up if this is the last preview on the VPS
  384. if preview.VPSID != "" && !strings.Contains(preview.VPSID, "byop.local") {
  385. // Check if there are other active previews on this VPS
  386. otherPreviews, err := rps.getActivePreviewsOnVPS(ctx, preview.VPSID)
  387. if err != nil {
  388. rps.entry.WithField("vps_id", preview.VPSID).Warnf("Failed to check other previews on VPS: %v", err)
  389. } else if len(otherPreviews) <= 1 { // Only this preview remains
  390. if err := rps.ovhProvider.ResetInstance(ctx, preview.VPSID); err != nil {
  391. rps.entry.WithField("vps_id", preview.VPSID).Errorf("Failed to reset VPS instance: %v", err)
  392. }
  393. }
  394. }
  395. // Delete the preview record from the database
  396. if err := rps.common.GetStore().DeletePreview(ctx, preview.ID); err != nil {
  397. return fmt.Errorf("failed to delete preview from database: %v", err)
  398. }
  399. rps.entry.WithField("preview_id", preview.ID).Info("Successfully deleted remote preview")
  400. return nil
  401. }
  402. // StopPreview stops a remote preview
  403. func (rps *RemotePreviewService) StopPreview(ctx context.Context, previewID int) error {
  404. preview, err := rps.common.GetStore().GetPreviewByID(ctx, previewID)
  405. if err != nil {
  406. return err
  407. }
  408. if preview == nil {
  409. return fmt.Errorf("preview not found")
  410. }
  411. // Stop containers on VPS
  412. projectName := fmt.Sprintf("preview-%d", previewID)
  413. projectDir := fmt.Sprintf("/home/debian/%s", projectName)
  414. stopCmd := fmt.Sprintf("cd %s && docker compose -p %s down --remove-orphans", projectDir, projectName)
  415. rps.common.executeSSHCommand(ctx, preview.IPAddress, stopCmd)
  416. // Clean up Docker images before destroying VPS
  417. if err := rps.common.CleanupPreviewImagesForApp(ctx, preview.AppID, true, preview.IPAddress); err != nil {
  418. rps.entry.WithField("preview_id", previewID).WithError(err).Warn("Failed to clean up Docker images")
  419. }
  420. // Destroy the VPS
  421. if err := rps.ovhProvider.ResetInstance(ctx, preview.VPSID); err != nil {
  422. rps.entry.WithField("vps_id", preview.VPSID).Errorf("Failed to destroy preview VPS: %v", err)
  423. }
  424. return rps.common.GetStore().UpdatePreviewStatus(ctx, previewID, "stopped", "")
  425. }
  426. // getActivePreviewsOnVPS returns all active previews running on a specific VPS
  427. func (rps *RemotePreviewService) getActivePreviewsOnVPS(ctx context.Context, vpsID string) ([]*models.Preview, error) {
  428. apps, err := rps.common.GetStore().GetAllApps(ctx)
  429. if err != nil {
  430. return nil, fmt.Errorf("failed to get apps: %v", err)
  431. }
  432. var activePreviews []*models.Preview
  433. for _, app := range apps {
  434. previews, err := rps.common.GetStore().GetPreviewsByAppID(ctx, app.ID)
  435. if err != nil {
  436. continue
  437. }
  438. for _, preview := range previews {
  439. if preview.VPSID == vpsID && preview.Status == "running" {
  440. activePreviews = append(activePreviews, preview)
  441. }
  442. }
  443. }
  444. return activePreviews, nil
  445. }
  446. // Helper methods - using common SSH command execution
  447. func (rps *RemotePreviewService) transferFile(ctx context.Context, localPath, remotePath string) error {
  448. hostAndPath := strings.SplitN(remotePath, ":", 2)
  449. if len(hostAndPath) != 2 {
  450. return fmt.Errorf("invalid SCP destination format: %s", remotePath)
  451. }
  452. remoteHost := hostAndPath[0]
  453. remotePath = hostAndPath[1]
  454. cmd := exec.CommandContext(ctx, "scp", "-o", "StrictHostKeyChecking=no", localPath, fmt.Sprintf("debian@%s:%s", remoteHost, remotePath))
  455. output, err := cmd.CombinedOutput()
  456. if err != nil {
  457. rps.entry.WithField("local_path", localPath).WithField("remote_path", remotePath).WithField("output", string(output)).WithError(err).Error("SCP transfer failed")
  458. return err
  459. }
  460. rps.entry.WithField("local_path", localPath).WithField("remote_path", remotePath).Info("Successfully transferred file")
  461. return nil
  462. }
  463. // validateDNSAndCertificate validates DNS resolution and Traefik certificate for debugging
  464. func (rps *RemotePreviewService) validateDNSAndCertificate(ctx context.Context, previewDomain, ipAddress string) {
  465. rps.entry.WithField("domain", previewDomain).WithField("ip_address", ipAddress).Info("Validating DNS and certificate setup")
  466. // Check DNS resolution from the VPS
  467. dnsCmd := fmt.Sprintf("nslookup %s", previewDomain)
  468. if err := rps.common.executeSSHCommand(ctx, ipAddress, dnsCmd); err != nil {
  469. rps.entry.WithField("domain", previewDomain).WithError(err).Warn("DNS resolution failed from VPS")
  470. }
  471. // Check if Traefik can see the service
  472. traefikCmd := "docker ps --filter 'label=traefik.enable=true' --format 'table {{.Names}}\t{{.Labels}}'"
  473. if err := rps.common.executeSSHCommand(ctx, ipAddress, traefikCmd); err != nil {
  474. rps.entry.WithError(err).Warn("Failed to check Traefik-enabled containers")
  475. }
  476. // Check Traefik logs for certificate issues
  477. logsCmd := "docker logs traefik --tail 20"
  478. if err := rps.common.executeSSHCommand(ctx, ipAddress, logsCmd); err != nil {
  479. rps.entry.WithError(err).Warn("Failed to get Traefik logs")
  480. }
  481. // Wait a bit for DNS propagation and certificate generation
  482. rps.entry.WithField("domain", previewDomain).Info("Waiting 30 seconds for DNS propagation and certificate generation")
  483. time.Sleep(30 * time.Second)
  484. }