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 }