preview_common.go 32 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908
  1. package services
  2. import (
  3. "archive/tar"
  4. "bytes"
  5. "context"
  6. "crypto/rand"
  7. "encoding/json"
  8. "fmt"
  9. "io"
  10. "os"
  11. "os/exec"
  12. "path/filepath"
  13. "strings"
  14. "time"
  15. "git.linuxforward.com/byop/byop-engine/analyzer"
  16. "git.linuxforward.com/byop/byop-engine/dbstore"
  17. "git.linuxforward.com/byop/byop-engine/models"
  18. "github.com/sirupsen/logrus"
  19. )
  20. // PreviewService defines the interface for preview services
  21. type PreviewService interface {
  22. CreatePreview(ctx context.Context, appId uint) (*models.Preview, error)
  23. DeletePreview(ctx context.Context, appID uint) error
  24. StopPreview(ctx context.Context, previewID uint) error
  25. Close(ctx context.Context)
  26. }
  27. // PreviewCommon contains shared functionality for preview services
  28. type PreviewCommon struct {
  29. store *dbstore.SQLiteStore
  30. entry *logrus.Entry
  31. registryURL string
  32. registryUser string
  33. registryPass string
  34. }
  35. // NewPreviewCommon creates a new PreviewCommon instance
  36. func NewPreviewCommon(store *dbstore.SQLiteStore, registryURL, registryUser, registryPass string) *PreviewCommon {
  37. return &PreviewCommon{
  38. store: store,
  39. entry: logrus.WithField("service", "PreviewCommon"),
  40. registryURL: registryURL,
  41. registryUser: registryUser,
  42. registryPass: registryPass,
  43. }
  44. }
  45. // Close cleans up resources
  46. func (pc *PreviewCommon) Close() {
  47. // Clean up preview database state
  48. pc.CleanupPreviewState(context.Background())
  49. }
  50. // GetStore returns the database store
  51. func (pc *PreviewCommon) GetStore() *dbstore.SQLiteStore {
  52. return pc.store
  53. }
  54. // GetLogger returns the logger
  55. func (pc *PreviewCommon) GetLogger() *logrus.Entry {
  56. return pc.entry
  57. }
  58. // GeneratePreviewID generates an 8-character random hex UUID for preview URLs
  59. func (pc *PreviewCommon) GeneratePreviewID() string {
  60. bytes := make([]byte, 4) // 4 bytes = 8 hex chars
  61. if _, err := rand.Read(bytes); err != nil {
  62. // Fallback to timestamp-based ID if crypto/rand fails
  63. return fmt.Sprintf("%08x", time.Now().Unix()%0xFFFFFFFF)
  64. }
  65. // Convert each byte directly to hex to ensure we get truly random looking IDs
  66. return fmt.Sprintf("%02x%02x%02x%02x", bytes[0], bytes[1], bytes[2], bytes[3])
  67. }
  68. // CloneRepository clones a git repository to a target directory
  69. func (pc *PreviewCommon) CloneRepository(ctx context.Context, repoURL, branch, targetDir string) error {
  70. if err := os.MkdirAll(targetDir, 0755); err != nil {
  71. return models.NewErrInternalServer(fmt.Sprintf("failed to create target directory %s", targetDir), err)
  72. }
  73. if branch == "" {
  74. branch = "main"
  75. }
  76. cmd := exec.CommandContext(ctx, "git", "clone", "--depth", "1", "--branch", branch, repoURL, targetDir)
  77. if err := cmd.Run(); err != nil {
  78. // Try with master branch if main fails
  79. if branch == "main" {
  80. cmd = exec.CommandContext(ctx, "git", "clone", "--depth", "1", "--branch", "master", repoURL, targetDir)
  81. if err := cmd.Run(); err != nil {
  82. return models.NewErrInternalServer(fmt.Sprintf("failed to clone repository (tried main and master branches): %s", repoURL), err)
  83. }
  84. } else {
  85. return models.NewErrInternalServer(fmt.Sprintf("failed to clone repository %s on branch %s", repoURL, branch), err)
  86. }
  87. }
  88. return nil
  89. }
  90. // CreateBuildContext creates a tar archive of the build context
  91. func (pc *PreviewCommon) CreateBuildContext(ctx context.Context, contextDir string, component *models.Component) (io.ReadCloser, error) {
  92. var buf bytes.Buffer
  93. tw := tar.NewWriter(&buf)
  94. defer tw.Close()
  95. // For docker-compose components, adjust the context directory to use the build context
  96. effectiveContextDir := contextDir
  97. relativeDockerfilePath := "Dockerfile"
  98. if component != nil && component.SourceType == "docker-compose" && component.BuildContext != "" {
  99. // Resolve the build context directory
  100. effectiveContextDir = filepath.Join(contextDir, component.BuildContext)
  101. // Set Dockerfile path relative to the build context
  102. if component.DockerfilePath != "" {
  103. relativeDockerfilePath = component.DockerfilePath
  104. }
  105. pc.entry.WithFields(logrus.Fields{
  106. "component_id": component.ID,
  107. "build_context": component.BuildContext,
  108. "dockerfile_path": relativeDockerfilePath,
  109. "resolved_context_dir": effectiveContextDir,
  110. }).Info("Using docker-compose build context for component")
  111. }
  112. // Common ignore patterns for Git repositories
  113. ignorePatterns := []string{
  114. ".git",
  115. ".gitignore",
  116. "node_modules",
  117. ".next",
  118. "dist",
  119. "build",
  120. "target",
  121. "__pycache__",
  122. "*.pyc",
  123. ".DS_Store",
  124. "Thumbs.db",
  125. "*.log",
  126. "*.tmp",
  127. "*.swp",
  128. ".env",
  129. ".vscode",
  130. ".idea",
  131. "playwright",
  132. "cypress",
  133. "coverage",
  134. "*.test.js",
  135. "*.spec.js",
  136. "*.test.ts",
  137. "*.spec.ts",
  138. "test",
  139. "tests",
  140. "__tests__",
  141. "snapshots",
  142. "*.png",
  143. "*.jpg",
  144. "*.jpeg",
  145. "*.gif",
  146. "*.bmp",
  147. "*.svg",
  148. "*.ico",
  149. "*.zip",
  150. "*.tar.gz",
  151. "*.tar",
  152. "*.gz",
  153. "README.md",
  154. "readme.md",
  155. "CHANGELOG.md",
  156. "LICENSE",
  157. "CONTRIBUTING.md",
  158. "*.md",
  159. "docs",
  160. "documentation",
  161. }
  162. err := filepath.Walk(effectiveContextDir, func(file string, fi os.FileInfo, err error) error {
  163. if err != nil {
  164. return err
  165. }
  166. // Get relative path
  167. relPath, err := filepath.Rel(effectiveContextDir, file)
  168. if err != nil {
  169. return err
  170. }
  171. // Skip if matches ignore patterns
  172. for _, pattern := range ignorePatterns {
  173. if matched, _ := filepath.Match(pattern, fi.Name()); matched {
  174. if fi.IsDir() {
  175. return filepath.SkipDir
  176. }
  177. return nil
  178. }
  179. if strings.Contains(relPath, pattern) {
  180. if fi.IsDir() {
  181. return filepath.SkipDir
  182. }
  183. return nil
  184. }
  185. }
  186. // Skip very large files (> 100MB)
  187. if !fi.IsDir() && fi.Size() > 100*1024*1024 {
  188. pc.entry.WithField("file", relPath).WithField("size", fi.Size()).Warn("Skipping large file in build context")
  189. return nil
  190. }
  191. // Skip files with very long paths (> 200 chars)
  192. if len(relPath) > 200 {
  193. pc.entry.WithField("file", relPath).WithField("length", len(relPath)).Warn("Skipping file with very long path")
  194. return nil
  195. }
  196. // Create tar header
  197. header, err := tar.FileInfoHeader(fi, fi.Name())
  198. if err != nil {
  199. return err
  200. }
  201. // Update the name to be relative to the context directory
  202. header.Name = filepath.ToSlash(relPath)
  203. // Ensure header name is not too long for tar format
  204. if len(header.Name) > 155 {
  205. pc.entry.WithField("file", header.Name).WithField("length", len(header.Name)).Warn("Skipping file with tar-incompatible long name")
  206. return nil
  207. }
  208. // Write header
  209. if err := tw.WriteHeader(header); err != nil {
  210. return fmt.Errorf("failed to write tar header for %s: %v", relPath, err)
  211. }
  212. // If it's a file, write its content
  213. if !fi.IsDir() {
  214. data, err := os.Open(file)
  215. if err != nil {
  216. return fmt.Errorf("failed to open file %s: %v", relPath, err)
  217. }
  218. defer data.Close()
  219. // Use limited reader to prevent issues with very large files
  220. limitedReader := io.LimitReader(data, 100*1024*1024) // 100MB limit
  221. written, err := io.Copy(tw, limitedReader)
  222. if err != nil {
  223. pc.entry.WithField("file", relPath).WithField("written_bytes", written).Warnf("Failed to copy file to tar, skipping: %v", err)
  224. // Don't return error, just skip this file
  225. return nil
  226. }
  227. }
  228. return nil
  229. })
  230. if err != nil {
  231. return nil, err
  232. }
  233. return io.NopCloser(&buf), nil
  234. }
  235. // fileExists checks if a file exists
  236. func fileExists(path string) bool {
  237. _, err := os.Stat(path)
  238. return err == nil
  239. }
  240. // BuildComponentImages builds Docker images for components using shell commands
  241. // This simplified version uses docker build commands directly instead of Docker API
  242. func (pc *PreviewCommon) BuildComponentImages(ctx context.Context, components []models.Component) ([]string, string, error) {
  243. var imageNames []string
  244. var allLogs strings.Builder
  245. for _, component := range components {
  246. pc.entry.WithFields(logrus.Fields{
  247. "component_id": component.ID,
  248. "status": component.Status,
  249. "source_type": component.SourceType,
  250. "build_context": component.BuildContext,
  251. "dockerfile_path": component.DockerfilePath,
  252. "service_name": component.ServiceName,
  253. }).Info("Processing component for preview")
  254. allLogs.WriteString(fmt.Sprintf("Processing component %d (%s) - SourceType: %s, BuildContext: %s, DockerfilePath: %s\n",
  255. component.ID, component.Name, component.SourceType, component.BuildContext, component.DockerfilePath))
  256. // Generate local image name for preview
  257. imageName := fmt.Sprintf("byop-preview-%s:%d", component.Name, component.ID)
  258. // Check if the local preview image already exists using shell command
  259. pc.entry.WithField("component_id", component.ID).WithField("image_name", imageName).Info("Checking if local preview image exists")
  260. checkCmd := exec.CommandContext(ctx, "docker", "image", "inspect", imageName)
  261. if err := checkCmd.Run(); err == nil {
  262. pc.entry.WithField("component_id", component.ID).WithField("image_name", imageName).Info("Local preview image already exists, skipping build")
  263. allLogs.WriteString(fmt.Sprintf("Component %d already has local preview image %s, skipping build\n", component.ID, imageName))
  264. imageNames = append(imageNames, imageName)
  265. continue // Skip to next component
  266. } else {
  267. pc.entry.WithField("component_id", component.ID).WithField("image_name", imageName).WithError(err).Info("Local preview image not found, will build from source")
  268. }
  269. // For docker-compose components with public images, use them directly
  270. if component.SourceType == "docker-compose" && component.CurrentImageURI != "" &&
  271. strings.Contains(component.CurrentImageURI, ":") &&
  272. !strings.Contains(component.CurrentImageURI, "host.docker.internal") &&
  273. !strings.Contains(component.CurrentImageURI, pc.registryURL) {
  274. // This is likely a public image (mysql:5.7, postgres:13, etc.)
  275. pc.entry.WithField("component_id", component.ID).WithField("image_uri", component.CurrentImageURI).Info("Using public image directly for preview")
  276. allLogs.WriteString(fmt.Sprintf("Component %d using public image %s directly\n", component.ID, component.CurrentImageURI))
  277. imageNames = append(imageNames, component.CurrentImageURI)
  278. continue // Skip to next component
  279. }
  280. // Build from source code
  281. pc.entry.WithField("component_id", component.ID).Info("Building Docker image from source")
  282. allLogs.WriteString(fmt.Sprintf("Building component %d from source\n", component.ID))
  283. // Create temp directory for this component
  284. tempDir := filepath.Join(os.TempDir(), fmt.Sprintf("byop-preview-%d-%d", component.ID, time.Now().Unix()))
  285. defer os.RemoveAll(tempDir)
  286. // Clone repository
  287. if err := pc.CloneRepository(ctx, component.Repository, component.Branch, tempDir); err != nil {
  288. allLogs.WriteString(fmt.Sprintf("Failed to clone %s: %v\n", component.Repository, err))
  289. return nil, allLogs.String(), err
  290. }
  291. // Determine build context and dockerfile path
  292. buildContext := tempDir
  293. dockerfilePath := "Dockerfile"
  294. if component.SourceType == "docker-compose" && component.BuildContext != "" {
  295. buildContext = filepath.Join(tempDir, component.BuildContext)
  296. if component.DockerfilePath != "" {
  297. dockerfilePath = component.DockerfilePath
  298. }
  299. }
  300. // Check if we need to generate a Dockerfile
  301. fullDockerfilePath := filepath.Join(buildContext, dockerfilePath)
  302. if component.Status != "valid" || !fileExists(fullDockerfilePath) {
  303. pc.entry.WithField("component_id", component.ID).Info("Generating Dockerfile for component")
  304. allLogs.WriteString(fmt.Sprintf("Generating Dockerfile for component %d\n", component.ID))
  305. // Use analyzer to generate Dockerfile
  306. stack, err := analyzer.AnalyzeCode(tempDir)
  307. if err != nil {
  308. allLogs.WriteString(fmt.Sprintf("Failed to analyze code for %s: %v\n", component.Name, err))
  309. return nil, allLogs.String(), err
  310. }
  311. dockerfileContent, err := stack.GenerateDockerfile(tempDir)
  312. if err != nil {
  313. allLogs.WriteString(fmt.Sprintf("Failed to generate Dockerfile for %s: %v\n", component.Name, err))
  314. return nil, allLogs.String(), err
  315. }
  316. // Write Dockerfile (no Traefik labels here - they'll be added at runtime)
  317. if err := os.WriteFile(fullDockerfilePath, []byte(dockerfileContent), 0644); err != nil {
  318. allLogs.WriteString(fmt.Sprintf("Failed to write Dockerfile for %s: %v\n", component.Name, err))
  319. return nil, allLogs.String(), err
  320. }
  321. }
  322. // Build the image using docker build command
  323. pc.entry.WithField("component_id", component.ID).WithField("image_name", imageName).Info("Building Docker image")
  324. allLogs.WriteString(fmt.Sprintf("Building Docker image for component %d: %s\n", component.ID, imageName))
  325. buildCmd := exec.CommandContext(ctx, "docker", "build", "-t", imageName, "-f", dockerfilePath, buildContext)
  326. output, err := buildCmd.CombinedOutput()
  327. buildOutputStr := string(output)
  328. allLogs.WriteString(fmt.Sprintf("Build output for %s:\n%s\n", imageName, buildOutputStr))
  329. if err != nil {
  330. allLogs.WriteString(fmt.Sprintf("Failed to build %s: %v\n", imageName, err))
  331. return nil, allLogs.String(), fmt.Errorf("failed to build image %s: %v", imageName, err)
  332. }
  333. // Verify the image was built successfully
  334. verifyCmd := exec.CommandContext(ctx, "docker", "image", "inspect", imageName)
  335. if err := verifyCmd.Run(); err != nil {
  336. allLogs.WriteString(fmt.Sprintf("Build verification failed for %s: image not found after build - %v\n", imageName, err))
  337. return nil, allLogs.String(), fmt.Errorf("failed to build image %s: image not found after build", imageName)
  338. }
  339. imageNames = append(imageNames, imageName)
  340. pc.entry.WithField("component_id", component.ID).WithField("image_name", imageName).Info("Successfully built Docker image")
  341. }
  342. return imageNames, allLogs.String(), nil
  343. }
  344. // GetAppComponents retrieves components for an app
  345. func (pc *PreviewCommon) GetAppComponents(ctx context.Context, app *models.App) ([]models.Component, error) {
  346. var components []models.Component
  347. // Parse the JSON string of component IDs
  348. var componentIDs []uint
  349. if app.Components != "" {
  350. if err := json.Unmarshal([]byte(app.Components), &componentIDs); err != nil {
  351. return nil, fmt.Errorf("failed to parse component IDs from app %d: %v", app.ID, err)
  352. }
  353. }
  354. for _, componentID := range componentIDs {
  355. component, err := pc.store.GetComponentByID(ctx, componentID)
  356. if err != nil {
  357. return nil, err
  358. }
  359. if component == nil {
  360. return nil, models.NewErrNotFound(fmt.Sprintf("Component with ID %d not found while fetching app components", componentID), nil)
  361. }
  362. components = append(components, *component)
  363. }
  364. return components, nil
  365. }
  366. // CleanupPreviewImages cleans up BYOP preview Docker images using shell commands
  367. func (pc *PreviewCommon) CleanupPreviewImages(ctx context.Context) {
  368. pc.entry.Info("Cleaning up BYOP preview images...")
  369. // List all images and filter for byop-preview
  370. cmd := exec.CommandContext(ctx, "docker", "images", "--format", "{{.Repository}}:{{.Tag}}")
  371. output, err := cmd.Output()
  372. if err != nil {
  373. pc.entry.WithError(err).Error("Failed to list images for cleanup")
  374. return
  375. }
  376. removedCount := 0
  377. lines := strings.Split(string(output), "\n")
  378. for _, line := range lines {
  379. line = strings.TrimSpace(line)
  380. if line == "" {
  381. continue
  382. }
  383. if strings.Contains(line, "byop-preview") {
  384. // Remove the image
  385. rmCmd := exec.CommandContext(ctx, "docker", "rmi", line, "--force")
  386. if err := rmCmd.Run(); err != nil {
  387. pc.entry.WithError(err).WithField("image", line).Warn("Failed to remove preview image")
  388. } else {
  389. removedCount++
  390. }
  391. }
  392. }
  393. if removedCount > 0 {
  394. pc.entry.WithField("removed_images", removedCount).Info("Cleaned up BYOP preview images")
  395. }
  396. }
  397. // CleanupAllPreviewContainers cleans up all BYOP preview containers using shell commands
  398. func (pc *PreviewCommon) CleanupAllPreviewContainers(ctx context.Context) {
  399. pc.entry.Info("Cleaning up all BYOP preview containers...")
  400. // List all containers and filter for byop-preview
  401. cmd := exec.CommandContext(ctx, "docker", "ps", "-a", "--format", "{{.Names}}")
  402. output, err := cmd.Output()
  403. if err != nil {
  404. pc.entry.WithError(err).Error("Failed to list containers for cleanup")
  405. return
  406. }
  407. removedCount := 0
  408. lines := strings.Split(string(output), "\n")
  409. for _, line := range lines {
  410. line = strings.TrimSpace(line)
  411. if line == "" {
  412. continue
  413. }
  414. if strings.Contains(line, "byop-preview") || strings.Contains(line, "preview") {
  415. pc.entry.WithField("container_name", line).Info("Removing BYOP preview container")
  416. // Stop and remove container
  417. stopCmd := exec.CommandContext(ctx, "docker", "stop", line)
  418. stopCmd.Run() // Ignore errors for stop
  419. rmCmd := exec.CommandContext(ctx, "docker", "rm", "-f", line)
  420. if err := rmCmd.Run(); err != nil {
  421. pc.entry.WithError(err).WithField("container_name", line).Error("Failed to remove container")
  422. } else {
  423. removedCount++
  424. }
  425. }
  426. }
  427. if removedCount > 0 {
  428. pc.entry.WithField("removed_containers", removedCount).Info("Cleaned up BYOP preview containers")
  429. }
  430. }
  431. // CleanupPreviewState cleans up preview database state - mark all running previews as stopped
  432. func (pc *PreviewCommon) CleanupPreviewState(ctx context.Context) {
  433. pc.entry.Info("Cleaning up preview database state...")
  434. // Get all active previews (building, deploying, running)
  435. activeStatuses := []string{"building", "deploying", "running"}
  436. for _, status := range activeStatuses {
  437. previews, err := pc.store.GetPreviewsByStatus(ctx, status)
  438. if err != nil {
  439. pc.entry.WithError(err).WithField("status", status).Error("Failed to get previews by status")
  440. continue
  441. }
  442. for _, preview := range previews {
  443. pc.entry.WithField("preview_id", preview.ID).WithField("app_id", preview.AppID).WithField("old_status", preview.Status).Info("Marking preview as stopped due to server shutdown")
  444. // Update preview status to stopped
  445. if err := pc.store.UpdatePreviewStatus(ctx, preview.ID, "stopped", "Server shutdown - containers may have been stopped"); err != nil {
  446. pc.entry.WithError(err).WithField("preview_id", preview.ID).Error("Failed to update preview status to stopped")
  447. }
  448. // Also update the associated app status back to "ready" if it was in a preview state
  449. if app, err := pc.store.GetAppByID(ctx, preview.AppID); err == nil && app != nil {
  450. if app.Status == "building" || app.Status == "deploying" {
  451. if err := pc.store.UpdateAppStatus(ctx, app.ID, "ready", ""); err != nil {
  452. pc.entry.WithError(err).WithField("app_id", app.ID).Error("Failed to reset app status to ready")
  453. } else {
  454. pc.entry.WithField("app_id", app.ID).Info("Reset app status to ready after preview cleanup")
  455. }
  456. }
  457. }
  458. }
  459. if len(previews) > 0 {
  460. pc.entry.WithField("count", len(previews)).WithField("status", status).Info("Updated preview statuses to stopped")
  461. }
  462. }
  463. pc.entry.Info("Preview database state cleanup completed")
  464. }
  465. // GetPreviewImageNames reconstructs the Docker image names used for a preview
  466. func (pc *PreviewCommon) GetPreviewImageNames(appID uint) ([]string, error) {
  467. // Get app details
  468. app, err := pc.store.GetAppByID(context.Background(), appID)
  469. if err != nil {
  470. return nil, fmt.Errorf("failed to get app by ID %d: %v", appID, err)
  471. }
  472. // Get all components for the app
  473. components, err := pc.GetAppComponents(context.Background(), app)
  474. if err != nil {
  475. return nil, fmt.Errorf("failed to get app components: %v", err)
  476. }
  477. // Reconstruct image names using the same format as BuildComponentImages
  478. var imageNames []string
  479. for _, component := range components {
  480. imageName := fmt.Sprintf("byop-preview-%s:%d", component.Name, component.ID)
  481. imageNames = append(imageNames, imageName)
  482. }
  483. return imageNames, nil
  484. }
  485. // CleanupPreviewImagesForApp cleans up Docker images for a specific app using shell commands
  486. func (pc *PreviewCommon) CleanupPreviewImagesForApp(ctx context.Context, appID uint, isRemote bool, ipAddress string) error {
  487. imageNames, err := pc.GetPreviewImageNames(appID)
  488. if err != nil {
  489. pc.entry.WithField("app_id", appID).WithError(err).Warn("Failed to get preview image names for cleanup")
  490. return err
  491. }
  492. if isRemote && ipAddress != "" && ipAddress != "127.0.0.1" {
  493. return pc.cleanupRemoteDockerImages(ctx, ipAddress, imageNames)
  494. } else {
  495. return pc.cleanupLocalDockerImages(ctx, imageNames)
  496. }
  497. }
  498. // cleanupLocalDockerImages removes specific Docker images locally using shell commands
  499. func (pc *PreviewCommon) cleanupLocalDockerImages(ctx context.Context, imageNames []string) error {
  500. pc.entry.WithField("image_count", len(imageNames)).Info("Cleaning up specific Docker images locally")
  501. for _, imageName := range imageNames {
  502. // Remove the image locally using docker rmi command
  503. cmd := exec.CommandContext(ctx, "docker", "rmi", imageName, "--force")
  504. if err := cmd.Run(); err != nil {
  505. // Log warning but don't fail the cleanup - image might already be removed or in use
  506. pc.entry.WithField("image_name", imageName).WithError(err).Warn("Failed to remove Docker image locally (this may be normal)")
  507. } else {
  508. pc.entry.WithField("image_name", imageName).Info("Successfully removed Docker image locally")
  509. }
  510. }
  511. return nil
  512. }
  513. // cleanupRemoteDockerImages removes Docker images from a VPS via SSH
  514. func (pc *PreviewCommon) cleanupRemoteDockerImages(ctx context.Context, ipAddress string, imageNames []string) error {
  515. pc.entry.WithField("ip_address", ipAddress).WithField("image_count", len(imageNames)).Info("Cleaning up Docker images on VPS")
  516. for _, imageName := range imageNames {
  517. // Remove the image
  518. rmImageCmd := fmt.Sprintf("docker rmi %s --force", imageName)
  519. pc.entry.WithField("image_name", imageName).WithField("ip_address", ipAddress).Info("Removing Docker image")
  520. if err := pc.executeSSHCommand(ctx, ipAddress, rmImageCmd); err != nil {
  521. // Log warning but don't fail the cleanup - image might already be removed or in use
  522. pc.entry.WithField("image_name", imageName).WithField("ip_address", ipAddress).WithError(err).Warn("Failed to remove Docker image (this may be normal)")
  523. } else {
  524. pc.entry.WithField("image_name", imageName).WithField("ip_address", ipAddress).Info("Successfully removed Docker image")
  525. }
  526. // Also remove the tar file if it exists
  527. tarFileName := strings.ReplaceAll(imageName, ":", "_")
  528. rmTarCmd := fmt.Sprintf("rm -f /tmp/%s.tar", tarFileName)
  529. pc.executeSSHCommand(ctx, ipAddress, rmTarCmd) // Ignore errors for tar cleanup
  530. }
  531. // Clean up any dangling images
  532. pc.entry.WithField("ip_address", ipAddress).Info("Cleaning up dangling Docker images")
  533. danglingCmd := "docker image prune -f"
  534. if err := pc.executeSSHCommand(ctx, ipAddress, danglingCmd); err != nil {
  535. pc.entry.WithField("ip_address", ipAddress).WithError(err).Warn("Failed to clean dangling images")
  536. }
  537. return nil
  538. }
  539. // executeSSHCommand executes a command on a remote VPS via SSH
  540. func (pc *PreviewCommon) executeSSHCommand(ctx context.Context, ipAddress, command string) error {
  541. pc.entry.WithField("ip_address", ipAddress).WithField("command", command).Debug("Executing SSH command")
  542. cmd := exec.CommandContext(ctx, "ssh", "-o", "StrictHostKeyChecking=no", ipAddress, command)
  543. output, err := cmd.CombinedOutput()
  544. if err != nil {
  545. pc.entry.WithField("ip_address", ipAddress).WithField("command", command).WithField("output", string(output)).WithError(err).Error("SSH command failed")
  546. return models.NewErrInternalServer(fmt.Sprintf("SSH command failed on %s: %s. Output: %s", ipAddress, command, string(output)), err)
  547. }
  548. if len(output) > 0 {
  549. pc.entry.WithField("ip_address", ipAddress).WithField("command", command).WithField("output", string(output)).Debug("SSH command output")
  550. }
  551. return nil
  552. }
  553. // Database helper methods
  554. func (pc *PreviewCommon) UpdatePreviewStatus(ctx context.Context, previewID uint, status, errorMsg string) {
  555. if err := pc.store.UpdatePreviewStatus(ctx, previewID, status, errorMsg); err != nil {
  556. pc.entry.WithField("preview_id", previewID).Errorf("Failed to update preview status: %v", err)
  557. }
  558. }
  559. func (pc *PreviewCommon) UpdatePreviewBuildLogs(ctx context.Context, previewID uint, logs string) {
  560. if err := pc.store.UpdatePreviewBuildLogs(ctx, previewID, logs); err != nil {
  561. pc.entry.WithField("preview_id", previewID).Errorf("Failed to update preview build logs: %v", err)
  562. }
  563. }
  564. func (pc *PreviewCommon) UpdatePreviewDeployLogs(ctx context.Context, previewID uint, logs string) {
  565. if err := pc.store.UpdatePreviewDeployLogs(ctx, previewID, logs); err != nil {
  566. pc.entry.WithField("preview_id", previewID).Errorf("Failed to update preview deploy logs: %v", err)
  567. }
  568. }
  569. // CleanupByAppID cleans up all BYOP preview containers and images for a specific app ID using shell commands
  570. func (pc *PreviewCommon) CleanupByAppID(ctx context.Context, appID uint) {
  571. pc.entry.WithField("app_id", appID).Info("Cleaning up BYOP preview containers...")
  572. // List all containers and filter for byop-preview containers with the specific app ID
  573. // We'll use the app ID in the container naming pattern
  574. appPattern := fmt.Sprintf("byop-preview-app-%d", appID)
  575. cmd := exec.CommandContext(ctx, "docker", "ps", "-a", "--format", "{{.Names}}")
  576. output, err := cmd.Output()
  577. if err != nil {
  578. pc.entry.WithError(err).Error("Failed to list containers for cleanup")
  579. return
  580. }
  581. removedCount := 0
  582. lines := strings.Split(string(output), "\n")
  583. for _, line := range lines {
  584. line = strings.TrimSpace(line)
  585. if line == "" {
  586. continue
  587. }
  588. // Check if this container is for the specific app
  589. if strings.Contains(line, appPattern) || (strings.Contains(line, "byop-preview") && strings.Contains(line, fmt.Sprintf("-%d-", appID))) {
  590. pc.entry.WithField("container_name", line).WithField("app_id", appID).Info("Removing BYOP preview container for app")
  591. // Stop and remove container
  592. stopCmd := exec.CommandContext(ctx, "docker", "stop", line)
  593. stopCmd.Run() // Ignore errors for stop
  594. rmCmd := exec.CommandContext(ctx, "docker", "rm", "-f", line)
  595. if err := rmCmd.Run(); err != nil {
  596. pc.entry.WithError(err).WithField("container_name", line).WithField("app_id", appID).Error("Failed to remove container")
  597. } else {
  598. removedCount++
  599. }
  600. }
  601. }
  602. if removedCount > 0 {
  603. pc.entry.WithField("removed_containers", removedCount).WithField("app_id", appID).Info("Cleaned up BYOP preview containers for app")
  604. }
  605. }
  606. // AddTraefikLabelsToDockerfile adds Traefik routing labels to a Dockerfile
  607. func (pc *PreviewCommon) AddTraefikLabelsToDockerfile(dockerfileContent, appName string, appID uint, port string) string {
  608. if port == "" {
  609. port = "3000" // Default port
  610. }
  611. // Generate the preview subdomain
  612. previewID := pc.GeneratePreviewID()
  613. subdomain := fmt.Sprintf("%s-%d-%s", appName, appID, previewID)
  614. // Traefik labels to add
  615. traefikLabels := []string{
  616. `LABEL traefik.enable="true"`,
  617. fmt.Sprintf(`LABEL traefik.http.routers.%s.rule="Host(%s.preview.byop.dev)"`, subdomain, "`"+subdomain+"`"),
  618. fmt.Sprintf(`LABEL traefik.http.routers.%s.entrypoints="websecure"`, subdomain),
  619. fmt.Sprintf(`LABEL traefik.http.routers.%s.tls.certresolver="letsencrypt"`, subdomain),
  620. fmt.Sprintf(`LABEL traefik.http.services.%s.loadbalancer.server.port="%s"`, subdomain, port),
  621. `LABEL traefik.docker.network="traefik"`,
  622. `LABEL byop.preview="true"`,
  623. fmt.Sprintf(`LABEL byop.app.id="%d"`, appID),
  624. fmt.Sprintf(`LABEL byop.app.name="%s"`, appName),
  625. fmt.Sprintf(`LABEL byop.preview.id="%s"`, previewID),
  626. }
  627. // Parse the Dockerfile content
  628. lines := strings.Split(dockerfileContent, "\n")
  629. var modifiedLines []string
  630. // Find the last non-empty, non-comment line to insert labels before any final CMD/ENTRYPOINT
  631. lastInstructionIndex := -1
  632. for i := len(lines) - 1; i >= 0; i-- {
  633. line := strings.TrimSpace(lines[i])
  634. if line != "" && !strings.HasPrefix(line, "#") {
  635. if strings.HasPrefix(strings.ToUpper(line), "CMD") ||
  636. strings.HasPrefix(strings.ToUpper(line), "ENTRYPOINT") {
  637. lastInstructionIndex = i
  638. break
  639. }
  640. }
  641. }
  642. // If we found a CMD/ENTRYPOINT, insert labels before it
  643. if lastInstructionIndex != -1 {
  644. modifiedLines = append(modifiedLines, lines[:lastInstructionIndex]...)
  645. modifiedLines = append(modifiedLines, "")
  646. modifiedLines = append(modifiedLines, "# Traefik labels for BYOP preview routing")
  647. modifiedLines = append(modifiedLines, traefikLabels...)
  648. modifiedLines = append(modifiedLines, "")
  649. modifiedLines = append(modifiedLines, lines[lastInstructionIndex:]...)
  650. } else {
  651. // No CMD/ENTRYPOINT found, just append labels at the end
  652. modifiedLines = append(modifiedLines, lines...)
  653. modifiedLines = append(modifiedLines, "")
  654. modifiedLines = append(modifiedLines, "# Traefik labels for BYOP preview routing")
  655. modifiedLines = append(modifiedLines, traefikLabels...)
  656. }
  657. return strings.Join(modifiedLines, "\n")
  658. }
  659. // DetectPortFromDockerfile attempts to detect the exposed port from a Dockerfile
  660. func (pc *PreviewCommon) DetectPortFromDockerfile(dockerfileContent string) string {
  661. lines := strings.Split(dockerfileContent, "\n")
  662. for _, line := range lines {
  663. line = strings.TrimSpace(strings.ToUpper(line))
  664. if strings.HasPrefix(line, "EXPOSE ") {
  665. // Extract port number
  666. parts := strings.Fields(line)
  667. if len(parts) >= 2 {
  668. port := strings.Split(parts[1], "/")[0] // Handle cases like "3000/tcp"
  669. return port
  670. }
  671. }
  672. }
  673. // Common default ports based on technology detection
  674. content := strings.ToLower(dockerfileContent)
  675. if strings.Contains(content, "node") || strings.Contains(content, "npm") {
  676. return "3000"
  677. } else if strings.Contains(content, "python") || strings.Contains(content, "flask") {
  678. return "5000"
  679. } else if strings.Contains(content, "django") {
  680. return "8000"
  681. } else if strings.Contains(content, "nginx") {
  682. return "80"
  683. }
  684. return "3000" // Default fallback
  685. }
  686. // GenerateTraefikComposeOverride creates a docker-compose override for Traefik routing
  687. // This is used during preview deployment to add routing labels to pre-built images
  688. func (pc *PreviewCommon) GenerateTraefikComposeOverride(serviceName, appName string, appID uint, port string, previewID string) string {
  689. if port == "" {
  690. port = "3000"
  691. }
  692. if previewID == "" {
  693. previewID = pc.GeneratePreviewID()
  694. }
  695. subdomain := fmt.Sprintf("%s-%d-%s", appName, appID, previewID)
  696. override := fmt.Sprintf(`version: '3.8'
  697. services:
  698. %s:
  699. labels:
  700. - traefik.enable=true
  701. - traefik.http.routers.%s.rule=Host(%s%s.preview.byop.dev%s)
  702. - traefik.http.routers.%s.entrypoints=websecure
  703. - traefik.http.routers.%s.tls.certresolver=letsencrypt
  704. - traefik.http.services.%s.loadbalancer.server.port=%s
  705. - traefik.docker.network=traefik
  706. - byop.preview=true
  707. - byop.app.id=%d
  708. - byop.app.name=%s
  709. - byop.preview.id=%s
  710. networks:
  711. - traefik
  712. - default
  713. networks:
  714. traefik:
  715. external: true
  716. `, serviceName, subdomain, "`", subdomain, "`", subdomain, subdomain, subdomain, port, appID, appName, previewID)
  717. return override
  718. }
  719. // GeneratePreviewComposeFile creates a complete docker-compose file for preview deployment
  720. // This uses pre-built component images and adds Traefik routing
  721. func (pc *PreviewCommon) GeneratePreviewComposeFile(app *models.App, components []models.Component, imageNames []string, previewID string) (string, error) {
  722. if len(components) != len(imageNames) {
  723. return "", fmt.Errorf("component count (%d) doesn't match image count (%d)", len(components), len(imageNames))
  724. }
  725. var services []string
  726. for i, component := range components {
  727. imageName := imageNames[i]
  728. serviceName := component.ServiceName
  729. if serviceName == "" {
  730. serviceName = component.Name
  731. }
  732. // Detect port from component or use default
  733. port := "3000" // Default port
  734. // Try to detect port from the built image if available
  735. // For now, we'll use a default since we don't store port info in Component model
  736. // Generate subdomain for this component
  737. subdomain := fmt.Sprintf("%s-%d-%s", app.Name, app.ID, previewID)
  738. if len(components) > 1 {
  739. subdomain = fmt.Sprintf("%s-%s-%d-%s", app.Name, component.Name, app.ID, previewID)
  740. }
  741. serviceConfig := fmt.Sprintf(` %s:
  742. image: %s
  743. labels:
  744. - traefik.enable=true
  745. - traefik.http.routers.%s.rule=Host(%s%s.preview.byop.dev%s)
  746. - traefik.http.routers.%s.entrypoints=websecure
  747. - traefik.http.routers.%s.tls.certresolver=letsencrypt
  748. - traefik.http.services.%s.loadbalancer.server.port=%s
  749. - traefik.docker.network=traefik
  750. - byop.preview=true
  751. - byop.app.id=%d
  752. - byop.app.name=%s
  753. - byop.preview.id=%s
  754. - byop.component.id=%d
  755. - byop.component.name=%s
  756. networks:
  757. - traefik
  758. - default
  759. restart: unless-stopped`,
  760. serviceName, imageName, subdomain, "`", subdomain, "`", subdomain, subdomain, subdomain, port, app.ID, app.Name, previewID, component.ID, component.Name)
  761. services = append(services, serviceConfig)
  762. }
  763. composeFile := fmt.Sprintf(`version: '3.8'
  764. services:
  765. %s
  766. networks:
  767. traefik:
  768. external: true
  769. `, strings.Join(services, "\n\n"))
  770. return composeFile, nil
  771. }