package services import ( "archive/tar" "bytes" "context" "crypto/rand" "encoding/base64" "encoding/json" "fmt" "io" "os" "os/exec" "path/filepath" "strings" "time" "git.linuxforward.com/byop/byop-engine/clients" "git.linuxforward.com/byop/byop-engine/dbstore" "git.linuxforward.com/byop/byop-engine/models" "github.com/sirupsen/logrus" "github.com/docker/docker/api/types" "github.com/docker/docker/api/types/container" "github.com/docker/docker/api/types/filters" "github.com/docker/docker/api/types/image" "github.com/docker/docker/api/types/registry" docker "github.com/docker/docker/client" ) // PreviewService defines the interface for preview services type PreviewService interface { CreatePreview(ctx context.Context, appId int) (*models.Preview, error) DeletePreview(ctx context.Context, appID int) error StopPreview(ctx context.Context, previewID int) error Close(ctx context.Context) // Updated signature to include context } // PreviewCommon contains shared functionality for preview services type PreviewCommon struct { store *dbstore.SQLiteStore entry *logrus.Entry dockerClient *docker.Client registryClient clients.RegistryClient registryURL string registryUser string registryPass string } // NewPreviewCommon creates a new PreviewCommon instance func NewPreviewCommon(store *dbstore.SQLiteStore, registryClient clients.RegistryClient, registryURL, registryUser, registryPass string) *PreviewCommon { dockerClient, err := docker.NewClientWithOpts(docker.FromEnv, docker.WithAPIVersionNegotiation()) if err != nil { logrus.WithError(err).Fatal("Failed to create Docker client") } return &PreviewCommon{ store: store, entry: logrus.WithField("service", "PreviewCommon"), dockerClient: dockerClient, registryClient: registryClient, registryURL: registryURL, registryUser: registryUser, registryPass: registryPass, } } // Close cleans up the Docker client connection func (pc *PreviewCommon) Close() { // Clean up BYOP preview images pc.CleanupPreviewImages(context.Background()) // Clean up preview database state pc.CleanupPreviewState(context.Background()) // Close the Docker client connection if pc.dockerClient != nil { if err := pc.dockerClient.Close(); err != nil { pc.entry.WithError(err).Error("Failed to close Docker client") } else { pc.entry.Info("Docker client connection closed") } } } // GetDockerClient returns the Docker client func (pc *PreviewCommon) GetDockerClient() *docker.Client { return pc.dockerClient } // 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) (io.ReadCloser, error) { var buf bytes.Buffer tw := tar.NewWriter(&buf) defer tw.Close() // 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(contextDir, func(file string, fi os.FileInfo, err error) error { if err != nil { return err } // Get relative path relPath, err := filepath.Rel(contextDir, 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 } // createDockerIgnore creates a .dockerignore file in the specified directory func (pc *PreviewCommon) createDockerIgnore(ctx context.Context, contextDir string) { dockerignoreContent := `# Auto-generated by BYOP Engine .git .gitignore node_modules .next dist build target __pycache__ *.pyc .DS_Store Thumbs.db *.log *.tmp *.swp .env .vscode .idea playwright cypress coverage test tests __tests__ snapshots *.test.js *.spec.js *.test.ts *.spec.ts *.png *.jpg *.jpeg *.gif *.bmp *.svg *.ico *.zip *.tar.gz *.tar *.gz README.md readme.md CHANGELOG.md LICENSE CONTRIBUTING.md *.md docs documentation ` dockerignorePath := filepath.Join(contextDir, ".dockerignore") if err := os.WriteFile(dockerignorePath, []byte(dockerignoreContent), 0644); err != nil { pc.entry.WithField("path", dockerignorePath).Warnf("Failed to create .dockerignore file: %v", err) } else { pc.entry.WithField("path", dockerignorePath).Debug("Created .dockerignore file") } } // validateAndFixDockerfile checks for unsupported Dockerfile syntax and fixes common issues func (pc *PreviewCommon) validateAndFixDockerfile(ctx context.Context, contextDir string) error { dockerfilePath := filepath.Join(contextDir, "Dockerfile") // Check if Dockerfile exists if _, err := os.Stat(dockerfilePath); os.IsNotExist(err) { return fmt.Errorf("dockerfile not found in repository") } // Read the Dockerfile content, err := os.ReadFile(dockerfilePath) if err != nil { return fmt.Errorf("failed to read Dockerfile: %v", err) } originalContent := string(content) modifiedContent := originalContent modified := false // Fix common issues lines := strings.Split(originalContent, "\n") var fixedLines []string for i, line := range lines { trimmedLine := strings.TrimSpace(line) // Check for unsupported --exclude flag in COPY or ADD commands if strings.HasPrefix(trimmedLine, "COPY") || strings.HasPrefix(trimmedLine, "ADD") { if strings.Contains(trimmedLine, "--exclude") { pc.entry.WithField("line", i+1).Warn("Found unsupported --exclude flag in Dockerfile, removing it") // Remove --exclude flag and its arguments parts := strings.Fields(trimmedLine) var cleanedParts []string skipNext := false for _, part := range parts { if skipNext { skipNext = false continue } if strings.HasPrefix(part, "--exclude") { if strings.Contains(part, "=") { // --exclude=pattern format continue } else { // --exclude pattern format skipNext = true continue } } cleanedParts = append(cleanedParts, part) } fixedLine := strings.Join(cleanedParts, " ") fixedLines = append(fixedLines, fixedLine) modified = true pc.entry.WithField("original", trimmedLine).WithField("fixed", fixedLine).Info("Fixed Dockerfile line") } else { fixedLines = append(fixedLines, line) } } else { fixedLines = append(fixedLines, line) } } // Write back the fixed Dockerfile if modified if modified { modifiedContent = strings.Join(fixedLines, "\n") if err := os.WriteFile(dockerfilePath, []byte(modifiedContent), 0644); err != nil { return fmt.Errorf("failed to write fixed Dockerfile: %v", err) } pc.entry.WithField("path", dockerfilePath).Info("Fixed Dockerfile syntax issues") } return nil } // isDockerfilePresent checks if a Dockerfile exists in the repository directory func (pc *PreviewCommon) isDockerfilePresent(tempDir string) (bool, error) { // Check for common Dockerfile names dockerfileNames := []string{"Dockerfile", "dockerfile", "Dockerfile.prod", "Dockerfile.production"} for _, name := range dockerfileNames { dockerfilePath := filepath.Join(tempDir, name) if _, err := os.Stat(dockerfilePath); err == nil { pc.entry.WithField("dockerfile_path", dockerfilePath).Debug("Found Dockerfile") return true, nil } } pc.entry.WithField("temp_dir", tempDir).Debug("No Dockerfile found") return false, nil } // BuildComponentImages builds Docker images for components // It first checks for pre-built images in the registry before rebuilding from source 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.WithField("component_id", component.ID).WithField("status", component.Status).Info("Processing component for preview") // Generate local image name for preview imageName := fmt.Sprintf("byop-preview-%s:%d", component.Name, component.ID) // Check if component has pre-built image information if component.CurrentImageURI != "" && component.CurrentImageTag != "" { pc.entry.WithField("component_id", component.ID).WithField("image_uri", component.CurrentImageURI).Info("Component has pre-built image, checking registry") allLogs.WriteString(fmt.Sprintf("Component %d has pre-built image %s, checking availability\n", component.ID, component.CurrentImageURI)) // Check if the pre-built image exists in the registry if pc.registryClient != nil && pc.registryURL != "" { exists, err := pc.registryClient.CheckImageExists(ctx, component.CurrentImageURI, pc.registryURL, pc.registryUser, pc.registryPass) if err != nil { pc.entry.WithField("component_id", component.ID).WithError(err).Warn("Failed to check if pre-built image exists, falling back to rebuild") allLogs.WriteString(fmt.Sprintf("Failed to check registry image for component %d: %v, rebuilding from source\n", component.ID, err)) } else if exists { // Pull the pre-built image from registry to local Docker if err := pc.pullPreBuiltImage(ctx, component.CurrentImageURI, imageName); err != nil { pc.entry.WithField("component_id", component.ID).WithError(err).Warn("Failed to pull pre-built image, falling back to rebuild") allLogs.WriteString(fmt.Sprintf("Failed to pull pre-built image for component %d: %v, rebuilding from source\n", component.ID, err)) } else { pc.entry.WithField("component_id", component.ID).WithField("image_name", imageName).Info("Successfully used pre-built image") allLogs.WriteString(fmt.Sprintf("Successfully pulled and tagged pre-built image for component %d as %s\n", component.ID, imageName)) imageNames = append(imageNames, imageName) continue // Skip to next component } } else { pc.entry.WithField("component_id", component.ID).Info("Pre-built image not found in registry, rebuilding from source") allLogs.WriteString(fmt.Sprintf("Pre-built image for component %d not found in registry, rebuilding from source\n", component.ID)) } } else { pc.entry.WithField("component_id", component.ID).Warn("Registry client not configured, cannot check pre-built images") allLogs.WriteString(fmt.Sprintf("Registry not configured for component %d, rebuilding from source\n", component.ID)) } } else { pc.entry.WithField("component_id", component.ID).Info("Component has no pre-built image information, building from source") allLogs.WriteString(fmt.Sprintf("Component %d has no pre-built image, building from source\n", component.ID)) } // Fallback: 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 } // Special handling for components with existing Dockerfiles (status "valid") if component.Status == "valid" { pc.entry.WithField("component_id", component.ID).Info("Component has existing Dockerfile, building directly") allLogs.WriteString(fmt.Sprintf("Component %d has existing Dockerfile, building directly\n", component.ID)) // For components with existing Dockerfiles, just use the Dockerfile as-is // No need to validate/fix or create .dockerignore since they should work as-is } else { // For components without existing Dockerfiles (generated via LLB), apply fixes pc.entry.WithField("component_id", component.ID).Info("Component using generated Dockerfile, applying fixes") // Create .dockerignore file to exclude unnecessary files pc.createDockerIgnore(ctx, tempDir) // Check and fix Dockerfile if needed if err := pc.validateAndFixDockerfile(ctx, tempDir); err != nil { allLogs.WriteString(fmt.Sprintf("Failed to validate Dockerfile for component %d: %v\n", component.ID, err)) return nil, allLogs.String(), err } } // Create a tar archive of the build context pc.entry.WithField("component_id", component.ID).Info("Creating build context tar archive") tarReader, err := pc.CreateBuildContext(ctx, tempDir) if err != nil { errMsg := fmt.Sprintf("Failed to create build context for %s: %v", imageName, err) pc.entry.WithField("component_id", component.ID).Error(errMsg) allLogs.WriteString(errMsg + "\n") return nil, allLogs.String(), err } defer tarReader.Close() pc.entry.WithField("component_id", component.ID).WithField("image_name", imageName).Info("Starting Docker image build") buildResponse, err := pc.dockerClient.ImageBuild(ctx, tarReader, types.ImageBuildOptions{ Tags: []string{imageName}, Dockerfile: "Dockerfile", Remove: true, ForceRemove: true, }) if err != nil { errMsg := fmt.Sprintf("Failed to start build for %s: %v", imageName, err) pc.entry.WithField("component_id", component.ID).Error(errMsg) allLogs.WriteString(errMsg + "\n") return nil, allLogs.String(), err } defer buildResponse.Body.Close() // Read and parse build output properly buildOutput, err := io.ReadAll(buildResponse.Body) if err != nil { allLogs.WriteString(fmt.Sprintf("Failed to read build output for %s: %v\n", imageName, err)) return nil, allLogs.String(), err } buildOutputStr := string(buildOutput) allLogs.WriteString(fmt.Sprintf("Building %s:\n%s\n", imageName, buildOutputStr)) // Check for Docker build errors in JSON output buildSuccess := false buildErrorFound := false // Parse each line of JSON output lines := strings.Split(buildOutputStr, "\n") for _, line := range lines { line = strings.TrimSpace(line) if line == "" { continue } // Look for success indicators if strings.Contains(line, `"stream":"Successfully built`) || strings.Contains(line, `"stream":"Successfully tagged`) { buildSuccess = true } // Look for error indicators if strings.Contains(line, `"error"`) || strings.Contains(line, `"errorDetail"`) || strings.Contains(line, `"stream":"ERROR`) || strings.Contains(line, `"stream":"The command"`) && strings.Contains(line, "returned a non-zero code") { buildErrorFound = true allLogs.WriteString(fmt.Sprintf("Build error detected in line: %s\n", line)) } } if buildErrorFound { allLogs.WriteString(fmt.Sprintf("Build failed for %s: errors found in build output\n", imageName)) return nil, allLogs.String(), fmt.Errorf("docker build failed for %s: check build logs", imageName) } if !buildSuccess { allLogs.WriteString(fmt.Sprintf("Build failed for %s: no success indicators found in build output\n", imageName)) return nil, allLogs.String(), fmt.Errorf("docker build failed for %s: build did not complete successfully", imageName) } // Verify the image exists and is properly tagged _, err = pc.dockerClient.ImageInspect(ctx, imageName) if 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 } // pullPreBuiltImage pulls a pre-built image from the registry and tags it for local use func (pc *PreviewCommon) pullPreBuiltImage(ctx context.Context, registryImageURI, localImageName string) error { pc.entry.WithField("registry_image", registryImageURI).WithField("local_image", localImageName).Info("Pulling pre-built image from registry") // Pull the image from registry pullOptions := image.PullOptions{} // Add authentication if registry credentials are configured if pc.registryUser != "" && pc.registryPass != "" { authConfig := registry.AuthConfig{ Username: pc.registryUser, Password: pc.registryPass, } encodedJSON, err := json.Marshal(authConfig) if err != nil { return fmt.Errorf("failed to encode registry auth: %w", err) } pullOptions.RegistryAuth = base64.URLEncoding.EncodeToString(encodedJSON) } reader, err := pc.dockerClient.ImagePull(ctx, registryImageURI, pullOptions) if err != nil { return fmt.Errorf("failed to pull image %s: %w", registryImageURI, err) } defer reader.Close() // Read the pull output (similar to build output) pullOutput, err := io.ReadAll(reader) if err != nil { return fmt.Errorf("failed to read pull output: %w", err) } pc.entry.WithField("pull_output", string(pullOutput)).Debug("Image pull completed") // Tag the pulled image with the local preview tag err = pc.dockerClient.ImageTag(ctx, registryImageURI, localImageName) if err != nil { return fmt.Errorf("failed to tag image %s as %s: %w", registryImageURI, localImageName, err) } // Verify the image is now available locally _, err = pc.dockerClient.ImageInspect(ctx, localImageName) if err != nil { return fmt.Errorf("failed to verify locally tagged image %s: %w", localImageName, err) } pc.entry.WithField("local_image", localImageName).Info("Successfully pulled and tagged pre-built image") return nil } // GetAppComponents retrieves components for an app func (pc *PreviewCommon) GetAppComponents(ctx context.Context, app *models.App) ([]models.Component, error) { var components []models.Component for _, componentID := range app.Components { 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 func (pc *PreviewCommon) CleanupPreviewImages(ctx context.Context) { pc.entry.Info("Cleaning up BYOP preview images...") images, err := pc.dockerClient.ImageList(ctx, image.ListOptions{All: true}) if err != nil { pc.entry.WithError(err).Error("Failed to list images for cleanup") return } removedCount := 0 for _, img := range images { // Check if image name contains "byop-preview" isPreviewImage := false for _, tag := range img.RepoTags { if strings.Contains(tag, "byop-preview") { isPreviewImage = true break } } if !isPreviewImage { continue } // Remove the image if _, err := pc.dockerClient.ImageRemove(ctx, img.ID, image.RemoveOptions{ Force: true, PruneChildren: true, }); err != nil { pc.entry.WithError(err).WithField("image_id", img.ID).Warn("Failed to remove preview image") } else { removedCount++ } } if removedCount > 0 { pc.entry.WithField("removed_images", removedCount).Info("Cleaned up BYOP preview images") } } // CleanupByAppID cleans up all BYOP preview containers and images for a specific app ID func (pc *PreviewCommon) CleanupByAppID(ctx context.Context, appID int) { pc.entry.WithField("app_id", appID).Info("Cleaning up BYOP preview containers...") // List all containers containers, err := pc.dockerClient.ContainerList(ctx, container.ListOptions{All: true}) if err != nil { pc.entry.WithError(err).Error("Failed to list containers for cleanup") return } for _, ctn := range containers { isPreviewContainer := false containerName := "" // Check if the container is a BYOP preview container for key, value := range ctn.Labels { if key == "byop.preview" && value == "true" { isPreviewContainer = true if len(ctn.Names) > 0 { containerName = ctn.Names[0] } break } } if !isPreviewContainer { continue } if ctn.Labels["byop.app.id"] != fmt.Sprintf("%d", appID) { continue // Only clean up containers for the specified app ID } pc.entry.WithField("container_id", ctn.ID).WithField("container_name", containerName).Info("Removing BYOP preview container") // Remove the container if err := pc.dockerClient.ContainerRemove(ctx, ctn.ID, container.RemoveOptions{ Force: true, }); err != nil { pc.entry.WithError(err).WithField("container_id", ctn.ID).Warn("Failed to remove preview container") } else { pc.entry.WithField("container_id", ctn.ID).Info("Successfully removed preview container") } } } // CleanupAllPreviewContainers cleans up all BYOP preview containers func (pc *PreviewCommon) CleanupAllPreviewContainers(ctx context.Context) { pc.entry.Info("Cleaning up all BYOP preview containers...") // Get all containers with filters for BYOP preview containers containers, err := pc.dockerClient.ContainerList(ctx, container.ListOptions{ All: true, // Include stopped containers too Filters: filters.NewArgs( filters.Arg("label", "byop.preview=true"), ), }) if err != nil { pc.entry.WithError(err).Error("Failed to list BYOP preview containers") // Fallback to name-based filtering if labels don't work pc.cleanupByName(ctx) return } if len(containers) == 0 { pc.entry.Info("No BYOP preview containers found to cleanup") } else { pc.entry.WithField("container_count", len(containers)).Info("Found BYOP preview containers to cleanup") } // Remove BYOP preview containers for _, ctn := range containers { containerName := "unknown" if len(ctn.Names) > 0 { containerName = strings.TrimPrefix(ctn.Names[0], "/") } pc.entry.WithField("container_id", ctn.ID).WithField("container_name", containerName).Info("Removing BYOP preview container") // Stop container first if it's running if ctn.State == "running" { if err := pc.dockerClient.ContainerStop(ctx, ctn.ID, container.StopOptions{}); err != nil { pc.entry.WithError(err).WithField("container_id", ctn.ID).Warn("Failed to stop container, will force remove") } } // Remove container if err := pc.dockerClient.ContainerRemove(ctx, ctn.ID, container.RemoveOptions{ Force: true, RemoveVolumes: true, }); err != nil { pc.entry.WithError(err).WithField("container_id", ctn.ID).Error("Failed to remove BYOP preview container") } else { pc.entry.WithField("container_id", ctn.ID).WithField("container_name", containerName).Info("Successfully removed BYOP preview container") } } } // Fallback method to cleanup containers by name pattern func (pc *PreviewCommon) cleanupByName(ctx context.Context) { pc.entry.Info("Using fallback name-based container cleanup") containers, err := pc.dockerClient.ContainerList(ctx, container.ListOptions{All: true}) if err != nil { pc.entry.WithError(err).Error("Failed to list containers for name-based cleanup") return } for _, ctn := range containers { // Check if any container name contains "byop-preview" isPreviewContainer := false containerName := "unknown" for _, name := range ctn.Names { cleanName := strings.TrimPrefix(name, "/") if strings.Contains(cleanName, "byop-preview") || strings.Contains(cleanName, "preview") { isPreviewContainer = true containerName = cleanName break } } if !isPreviewContainer { continue } pc.entry.WithField("container_id", ctn.ID).WithField("container_name", containerName).Info("Removing BYOP preview container (name-based)") // Stop and remove if ctn.State == "running" { pc.dockerClient.ContainerStop(ctx, ctn.ID, container.StopOptions{}) } if err := pc.dockerClient.ContainerRemove(ctx, ctn.ID, container.RemoveOptions{ Force: true, RemoveVolumes: true, }); err != nil { pc.entry.WithError(err).WithField("container_id", ctn.ID).Error("Failed to remove container") } } } // 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 int) ([]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 (works for both local and remote) func (pc *PreviewCommon) CleanupPreviewImagesForApp(ctx context.Context, appID int, 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 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 client if _, err := pc.dockerClient.ImageRemove(ctx, imageName, image.RemoveOptions{ Force: true, PruneChildren: true, }); 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 int, 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 int, 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 int, 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) } }