remote_preview.go 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582
  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, 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 uint) (*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), // 24h expiry
  134. }
  135. 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. // Start async build and deploy to VPS
  140. go rps.buildAndDeployPreview(ctx, preview, app)
  141. return &preview, nil
  142. }
  143. func (rps *RemotePreviewService) buildAndDeployPreview(ctx context.Context, preview models.Preview, app *models.App) {
  144. rps.entry.WithField("preview_id", preview.ID).Info("Starting remote preview build and deployment")
  145. // Get all components for the app
  146. rps.entry.WithField("preview_id", preview.ID).Info("Getting app components")
  147. components, err := rps.common.GetAppComponents(ctx, app)
  148. if err != nil {
  149. rps.entry.WithField("preview_id", preview.ID).Errorf("Failed to get app components: %v", err)
  150. rps.common.UpdatePreviewStatus(ctx, preview.ID, "failed", fmt.Sprintf("Failed to get app components: %v", err))
  151. rps.common.GetStore().UpdateAppStatus(ctx, app.ID, "failed", fmt.Sprintf("Preview creation failed: %v", err))
  152. return
  153. }
  154. rps.entry.WithField("preview_id", preview.ID).WithField("component_count", len(components)).Info("Successfully retrieved app components")
  155. // Step 1: Build Docker images locally
  156. rps.entry.WithField("preview_id", preview.ID).Info("Starting Docker image build phase")
  157. imageNames, buildLogs, err := rps.common.BuildComponentImages(ctx, components)
  158. if err != nil {
  159. rps.entry.WithField("preview_id", preview.ID).Errorf("Failed to build component images: %v", err)
  160. rps.common.UpdatePreviewStatus(ctx, preview.ID, "failed", fmt.Sprintf("Failed to build images: %v", err))
  161. rps.common.UpdatePreviewBuildLogs(ctx, preview.ID, buildLogs)
  162. rps.common.GetStore().UpdateAppStatus(ctx, app.ID, "failed", fmt.Sprintf("Preview build failed: %v", err))
  163. return
  164. }
  165. rps.entry.WithField("preview_id", preview.ID).WithField("image_count", len(imageNames)).Info("Docker image build phase completed successfully")
  166. rps.common.UpdatePreviewBuildLogs(ctx, preview.ID, buildLogs)
  167. // Step 2: Provision preview VPS
  168. rps.entry.WithField("preview_id", preview.ID).Info("Starting VPS provisioning phase")
  169. rps.common.UpdatePreviewStatus(ctx, preview.ID, "deploying", "")
  170. vps, err := rps.findAvailablePreviewVPS(ctx)
  171. if err != nil {
  172. rps.entry.WithField("preview_id", preview.ID).Errorf("Failed to find available VPS: %v", err)
  173. rps.common.UpdatePreviewStatus(ctx, preview.ID, "failed", fmt.Sprintf("Failed to find available VPS: %v", err))
  174. rps.common.GetStore().UpdateAppStatus(ctx, app.ID, "failed", fmt.Sprintf("Preview VPS provisioning failed: %v", err))
  175. return
  176. }
  177. vpsID := vps.ID
  178. ipAddress := vps.IPAddress
  179. rps.entry.WithField("preview_id", preview.ID).WithField("vps_id", vpsID).WithField("ip_address", ipAddress).Info("VPS provisioning completed")
  180. // Generate preview URL with UUID
  181. previewUUID := rps.common.GeneratePreviewID()
  182. previewTLD := rps.config.PreviewTLD
  183. previewURL := fmt.Sprintf("https://%s.%s", previewUUID, previewTLD)
  184. rps.entry.WithField("preview_id", preview.ID).WithField("preview_uuid", previewUUID).WithField("preview_url", previewURL).Info("Generated remote preview URL")
  185. // Update preview with VPS info
  186. if err := rps.common.GetStore().UpdatePreviewVPS(ctx, preview.ID, vpsID, ipAddress, previewURL); err != nil {
  187. rps.entry.WithField("preview_id", preview.ID).Errorf("Failed to update preview VPS info: %v", err)
  188. }
  189. // Step 3: Deploy to VPS
  190. rps.entry.WithField("preview_id", preview.ID).Info("Starting remote deployment phase")
  191. deployLogs, err := rps.deployToVPS(ctx, ipAddress, imageNames, app, preview.ID, previewUUID)
  192. if err != nil {
  193. rps.entry.WithField("preview_id", preview.ID).Errorf("Failed to deploy to VPS: %v", err)
  194. rps.common.UpdatePreviewStatus(ctx, preview.ID, "failed", fmt.Sprintf("Failed to deploy to VPS: %v", err))
  195. rps.common.UpdatePreviewDeployLogs(ctx, preview.ID, deployLogs)
  196. rps.common.GetStore().UpdateAppStatus(ctx, app.ID, "failed", fmt.Sprintf("Remote deployment failed: %v", err))
  197. return
  198. }
  199. rps.entry.WithField("preview_id", preview.ID).Info("Remote deployment completed successfully")
  200. rps.common.UpdatePreviewDeployLogs(ctx, preview.ID, deployLogs)
  201. rps.common.UpdatePreviewStatus(ctx, preview.ID, "running", "")
  202. // Update app status to ready with preview info
  203. rps.common.GetStore().UpdateAppPreview(ctx, app.ID, preview.ID, previewURL)
  204. rps.entry.WithField("preview_id", preview.ID).WithField("vps_id", vpsID).WithField("preview_url", previewURL).Info("Remote preview deployment completed successfully")
  205. }
  206. // findAvailablePreviewVPS finds an existing VPS that can accommodate more previews
  207. func (rps *RemotePreviewService) findAvailablePreviewVPS(ctx context.Context) (*cloud.Instance, error) {
  208. // Get all VPS instances
  209. instances, err := rps.ovhProvider.ListInstances(ctx)
  210. if err != nil {
  211. return nil, fmt.Errorf("failed to list VPS instances: %v", err)
  212. }
  213. // Count previews per VPS by checking all preview records
  214. vpsUsage := make(map[string]int)
  215. // Get all preview instances from database and count usage per VPS
  216. for _, instance := range instances {
  217. if strings.Contains(instance.Name, "preview.byop.fr") {
  218. vpsUsage[instance.ID] = 0 // Initialize to 0, will be updated below
  219. }
  220. }
  221. // Simple approach: look for any preview VPS that exists and return it
  222. maxPreviewsPerVPS := 5
  223. // Check if any existing VPS has capacity
  224. for _, instance := range instances {
  225. if strings.Contains(instance.Name, "preview.byop.fr") {
  226. currentCount := vpsUsage[instance.ID]
  227. if currentCount < maxPreviewsPerVPS {
  228. rps.entry.WithField("vps_id", instance.ID).WithField("current_previews", currentCount).Info("Found VPS with available capacity")
  229. return &instance, nil
  230. }
  231. }
  232. }
  233. return nil, fmt.Errorf("no available VPS with capacity found")
  234. }
  235. func (rps *RemotePreviewService) deployToVPS(ctx context.Context, ipAddress string, imageNames []string, app *models.App, previewID uint, previewUUID string) (string, error) {
  236. var logs strings.Builder
  237. rps.entry.WithField("ip_address", ipAddress).WithField("app_name", app.Name).WithField("preview_id", previewID).Info("Starting deployment to VPS")
  238. // Generate docker-compose.yml for the preview
  239. rps.entry.Info("Generating docker-compose.yml for preview")
  240. composeContent, err := rps.generatePreviewDockerCompose(imageNames, app, previewID, previewUUID)
  241. if err != nil {
  242. rps.entry.WithError(err).Error("Failed to generate docker-compose.yml")
  243. return logs.String(), err
  244. }
  245. logs.WriteString("Generated docker-compose.yml\n")
  246. // Save images to tar files and transfer to VPS
  247. for _, imageName := range imageNames {
  248. tarFile := fmt.Sprintf("/tmp/%s.tar", strings.ReplaceAll(imageName, ":", "_"))
  249. // Save Docker image
  250. rps.entry.WithField("image_name", imageName).WithField("tar_file", tarFile).Info("Saving Docker image to tar file")
  251. cmd := exec.CommandContext(ctx, "docker", "save", "-o", tarFile, imageName)
  252. if err := cmd.Run(); err != nil {
  253. rps.entry.WithField("image_name", imageName).WithError(err).Error("Failed to save image to tar")
  254. logs.WriteString(fmt.Sprintf("Failed to save image %s: %v\n", imageName, err))
  255. return logs.String(), err
  256. }
  257. rps.entry.WithField("image_name", imageName).Info("Successfully saved image to tar")
  258. // Transfer to VPS
  259. rps.entry.WithField("image_name", imageName).WithField("ip_address", ipAddress).Info("Transferring image to VPS")
  260. if err := rps.transferFile(ctx, tarFile, fmt.Sprintf("%s:/tmp/", ipAddress)); err != nil {
  261. rps.entry.WithField("image_name", imageName).WithError(err).Error("Failed to transfer image to VPS")
  262. logs.WriteString(fmt.Sprintf("Failed to transfer image %s: %v\n", imageName, err))
  263. return logs.String(), err
  264. }
  265. rps.entry.WithField("image_name", imageName).Info("Successfully transferred image to VPS")
  266. // Load image on VPS
  267. loadCmd := fmt.Sprintf("docker load -i /tmp/%s.tar", strings.ReplaceAll(imageName, ":", "_"))
  268. rps.entry.WithField("image_name", imageName).WithField("command", loadCmd).Info("Loading image on VPS")
  269. if err := rps.common.executeSSHCommand(ctx, ipAddress, loadCmd); err != nil {
  270. rps.entry.WithField("image_name", imageName).WithError(err).Error("Failed to load image on VPS")
  271. logs.WriteString(fmt.Sprintf("Failed to load image %s on VPS: %v\n", imageName, err))
  272. return logs.String(), err
  273. }
  274. rps.entry.WithField("image_name", imageName).Info("Successfully loaded image on VPS")
  275. // Clean up local tar file
  276. os.Remove(tarFile)
  277. rps.entry.WithField("tar_file", tarFile).Info("Cleaned up local tar file")
  278. }
  279. // Create project-specific directory and transfer docker-compose.yml
  280. projectName := fmt.Sprintf("preview-%d", previewID)
  281. projectDir := fmt.Sprintf("/home/debian/%s", projectName)
  282. // Create project directory on VPS
  283. rps.entry.WithField("project_dir", projectDir).Info("Creating project directory on VPS")
  284. if err := rps.common.executeSSHCommand(ctx, ipAddress, fmt.Sprintf("mkdir -p %s", projectDir)); err != nil {
  285. rps.entry.WithError(err).Error("Failed to create project directory on VPS")
  286. return logs.String(), err
  287. }
  288. composeFile := "/tmp/docker-compose-preview.yml"
  289. rps.entry.WithField("compose_file", composeFile).Info("Writing docker-compose.yml to temporary file")
  290. if err := os.WriteFile(composeFile, []byte(composeContent), 0644); err != nil {
  291. rps.entry.WithError(err).Error("Failed to write docker-compose.yml")
  292. return logs.String(), err
  293. }
  294. rps.entry.WithField("ip_address", ipAddress).WithField("project_dir", projectDir).Info("Transferring docker-compose.yml to VPS")
  295. if err := rps.transferFile(ctx, composeFile, fmt.Sprintf("%s:%s/docker-compose.yml", ipAddress, projectDir)); err != nil {
  296. rps.entry.WithError(err).Error("Failed to transfer docker-compose.yml to VPS")
  297. logs.WriteString(fmt.Sprintf("Failed to transfer docker-compose.yml: %v\n", err))
  298. return logs.String(), err
  299. }
  300. rps.entry.Info("Successfully transferred docker-compose.yml to VPS")
  301. // Start services on VPS with project-specific naming
  302. rps.entry.WithField("ip_address", ipAddress).WithField("project_name", projectName).Info("Starting services on VPS with docker-compose")
  303. startCmd := fmt.Sprintf("cd %s && docker compose -p %s up -d", projectDir, projectName)
  304. if err := rps.common.executeSSHCommand(ctx, ipAddress, startCmd); err != nil {
  305. rps.entry.WithError(err).Error("Failed to start services on VPS")
  306. logs.WriteString(fmt.Sprintf("Failed to start services: %v\n", err))
  307. return logs.String(), err
  308. }
  309. rps.entry.Info("Successfully started services on VPS")
  310. // Validate DNS and certificate setup for debugging
  311. previewDomain := fmt.Sprintf("%s.%s", previewUUID, rps.config.PreviewTLD)
  312. rps.validateDNSAndCertificate(ctx, previewDomain, ipAddress)
  313. rps.entry.WithField("ip_address", ipAddress).WithField("project_name", projectName).Info("Remote preview deployment completed successfully")
  314. logs.WriteString("Remote preview deployment completed successfully\n")
  315. return logs.String(), nil
  316. }
  317. func (rps *RemotePreviewService) generatePreviewDockerCompose(imageNames []string, app *models.App, previewID uint, previewUUID string) (string, error) {
  318. rps.entry.WithField("app_id", app.ID).WithField("preview_id", previewID).WithField("image_count", len(imageNames)).Info("Generating docker-compose content for remote deployment")
  319. compose := "services:\n"
  320. for i, imageName := range imageNames {
  321. serviceName := fmt.Sprintf("service-%d", i)
  322. compose += fmt.Sprintf(" %s:\n", serviceName)
  323. compose += fmt.Sprintf(" image: %s\n", imageName)
  324. compose += " restart: unless-stopped\n"
  325. compose += " environment:\n"
  326. compose += " - NODE_ENV=preview\n"
  327. compose += fmt.Sprintf(" - APP_NAME=%s\n", app.Name)
  328. // Add BYOP preview labels for tracking
  329. compose += " labels:\n"
  330. compose += " - \"byop.preview=true\"\n"
  331. compose += fmt.Sprintf(" - \"byop.app.id=%d\"\n", app.ID)
  332. compose += fmt.Sprintf(" - \"byop.app.name=%s\"\n", app.Name)
  333. compose += fmt.Sprintf(" - \"byop.preview.id=%d\"\n", previewID)
  334. // Add Traefik labels for the first service (main entry point)
  335. if i == 0 {
  336. previewDomain := fmt.Sprintf("%s.%s", previewUUID, rps.config.PreviewTLD)
  337. routerName := fmt.Sprintf("preview-%d-%s", previewID, previewUUID)
  338. compose += " - \"traefik.enable=true\"\n"
  339. compose += fmt.Sprintf(" - \"traefik.http.routers.%s.rule=Host(`%s`)\"\n", routerName, previewDomain)
  340. compose += fmt.Sprintf(" - \"traefik.http.routers.%s.entrypoints=websecure\"\n", routerName)
  341. compose += fmt.Sprintf(" - \"traefik.http.routers.%s.tls=true\"\n", routerName)
  342. compose += fmt.Sprintf(" - \"traefik.http.routers.%s.tls.certresolver=tlsresolver\"\n", routerName)
  343. compose += " - \"traefik.docker.network=traefik\"\n"
  344. }
  345. compose += " networks:\n"
  346. compose += " - traefik\n"
  347. compose += "\n"
  348. }
  349. // Add networks section
  350. compose += "networks:\n"
  351. compose += " traefik:\n"
  352. compose += " external: true\n"
  353. return compose, nil
  354. }
  355. // DeletePreview deletes a remote preview
  356. func (rps *RemotePreviewService) DeletePreview(ctx context.Context, appID uint) error {
  357. // Get the preview to ensure it exists
  358. preview, err := rps.common.GetStore().GetPreviewByAppID(ctx, appID)
  359. if err != nil {
  360. return fmt.Errorf("failed to get preview by app ID %d: %v", appID, err)
  361. }
  362. if preview == nil {
  363. return fmt.Errorf("preview with app ID %d not found", appID)
  364. }
  365. rps.entry.WithField("preview_id", preview.ID).Info("Deleting remote preview")
  366. // Stop and remove containers on VPS using project-specific naming
  367. if preview.IPAddress != "" && preview.IPAddress != "127.0.0.1" {
  368. projectName := fmt.Sprintf("preview-%d", preview.ID)
  369. projectDir := fmt.Sprintf("/home/debian/%s", projectName)
  370. // Stop containers with project-specific naming
  371. stopCmd := fmt.Sprintf("cd %s && docker compose -p %s down --remove-orphans", projectDir, projectName)
  372. rps.common.executeSSHCommand(ctx, preview.IPAddress, stopCmd)
  373. // Remove project directory
  374. rmCmd := fmt.Sprintf("rm -rf %s", projectDir)
  375. rps.common.executeSSHCommand(ctx, preview.IPAddress, rmCmd)
  376. // Cleanup Docker images
  377. if err := rps.common.CleanupPreviewImagesForApp(ctx, appID, true, preview.IPAddress); err != nil {
  378. rps.entry.WithField("preview_id", preview.ID).WithError(err).Warn("Failed to clean up Docker images")
  379. }
  380. }
  381. // Don't remove the VPS instance - it might be hosting other previews
  382. // Only clean up if this is the last preview on the VPS
  383. if preview.VPSID != "" && !strings.Contains(preview.VPSID, "byop.local") {
  384. // Check if there are other active previews on this VPS
  385. otherPreviews, err := rps.getActivePreviewsOnVPS(ctx, preview.VPSID)
  386. if err != nil {
  387. rps.entry.WithField("vps_id", preview.VPSID).Warnf("Failed to check other previews on VPS: %v", err)
  388. } else if len(otherPreviews) <= 1 { // Only this preview remains
  389. if err := rps.ovhProvider.ResetInstance(ctx, preview.VPSID); err != nil {
  390. rps.entry.WithField("vps_id", preview.VPSID).Errorf("Failed to reset VPS instance: %v", err)
  391. }
  392. }
  393. }
  394. // Delete the preview record from the database
  395. if err := rps.common.GetStore().DeletePreview(ctx, preview.ID); err != nil {
  396. return fmt.Errorf("failed to delete preview from database: %v", err)
  397. }
  398. rps.entry.WithField("preview_id", preview.ID).Info("Successfully deleted remote preview")
  399. return nil
  400. }
  401. // StopPreview stops a remote preview
  402. func (rps *RemotePreviewService) StopPreview(ctx context.Context, previewID uint) error {
  403. preview, err := rps.common.GetStore().GetPreviewByID(ctx, previewID)
  404. if err != nil {
  405. return err
  406. }
  407. if preview == nil {
  408. return fmt.Errorf("preview not found")
  409. }
  410. // Stop containers on VPS
  411. projectName := fmt.Sprintf("preview-%d", previewID)
  412. projectDir := fmt.Sprintf("/home/debian/%s", projectName)
  413. stopCmd := fmt.Sprintf("cd %s && docker compose -p %s down --remove-orphans", projectDir, projectName)
  414. rps.common.executeSSHCommand(ctx, preview.IPAddress, stopCmd)
  415. // Clean up Docker images before destroying VPS
  416. if err := rps.common.CleanupPreviewImagesForApp(ctx, preview.AppID, true, preview.IPAddress); err != nil {
  417. rps.entry.WithField("preview_id", previewID).WithError(err).Warn("Failed to clean up Docker images")
  418. }
  419. // Destroy the VPS
  420. if err := rps.ovhProvider.ResetInstance(ctx, preview.VPSID); err != nil {
  421. rps.entry.WithField("vps_id", preview.VPSID).Errorf("Failed to destroy preview VPS: %v", err)
  422. }
  423. return rps.common.GetStore().UpdatePreviewStatus(ctx, previewID, "stopped", "")
  424. }
  425. // getActivePreviewsOnVPS returns all active previews running on a specific VPS
  426. func (rps *RemotePreviewService) getActivePreviewsOnVPS(ctx context.Context, vpsID string) ([]*models.Preview, error) {
  427. apps, err := rps.common.GetStore().GetAllApps(ctx)
  428. if err != nil {
  429. return nil, fmt.Errorf("failed to get apps: %v", err)
  430. }
  431. var activePreviews []*models.Preview
  432. for _, app := range apps {
  433. previews, err := rps.common.GetStore().GetPreviewsByAppID(ctx, app.ID)
  434. if err != nil {
  435. continue
  436. }
  437. for _, preview := range previews {
  438. if preview.VPSID == vpsID && preview.Status == "running" {
  439. activePreviews = append(activePreviews, preview)
  440. }
  441. }
  442. }
  443. return activePreviews, nil
  444. }
  445. // Helper methods - using common SSH command execution
  446. func (rps *RemotePreviewService) transferFile(ctx context.Context, localPath, remotePath string) error {
  447. hostAndPath := strings.SplitN(remotePath, ":", 2)
  448. if len(hostAndPath) != 2 {
  449. return fmt.Errorf("invalid SCP destination format: %s", remotePath)
  450. }
  451. remoteHost := hostAndPath[0]
  452. remotePath = hostAndPath[1]
  453. cmd := exec.CommandContext(ctx, "scp", "-o", "StrictHostKeyChecking=no", localPath, fmt.Sprintf("debian@%s:%s", remoteHost, remotePath))
  454. output, err := cmd.CombinedOutput()
  455. if err != nil {
  456. rps.entry.WithField("local_path", localPath).WithField("remote_path", remotePath).WithField("output", string(output)).WithError(err).Error("SCP transfer failed")
  457. return err
  458. }
  459. rps.entry.WithField("local_path", localPath).WithField("remote_path", remotePath).Info("Successfully transferred file")
  460. return nil
  461. }
  462. // validateDNSAndCertificate validates DNS resolution and Traefik certificate for debugging
  463. func (rps *RemotePreviewService) validateDNSAndCertificate(ctx context.Context, previewDomain, ipAddress string) {
  464. rps.entry.WithField("domain", previewDomain).WithField("ip_address", ipAddress).Info("Validating DNS and certificate setup")
  465. // Check DNS resolution from the VPS
  466. dnsCmd := fmt.Sprintf("nslookup %s", previewDomain)
  467. if err := rps.common.executeSSHCommand(ctx, ipAddress, dnsCmd); err != nil {
  468. rps.entry.WithField("domain", previewDomain).WithError(err).Warn("DNS resolution failed from VPS")
  469. }
  470. // Check if Traefik can see the service
  471. traefikCmd := "docker ps --filter 'label=traefik.enable=true' --format 'table {{.Names}}\t{{.Labels}}'"
  472. if err := rps.common.executeSSHCommand(ctx, ipAddress, traefikCmd); err != nil {
  473. rps.entry.WithError(err).Warn("Failed to check Traefik-enabled containers")
  474. }
  475. // Check Traefik logs for certificate issues
  476. logsCmd := "docker logs traefik --tail 20"
  477. if err := rps.common.executeSSHCommand(ctx, ipAddress, logsCmd); err != nil {
  478. rps.entry.WithError(err).Warn("Failed to get Traefik logs")
  479. }
  480. // Wait a bit for DNS propagation and certificate generation
  481. rps.entry.WithField("domain", previewDomain).Info("Waiting 30 seconds for DNS propagation and certificate generation")
  482. time.Sleep(30 * time.Second)
  483. }