123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908 |
- package services
- import (
- "archive/tar"
- "bytes"
- "context"
- "crypto/rand"
- "encoding/json"
- "fmt"
- "io"
- "os"
- "os/exec"
- "path/filepath"
- "strings"
- "time"
- "git.linuxforward.com/byop/byop-engine/analyzer"
- "git.linuxforward.com/byop/byop-engine/dbstore"
- "git.linuxforward.com/byop/byop-engine/models"
- "github.com/sirupsen/logrus"
- )
- // PreviewService defines the interface for preview services
- type PreviewService interface {
- CreatePreview(ctx context.Context, appId uint) (*models.Preview, error)
- DeletePreview(ctx context.Context, appID uint) error
- StopPreview(ctx context.Context, previewID uint) error
- Close(ctx context.Context)
- }
- // PreviewCommon contains shared functionality for preview services
- type PreviewCommon struct {
- store *dbstore.SQLiteStore
- entry *logrus.Entry
- registryURL string
- registryUser string
- registryPass string
- }
- // NewPreviewCommon creates a new PreviewCommon instance
- func NewPreviewCommon(store *dbstore.SQLiteStore, registryURL, registryUser, registryPass string) *PreviewCommon {
- return &PreviewCommon{
- store: store,
- entry: logrus.WithField("service", "PreviewCommon"),
- registryURL: registryURL,
- registryUser: registryUser,
- registryPass: registryPass,
- }
- }
- // Close cleans up resources
- func (pc *PreviewCommon) Close() {
- // Clean up preview database state
- pc.CleanupPreviewState(context.Background())
- }
- // GetStore returns the database store
- func (pc *PreviewCommon) GetStore() *dbstore.SQLiteStore {
- return pc.store
- }
- // GetLogger returns the logger
- func (pc *PreviewCommon) GetLogger() *logrus.Entry {
- return pc.entry
- }
- // GeneratePreviewID generates an 8-character random hex UUID for preview URLs
- func (pc *PreviewCommon) GeneratePreviewID() string {
- bytes := make([]byte, 4) // 4 bytes = 8 hex chars
- if _, err := rand.Read(bytes); err != nil {
- // Fallback to timestamp-based ID if crypto/rand fails
- return fmt.Sprintf("%08x", time.Now().Unix()%0xFFFFFFFF)
- }
- // Convert each byte directly to hex to ensure we get truly random looking IDs
- return fmt.Sprintf("%02x%02x%02x%02x", bytes[0], bytes[1], bytes[2], bytes[3])
- }
- // CloneRepository clones a git repository to a target directory
- func (pc *PreviewCommon) CloneRepository(ctx context.Context, repoURL, branch, targetDir string) error {
- if err := os.MkdirAll(targetDir, 0755); err != nil {
- return models.NewErrInternalServer(fmt.Sprintf("failed to create target directory %s", targetDir), err)
- }
- if branch == "" {
- branch = "main"
- }
- cmd := exec.CommandContext(ctx, "git", "clone", "--depth", "1", "--branch", branch, repoURL, targetDir)
- if err := cmd.Run(); err != nil {
- // Try with master branch if main fails
- if branch == "main" {
- cmd = exec.CommandContext(ctx, "git", "clone", "--depth", "1", "--branch", "master", repoURL, targetDir)
- if err := cmd.Run(); err != nil {
- return models.NewErrInternalServer(fmt.Sprintf("failed to clone repository (tried main and master branches): %s", repoURL), err)
- }
- } else {
- return models.NewErrInternalServer(fmt.Sprintf("failed to clone repository %s on branch %s", repoURL, branch), err)
- }
- }
- return nil
- }
- // CreateBuildContext creates a tar archive of the build context
- func (pc *PreviewCommon) CreateBuildContext(ctx context.Context, contextDir string, component *models.Component) (io.ReadCloser, error) {
- var buf bytes.Buffer
- tw := tar.NewWriter(&buf)
- defer tw.Close()
- // For docker-compose components, adjust the context directory to use the build context
- effectiveContextDir := contextDir
- relativeDockerfilePath := "Dockerfile"
- if component != nil && component.SourceType == "docker-compose" && component.BuildContext != "" {
- // Resolve the build context directory
- effectiveContextDir = filepath.Join(contextDir, component.BuildContext)
- // Set Dockerfile path relative to the build context
- if component.DockerfilePath != "" {
- relativeDockerfilePath = component.DockerfilePath
- }
- pc.entry.WithFields(logrus.Fields{
- "component_id": component.ID,
- "build_context": component.BuildContext,
- "dockerfile_path": relativeDockerfilePath,
- "resolved_context_dir": effectiveContextDir,
- }).Info("Using docker-compose build context for component")
- }
- // Common ignore patterns for Git repositories
- ignorePatterns := []string{
- ".git",
- ".gitignore",
- "node_modules",
- ".next",
- "dist",
- "build",
- "target",
- "__pycache__",
- "*.pyc",
- ".DS_Store",
- "Thumbs.db",
- "*.log",
- "*.tmp",
- "*.swp",
- ".env",
- ".vscode",
- ".idea",
- "playwright",
- "cypress",
- "coverage",
- "*.test.js",
- "*.spec.js",
- "*.test.ts",
- "*.spec.ts",
- "test",
- "tests",
- "__tests__",
- "snapshots",
- "*.png",
- "*.jpg",
- "*.jpeg",
- "*.gif",
- "*.bmp",
- "*.svg",
- "*.ico",
- "*.zip",
- "*.tar.gz",
- "*.tar",
- "*.gz",
- "README.md",
- "readme.md",
- "CHANGELOG.md",
- "LICENSE",
- "CONTRIBUTING.md",
- "*.md",
- "docs",
- "documentation",
- }
- err := filepath.Walk(effectiveContextDir, func(file string, fi os.FileInfo, err error) error {
- if err != nil {
- return err
- }
- // Get relative path
- relPath, err := filepath.Rel(effectiveContextDir, file)
- if err != nil {
- return err
- }
- // Skip if matches ignore patterns
- for _, pattern := range ignorePatterns {
- if matched, _ := filepath.Match(pattern, fi.Name()); matched {
- if fi.IsDir() {
- return filepath.SkipDir
- }
- return nil
- }
- if strings.Contains(relPath, pattern) {
- if fi.IsDir() {
- return filepath.SkipDir
- }
- return nil
- }
- }
- // Skip very large files (> 100MB)
- if !fi.IsDir() && fi.Size() > 100*1024*1024 {
- pc.entry.WithField("file", relPath).WithField("size", fi.Size()).Warn("Skipping large file in build context")
- return nil
- }
- // Skip files with very long paths (> 200 chars)
- if len(relPath) > 200 {
- pc.entry.WithField("file", relPath).WithField("length", len(relPath)).Warn("Skipping file with very long path")
- return nil
- }
- // Create tar header
- header, err := tar.FileInfoHeader(fi, fi.Name())
- if err != nil {
- return err
- }
- // Update the name to be relative to the context directory
- header.Name = filepath.ToSlash(relPath)
- // Ensure header name is not too long for tar format
- if len(header.Name) > 155 {
- pc.entry.WithField("file", header.Name).WithField("length", len(header.Name)).Warn("Skipping file with tar-incompatible long name")
- return nil
- }
- // Write header
- if err := tw.WriteHeader(header); err != nil {
- return fmt.Errorf("failed to write tar header for %s: %v", relPath, err)
- }
- // If it's a file, write its content
- if !fi.IsDir() {
- data, err := os.Open(file)
- if err != nil {
- return fmt.Errorf("failed to open file %s: %v", relPath, err)
- }
- defer data.Close()
- // Use limited reader to prevent issues with very large files
- limitedReader := io.LimitReader(data, 100*1024*1024) // 100MB limit
- written, err := io.Copy(tw, limitedReader)
- if err != nil {
- pc.entry.WithField("file", relPath).WithField("written_bytes", written).Warnf("Failed to copy file to tar, skipping: %v", err)
- // Don't return error, just skip this file
- return nil
- }
- }
- return nil
- })
- if err != nil {
- return nil, err
- }
- return io.NopCloser(&buf), nil
- }
- // fileExists checks if a file exists
- func fileExists(path string) bool {
- _, err := os.Stat(path)
- return err == nil
- }
- // BuildComponentImages builds Docker images for components using shell commands
- // This simplified version uses docker build commands directly instead of Docker API
- func (pc *PreviewCommon) BuildComponentImages(ctx context.Context, components []models.Component) ([]string, string, error) {
- var imageNames []string
- var allLogs strings.Builder
- for _, component := range components {
- pc.entry.WithFields(logrus.Fields{
- "component_id": component.ID,
- "status": component.Status,
- "source_type": component.SourceType,
- "build_context": component.BuildContext,
- "dockerfile_path": component.DockerfilePath,
- "service_name": component.ServiceName,
- }).Info("Processing component for preview")
- allLogs.WriteString(fmt.Sprintf("Processing component %d (%s) - SourceType: %s, BuildContext: %s, DockerfilePath: %s\n",
- component.ID, component.Name, component.SourceType, component.BuildContext, component.DockerfilePath))
- // Generate local image name for preview
- imageName := fmt.Sprintf("byop-preview-%s:%d", component.Name, component.ID)
- // Check if the local preview image already exists using shell command
- pc.entry.WithField("component_id", component.ID).WithField("image_name", imageName).Info("Checking if local preview image exists")
- checkCmd := exec.CommandContext(ctx, "docker", "image", "inspect", imageName)
- if err := checkCmd.Run(); err == nil {
- pc.entry.WithField("component_id", component.ID).WithField("image_name", imageName).Info("Local preview image already exists, skipping build")
- allLogs.WriteString(fmt.Sprintf("Component %d already has local preview image %s, skipping build\n", component.ID, imageName))
- imageNames = append(imageNames, imageName)
- continue // Skip to next component
- } else {
- pc.entry.WithField("component_id", component.ID).WithField("image_name", imageName).WithError(err).Info("Local preview image not found, will build from source")
- }
- // For docker-compose components with public images, use them directly
- if component.SourceType == "docker-compose" && component.CurrentImageURI != "" &&
- strings.Contains(component.CurrentImageURI, ":") &&
- !strings.Contains(component.CurrentImageURI, "host.docker.internal") &&
- !strings.Contains(component.CurrentImageURI, pc.registryURL) {
- // This is likely a public image (mysql:5.7, postgres:13, etc.)
- pc.entry.WithField("component_id", component.ID).WithField("image_uri", component.CurrentImageURI).Info("Using public image directly for preview")
- allLogs.WriteString(fmt.Sprintf("Component %d using public image %s directly\n", component.ID, component.CurrentImageURI))
- imageNames = append(imageNames, component.CurrentImageURI)
- continue // Skip to next component
- }
- // Build from source code
- pc.entry.WithField("component_id", component.ID).Info("Building Docker image from source")
- allLogs.WriteString(fmt.Sprintf("Building component %d from source\n", component.ID))
- // Create temp directory for this component
- tempDir := filepath.Join(os.TempDir(), fmt.Sprintf("byop-preview-%d-%d", component.ID, time.Now().Unix()))
- defer os.RemoveAll(tempDir)
- // Clone repository
- if err := pc.CloneRepository(ctx, component.Repository, component.Branch, tempDir); err != nil {
- allLogs.WriteString(fmt.Sprintf("Failed to clone %s: %v\n", component.Repository, err))
- return nil, allLogs.String(), err
- }
- // Determine build context and dockerfile path
- buildContext := tempDir
- dockerfilePath := "Dockerfile"
- if component.SourceType == "docker-compose" && component.BuildContext != "" {
- buildContext = filepath.Join(tempDir, component.BuildContext)
- if component.DockerfilePath != "" {
- dockerfilePath = component.DockerfilePath
- }
- }
- // Check if we need to generate a Dockerfile
- fullDockerfilePath := filepath.Join(buildContext, dockerfilePath)
- if component.Status != "valid" || !fileExists(fullDockerfilePath) {
- pc.entry.WithField("component_id", component.ID).Info("Generating Dockerfile for component")
- allLogs.WriteString(fmt.Sprintf("Generating Dockerfile for component %d\n", component.ID))
- // Use analyzer to generate Dockerfile
- stack, err := analyzer.AnalyzeCode(tempDir)
- if err != nil {
- allLogs.WriteString(fmt.Sprintf("Failed to analyze code for %s: %v\n", component.Name, err))
- return nil, allLogs.String(), err
- }
- dockerfileContent, err := stack.GenerateDockerfile(tempDir)
- if err != nil {
- allLogs.WriteString(fmt.Sprintf("Failed to generate Dockerfile for %s: %v\n", component.Name, err))
- return nil, allLogs.String(), err
- }
- // Write Dockerfile (no Traefik labels here - they'll be added at runtime)
- if err := os.WriteFile(fullDockerfilePath, []byte(dockerfileContent), 0644); err != nil {
- allLogs.WriteString(fmt.Sprintf("Failed to write Dockerfile for %s: %v\n", component.Name, err))
- return nil, allLogs.String(), err
- }
- }
- // Build the image using docker build command
- pc.entry.WithField("component_id", component.ID).WithField("image_name", imageName).Info("Building Docker image")
- allLogs.WriteString(fmt.Sprintf("Building Docker image for component %d: %s\n", component.ID, imageName))
- buildCmd := exec.CommandContext(ctx, "docker", "build", "-t", imageName, "-f", dockerfilePath, buildContext)
- output, err := buildCmd.CombinedOutput()
- buildOutputStr := string(output)
- allLogs.WriteString(fmt.Sprintf("Build output for %s:\n%s\n", imageName, buildOutputStr))
- if err != nil {
- allLogs.WriteString(fmt.Sprintf("Failed to build %s: %v\n", imageName, err))
- return nil, allLogs.String(), fmt.Errorf("failed to build image %s: %v", imageName, err)
- }
- // Verify the image was built successfully
- verifyCmd := exec.CommandContext(ctx, "docker", "image", "inspect", imageName)
- if err := verifyCmd.Run(); err != nil {
- allLogs.WriteString(fmt.Sprintf("Build verification failed for %s: image not found after build - %v\n", imageName, err))
- return nil, allLogs.String(), fmt.Errorf("failed to build image %s: image not found after build", imageName)
- }
- imageNames = append(imageNames, imageName)
- pc.entry.WithField("component_id", component.ID).WithField("image_name", imageName).Info("Successfully built Docker image")
- }
- return imageNames, allLogs.String(), nil
- }
- // GetAppComponents retrieves components for an app
- func (pc *PreviewCommon) GetAppComponents(ctx context.Context, app *models.App) ([]models.Component, error) {
- var components []models.Component
- // Parse the JSON string of component IDs
- var componentIDs []uint
- if app.Components != "" {
- if err := json.Unmarshal([]byte(app.Components), &componentIDs); err != nil {
- return nil, fmt.Errorf("failed to parse component IDs from app %d: %v", app.ID, err)
- }
- }
- for _, componentID := range componentIDs {
- component, err := pc.store.GetComponentByID(ctx, componentID)
- if err != nil {
- return nil, err
- }
- if component == nil {
- return nil, models.NewErrNotFound(fmt.Sprintf("Component with ID %d not found while fetching app components", componentID), nil)
- }
- components = append(components, *component)
- }
- return components, nil
- }
- // CleanupPreviewImages cleans up BYOP preview Docker images using shell commands
- func (pc *PreviewCommon) CleanupPreviewImages(ctx context.Context) {
- pc.entry.Info("Cleaning up BYOP preview images...")
- // List all images and filter for byop-preview
- cmd := exec.CommandContext(ctx, "docker", "images", "--format", "{{.Repository}}:{{.Tag}}")
- output, err := cmd.Output()
- if err != nil {
- pc.entry.WithError(err).Error("Failed to list images for cleanup")
- return
- }
- removedCount := 0
- lines := strings.Split(string(output), "\n")
- for _, line := range lines {
- line = strings.TrimSpace(line)
- if line == "" {
- continue
- }
- if strings.Contains(line, "byop-preview") {
- // Remove the image
- rmCmd := exec.CommandContext(ctx, "docker", "rmi", line, "--force")
- if err := rmCmd.Run(); err != nil {
- pc.entry.WithError(err).WithField("image", line).Warn("Failed to remove preview image")
- } else {
- removedCount++
- }
- }
- }
- if removedCount > 0 {
- pc.entry.WithField("removed_images", removedCount).Info("Cleaned up BYOP preview images")
- }
- }
- // CleanupAllPreviewContainers cleans up all BYOP preview containers using shell commands
- func (pc *PreviewCommon) CleanupAllPreviewContainers(ctx context.Context) {
- pc.entry.Info("Cleaning up all BYOP preview containers...")
- // List all containers and filter for byop-preview
- cmd := exec.CommandContext(ctx, "docker", "ps", "-a", "--format", "{{.Names}}")
- output, err := cmd.Output()
- if err != nil {
- pc.entry.WithError(err).Error("Failed to list containers for cleanup")
- return
- }
- removedCount := 0
- lines := strings.Split(string(output), "\n")
- for _, line := range lines {
- line = strings.TrimSpace(line)
- if line == "" {
- continue
- }
- if strings.Contains(line, "byop-preview") || strings.Contains(line, "preview") {
- pc.entry.WithField("container_name", line).Info("Removing BYOP preview container")
- // Stop and remove container
- stopCmd := exec.CommandContext(ctx, "docker", "stop", line)
- stopCmd.Run() // Ignore errors for stop
- rmCmd := exec.CommandContext(ctx, "docker", "rm", "-f", line)
- if err := rmCmd.Run(); err != nil {
- pc.entry.WithError(err).WithField("container_name", line).Error("Failed to remove container")
- } else {
- removedCount++
- }
- }
- }
- if removedCount > 0 {
- pc.entry.WithField("removed_containers", removedCount).Info("Cleaned up BYOP preview containers")
- }
- }
- // CleanupPreviewState cleans up preview database state - mark all running previews as stopped
- func (pc *PreviewCommon) CleanupPreviewState(ctx context.Context) {
- pc.entry.Info("Cleaning up preview database state...")
- // Get all active previews (building, deploying, running)
- activeStatuses := []string{"building", "deploying", "running"}
- for _, status := range activeStatuses {
- previews, err := pc.store.GetPreviewsByStatus(ctx, status)
- if err != nil {
- pc.entry.WithError(err).WithField("status", status).Error("Failed to get previews by status")
- continue
- }
- for _, preview := range previews {
- 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")
- // Update preview status to stopped
- if err := pc.store.UpdatePreviewStatus(ctx, preview.ID, "stopped", "Server shutdown - containers may have been stopped"); err != nil {
- pc.entry.WithError(err).WithField("preview_id", preview.ID).Error("Failed to update preview status to stopped")
- }
- // Also update the associated app status back to "ready" if it was in a preview state
- if app, err := pc.store.GetAppByID(ctx, preview.AppID); err == nil && app != nil {
- if app.Status == "building" || app.Status == "deploying" {
- if err := pc.store.UpdateAppStatus(ctx, app.ID, "ready", ""); err != nil {
- pc.entry.WithError(err).WithField("app_id", app.ID).Error("Failed to reset app status to ready")
- } else {
- pc.entry.WithField("app_id", app.ID).Info("Reset app status to ready after preview cleanup")
- }
- }
- }
- }
- if len(previews) > 0 {
- pc.entry.WithField("count", len(previews)).WithField("status", status).Info("Updated preview statuses to stopped")
- }
- }
- pc.entry.Info("Preview database state cleanup completed")
- }
- // GetPreviewImageNames reconstructs the Docker image names used for a preview
- func (pc *PreviewCommon) GetPreviewImageNames(appID uint) ([]string, error) {
- // Get app details
- app, err := pc.store.GetAppByID(context.Background(), appID)
- if err != nil {
- return nil, fmt.Errorf("failed to get app by ID %d: %v", appID, err)
- }
- // Get all components for the app
- components, err := pc.GetAppComponents(context.Background(), app)
- if err != nil {
- return nil, fmt.Errorf("failed to get app components: %v", err)
- }
- // Reconstruct image names using the same format as BuildComponentImages
- var imageNames []string
- for _, component := range components {
- imageName := fmt.Sprintf("byop-preview-%s:%d", component.Name, component.ID)
- imageNames = append(imageNames, imageName)
- }
- return imageNames, nil
- }
- // CleanupPreviewImagesForApp cleans up Docker images for a specific app using shell commands
- func (pc *PreviewCommon) CleanupPreviewImagesForApp(ctx context.Context, appID uint, isRemote bool, ipAddress string) error {
- imageNames, err := pc.GetPreviewImageNames(appID)
- if err != nil {
- pc.entry.WithField("app_id", appID).WithError(err).Warn("Failed to get preview image names for cleanup")
- return err
- }
- if isRemote && ipAddress != "" && ipAddress != "127.0.0.1" {
- return pc.cleanupRemoteDockerImages(ctx, ipAddress, imageNames)
- } else {
- return pc.cleanupLocalDockerImages(ctx, imageNames)
- }
- }
- // cleanupLocalDockerImages removes specific Docker images locally using shell commands
- func (pc *PreviewCommon) cleanupLocalDockerImages(ctx context.Context, imageNames []string) error {
- pc.entry.WithField("image_count", len(imageNames)).Info("Cleaning up specific Docker images locally")
- for _, imageName := range imageNames {
- // Remove the image locally using docker rmi command
- cmd := exec.CommandContext(ctx, "docker", "rmi", imageName, "--force")
- if err := cmd.Run(); err != nil {
- // Log warning but don't fail the cleanup - image might already be removed or in use
- pc.entry.WithField("image_name", imageName).WithError(err).Warn("Failed to remove Docker image locally (this may be normal)")
- } else {
- pc.entry.WithField("image_name", imageName).Info("Successfully removed Docker image locally")
- }
- }
- return nil
- }
- // cleanupRemoteDockerImages removes Docker images from a VPS via SSH
- func (pc *PreviewCommon) cleanupRemoteDockerImages(ctx context.Context, ipAddress string, imageNames []string) error {
- pc.entry.WithField("ip_address", ipAddress).WithField("image_count", len(imageNames)).Info("Cleaning up Docker images on VPS")
- for _, imageName := range imageNames {
- // Remove the image
- rmImageCmd := fmt.Sprintf("docker rmi %s --force", imageName)
- pc.entry.WithField("image_name", imageName).WithField("ip_address", ipAddress).Info("Removing Docker image")
- if err := pc.executeSSHCommand(ctx, ipAddress, rmImageCmd); err != nil {
- // Log warning but don't fail the cleanup - image might already be removed or in use
- pc.entry.WithField("image_name", imageName).WithField("ip_address", ipAddress).WithError(err).Warn("Failed to remove Docker image (this may be normal)")
- } else {
- pc.entry.WithField("image_name", imageName).WithField("ip_address", ipAddress).Info("Successfully removed Docker image")
- }
- // Also remove the tar file if it exists
- tarFileName := strings.ReplaceAll(imageName, ":", "_")
- rmTarCmd := fmt.Sprintf("rm -f /tmp/%s.tar", tarFileName)
- pc.executeSSHCommand(ctx, ipAddress, rmTarCmd) // Ignore errors for tar cleanup
- }
- // Clean up any dangling images
- pc.entry.WithField("ip_address", ipAddress).Info("Cleaning up dangling Docker images")
- danglingCmd := "docker image prune -f"
- if err := pc.executeSSHCommand(ctx, ipAddress, danglingCmd); err != nil {
- pc.entry.WithField("ip_address", ipAddress).WithError(err).Warn("Failed to clean dangling images")
- }
- return nil
- }
- // executeSSHCommand executes a command on a remote VPS via SSH
- func (pc *PreviewCommon) executeSSHCommand(ctx context.Context, ipAddress, command string) error {
- pc.entry.WithField("ip_address", ipAddress).WithField("command", command).Debug("Executing SSH command")
- cmd := exec.CommandContext(ctx, "ssh", "-o", "StrictHostKeyChecking=no", ipAddress, command)
- output, err := cmd.CombinedOutput()
- if err != nil {
- pc.entry.WithField("ip_address", ipAddress).WithField("command", command).WithField("output", string(output)).WithError(err).Error("SSH command failed")
- return models.NewErrInternalServer(fmt.Sprintf("SSH command failed on %s: %s. Output: %s", ipAddress, command, string(output)), err)
- }
- if len(output) > 0 {
- pc.entry.WithField("ip_address", ipAddress).WithField("command", command).WithField("output", string(output)).Debug("SSH command output")
- }
- return nil
- }
- // Database helper methods
- func (pc *PreviewCommon) UpdatePreviewStatus(ctx context.Context, previewID uint, status, errorMsg string) {
- if err := pc.store.UpdatePreviewStatus(ctx, previewID, status, errorMsg); err != nil {
- pc.entry.WithField("preview_id", previewID).Errorf("Failed to update preview status: %v", err)
- }
- }
- func (pc *PreviewCommon) UpdatePreviewBuildLogs(ctx context.Context, previewID uint, logs string) {
- if err := pc.store.UpdatePreviewBuildLogs(ctx, previewID, logs); err != nil {
- pc.entry.WithField("preview_id", previewID).Errorf("Failed to update preview build logs: %v", err)
- }
- }
- func (pc *PreviewCommon) UpdatePreviewDeployLogs(ctx context.Context, previewID uint, logs string) {
- if err := pc.store.UpdatePreviewDeployLogs(ctx, previewID, logs); err != nil {
- pc.entry.WithField("preview_id", previewID).Errorf("Failed to update preview deploy logs: %v", err)
- }
- }
- // CleanupByAppID cleans up all BYOP preview containers and images for a specific app ID using shell commands
- func (pc *PreviewCommon) CleanupByAppID(ctx context.Context, appID uint) {
- pc.entry.WithField("app_id", appID).Info("Cleaning up BYOP preview containers...")
- // List all containers and filter for byop-preview containers with the specific app ID
- // We'll use the app ID in the container naming pattern
- appPattern := fmt.Sprintf("byop-preview-app-%d", appID)
- cmd := exec.CommandContext(ctx, "docker", "ps", "-a", "--format", "{{.Names}}")
- output, err := cmd.Output()
- if err != nil {
- pc.entry.WithError(err).Error("Failed to list containers for cleanup")
- return
- }
- removedCount := 0
- lines := strings.Split(string(output), "\n")
- for _, line := range lines {
- line = strings.TrimSpace(line)
- if line == "" {
- continue
- }
- // Check if this container is for the specific app
- if strings.Contains(line, appPattern) || (strings.Contains(line, "byop-preview") && strings.Contains(line, fmt.Sprintf("-%d-", appID))) {
- pc.entry.WithField("container_name", line).WithField("app_id", appID).Info("Removing BYOP preview container for app")
- // Stop and remove container
- stopCmd := exec.CommandContext(ctx, "docker", "stop", line)
- stopCmd.Run() // Ignore errors for stop
- rmCmd := exec.CommandContext(ctx, "docker", "rm", "-f", line)
- if err := rmCmd.Run(); err != nil {
- pc.entry.WithError(err).WithField("container_name", line).WithField("app_id", appID).Error("Failed to remove container")
- } else {
- removedCount++
- }
- }
- }
- if removedCount > 0 {
- pc.entry.WithField("removed_containers", removedCount).WithField("app_id", appID).Info("Cleaned up BYOP preview containers for app")
- }
- }
- // AddTraefikLabelsToDockerfile adds Traefik routing labels to a Dockerfile
- func (pc *PreviewCommon) AddTraefikLabelsToDockerfile(dockerfileContent, appName string, appID uint, port string) string {
- if port == "" {
- port = "3000" // Default port
- }
- // Generate the preview subdomain
- previewID := pc.GeneratePreviewID()
- subdomain := fmt.Sprintf("%s-%d-%s", appName, appID, previewID)
- // Traefik labels to add
- traefikLabels := []string{
- `LABEL traefik.enable="true"`,
- fmt.Sprintf(`LABEL traefik.http.routers.%s.rule="Host(%s.preview.byop.dev)"`, subdomain, "`"+subdomain+"`"),
- fmt.Sprintf(`LABEL traefik.http.routers.%s.entrypoints="websecure"`, subdomain),
- fmt.Sprintf(`LABEL traefik.http.routers.%s.tls.certresolver="letsencrypt"`, subdomain),
- fmt.Sprintf(`LABEL traefik.http.services.%s.loadbalancer.server.port="%s"`, subdomain, port),
- `LABEL traefik.docker.network="traefik"`,
- `LABEL byop.preview="true"`,
- fmt.Sprintf(`LABEL byop.app.id="%d"`, appID),
- fmt.Sprintf(`LABEL byop.app.name="%s"`, appName),
- fmt.Sprintf(`LABEL byop.preview.id="%s"`, previewID),
- }
- // Parse the Dockerfile content
- lines := strings.Split(dockerfileContent, "\n")
- var modifiedLines []string
- // Find the last non-empty, non-comment line to insert labels before any final CMD/ENTRYPOINT
- lastInstructionIndex := -1
- for i := len(lines) - 1; i >= 0; i-- {
- line := strings.TrimSpace(lines[i])
- if line != "" && !strings.HasPrefix(line, "#") {
- if strings.HasPrefix(strings.ToUpper(line), "CMD") ||
- strings.HasPrefix(strings.ToUpper(line), "ENTRYPOINT") {
- lastInstructionIndex = i
- break
- }
- }
- }
- // If we found a CMD/ENTRYPOINT, insert labels before it
- if lastInstructionIndex != -1 {
- modifiedLines = append(modifiedLines, lines[:lastInstructionIndex]...)
- modifiedLines = append(modifiedLines, "")
- modifiedLines = append(modifiedLines, "# Traefik labels for BYOP preview routing")
- modifiedLines = append(modifiedLines, traefikLabels...)
- modifiedLines = append(modifiedLines, "")
- modifiedLines = append(modifiedLines, lines[lastInstructionIndex:]...)
- } else {
- // No CMD/ENTRYPOINT found, just append labels at the end
- modifiedLines = append(modifiedLines, lines...)
- modifiedLines = append(modifiedLines, "")
- modifiedLines = append(modifiedLines, "# Traefik labels for BYOP preview routing")
- modifiedLines = append(modifiedLines, traefikLabels...)
- }
- return strings.Join(modifiedLines, "\n")
- }
- // DetectPortFromDockerfile attempts to detect the exposed port from a Dockerfile
- func (pc *PreviewCommon) DetectPortFromDockerfile(dockerfileContent string) string {
- lines := strings.Split(dockerfileContent, "\n")
- for _, line := range lines {
- line = strings.TrimSpace(strings.ToUpper(line))
- if strings.HasPrefix(line, "EXPOSE ") {
- // Extract port number
- parts := strings.Fields(line)
- if len(parts) >= 2 {
- port := strings.Split(parts[1], "/")[0] // Handle cases like "3000/tcp"
- return port
- }
- }
- }
- // Common default ports based on technology detection
- content := strings.ToLower(dockerfileContent)
- if strings.Contains(content, "node") || strings.Contains(content, "npm") {
- return "3000"
- } else if strings.Contains(content, "python") || strings.Contains(content, "flask") {
- return "5000"
- } else if strings.Contains(content, "django") {
- return "8000"
- } else if strings.Contains(content, "nginx") {
- return "80"
- }
- return "3000" // Default fallback
- }
- // GenerateTraefikComposeOverride creates a docker-compose override for Traefik routing
- // This is used during preview deployment to add routing labels to pre-built images
- func (pc *PreviewCommon) GenerateTraefikComposeOverride(serviceName, appName string, appID uint, port string, previewID string) string {
- if port == "" {
- port = "3000"
- }
- if previewID == "" {
- previewID = pc.GeneratePreviewID()
- }
- subdomain := fmt.Sprintf("%s-%d-%s", appName, appID, previewID)
- override := fmt.Sprintf(`version: '3.8'
- services:
- %s:
- labels:
- - traefik.enable=true
- - traefik.http.routers.%s.rule=Host(%s%s.preview.byop.dev%s)
- - traefik.http.routers.%s.entrypoints=websecure
- - traefik.http.routers.%s.tls.certresolver=letsencrypt
- - traefik.http.services.%s.loadbalancer.server.port=%s
- - traefik.docker.network=traefik
- - byop.preview=true
- - byop.app.id=%d
- - byop.app.name=%s
- - byop.preview.id=%s
- networks:
- - traefik
- - default
- networks:
- traefik:
- external: true
- `, serviceName, subdomain, "`", subdomain, "`", subdomain, subdomain, subdomain, port, appID, appName, previewID)
- return override
- }
- // GeneratePreviewComposeFile creates a complete docker-compose file for preview deployment
- // This uses pre-built component images and adds Traefik routing
- func (pc *PreviewCommon) GeneratePreviewComposeFile(app *models.App, components []models.Component, imageNames []string, previewID string) (string, error) {
- if len(components) != len(imageNames) {
- return "", fmt.Errorf("component count (%d) doesn't match image count (%d)", len(components), len(imageNames))
- }
- var services []string
- for i, component := range components {
- imageName := imageNames[i]
- serviceName := component.ServiceName
- if serviceName == "" {
- serviceName = component.Name
- }
- // Detect port from component or use default
- port := "3000" // Default port
- // Try to detect port from the built image if available
- // For now, we'll use a default since we don't store port info in Component model
- // Generate subdomain for this component
- subdomain := fmt.Sprintf("%s-%d-%s", app.Name, app.ID, previewID)
- if len(components) > 1 {
- subdomain = fmt.Sprintf("%s-%s-%d-%s", app.Name, component.Name, app.ID, previewID)
- }
- serviceConfig := fmt.Sprintf(` %s:
- image: %s
- labels:
- - traefik.enable=true
- - traefik.http.routers.%s.rule=Host(%s%s.preview.byop.dev%s)
- - traefik.http.routers.%s.entrypoints=websecure
- - traefik.http.routers.%s.tls.certresolver=letsencrypt
- - traefik.http.services.%s.loadbalancer.server.port=%s
- - traefik.docker.network=traefik
- - byop.preview=true
- - byop.app.id=%d
- - byop.app.name=%s
- - byop.preview.id=%s
- - byop.component.id=%d
- - byop.component.name=%s
- networks:
- - traefik
- - default
- restart: unless-stopped`,
- serviceName, imageName, subdomain, "`", subdomain, "`", subdomain, subdomain, subdomain, port, app.ID, app.Name, previewID, component.ID, component.Name)
- services = append(services, serviceConfig)
- }
- composeFile := fmt.Sprintf(`version: '3.8'
- services:
- %s
- networks:
- traefik:
- external: true
- `, strings.Join(services, "\n\n"))
- return composeFile, nil
- }
|