package services import ( "context" "fmt" "os" "os/exec" "path/filepath" "strings" "time" "git.linuxforward.com/byop/byop-engine/clients" "git.linuxforward.com/byop/byop-engine/config" "git.linuxforward.com/byop/byop-engine/dbstore" "git.linuxforward.com/byop/byop-engine/models" "github.com/sirupsen/logrus" ) // vpsID and ipAddress are used for local previews const vpsID = "byop.local" const ipAddress = "127.0.0.1" // LocalPreviewService handles local preview deployments using Docker Compose // // IMPORTANT: This service is intended for development and testing purposes only. // For production environments, use the RemotePreviewService which deploys to VPS instances // for proper isolation, security, and scalability. // // Local previews use: // - Docker Compose for container orchestration // - Local Traefik instance for routing // - Host Docker daemon (shared with development environment) // - localhost/127.0.0.1 networking type LocalPreviewService struct { common *PreviewCommon entry *logrus.Entry config *config.Config } // NewLocalPreviewService creates a new LocalPreviewService func NewLocalPreviewService(store *dbstore.SQLiteStore, cfg *config.Config, registryClient clients.RegistryClient, registryURL, registryUser, registryPass string) *LocalPreviewService { entry := logrus.WithField("service", "LocalPreviewService") entry.Warn("LocalPreviewService initialized - this is for development/testing only, not for production use") return &LocalPreviewService{ common: NewPreviewCommon(store, registryClient, registryURL, registryUser, registryPass), entry: entry, config: cfg, } } // Close cleans up resources func (lps *LocalPreviewService) Close(ctx context.Context) { lps.entry.Info("Cleaning up local preview service...") lps.common.CleanupAllPreviewContainers(ctx) lps.common.Close() } // CreatePreview creates a local preview environment func (lps *LocalPreviewService) CreatePreview(ctx context.Context, appId int) (*models.Preview, error) { // Get app details app, err := lps.common.GetStore().GetAppByID(ctx, appId) if err != nil { if models.IsErrNotFound(err) { return nil, models.NewErrNotFound(fmt.Sprintf("app with ID %d not found for preview creation", appId), err) } return nil, models.NewErrInternalServer(fmt.Sprintf("failed to get app by ID %d", appId), err) } if app == nil { return nil, models.NewErrNotFound(fmt.Sprintf("app with ID %d not found (unexpected nil)", appId), nil) } // Create preview record preview := models.Preview{ AppID: app.ID, Status: models.PreviewStatusBuilding, ExpiresAt: time.Now().Add(24 * time.Hour).Format(time.RFC3339), } previewID, err := lps.common.GetStore().CreatePreview(ctx, &preview) if err != nil { if _, ok := err.(models.CustomError); !ok { return nil, models.NewErrInternalServer("failed to create preview record in db", err) } return nil, err } preview.ID = previewID // Start async build and deploy locally go lps.buildAndDeployPreview(context.Background(), preview, app) return &preview, nil } func (lps *LocalPreviewService) buildAndDeployPreview(ctx context.Context, preview models.Preview, app *models.App) { lps.entry.WithField("preview_id", preview.ID).Info("Starting local preview build and deployment") // Get all components for the app lps.entry.WithField("preview_id", preview.ID).Info("Getting app components") components, err := lps.common.GetAppComponents(ctx, app) if err != nil { lps.entry.WithField("preview_id", preview.ID).Errorf("Failed to get app components: %v", err) lps.common.UpdatePreviewStatus(ctx, preview.ID, models.PreviewStatusFailed, fmt.Sprintf("Failed to get app components: %v", err)) lps.common.GetStore().UpdateAppStatus(ctx, app.ID, models.AppStatusFailed, fmt.Sprintf("Preview creation failed: %v", err)) return } lps.entry.WithField("preview_id", preview.ID).WithField("component_count", len(components)).Info("Successfully retrieved app components") // Step 1: Build Docker images locally lps.entry.WithField("preview_id", preview.ID).Info("Starting Docker image build phase") imageNames, buildLogs, err := lps.common.BuildComponentImages(ctx, components) if err != nil { lps.entry.WithField("preview_id", preview.ID).Errorf("Failed to build component images: %v", err) lps.common.UpdatePreviewStatus(ctx, preview.ID, models.PreviewStatusFailed, fmt.Sprintf("Failed to build images: %v", err)) lps.common.UpdatePreviewBuildLogs(ctx, preview.ID, buildLogs) lps.common.GetStore().UpdateAppStatus(ctx, app.ID, models.AppStatusFailed, fmt.Sprintf("Preview build failed: %v", err)) return } lps.entry.WithField("preview_id", preview.ID).WithField("image_count", len(imageNames)).Info("Docker image build phase completed successfully") lps.common.UpdatePreviewBuildLogs(ctx, preview.ID, buildLogs) // Step 2: Local deployment setup lps.entry.WithField("preview_id", preview.ID).Info("Starting local deployment phase") lps.common.UpdatePreviewStatus(ctx, preview.ID, models.PreviewStatusDeploying, "") // Generate unique preview ID and URL previewIDStr := lps.common.GeneratePreviewID() previewURL := fmt.Sprintf("https://%s.%s", previewIDStr, lps.config.PreviewTLD) lps.entry.WithField("preview_id", preview.ID).WithField("preview_url", previewURL).WithField("uuid", previewIDStr).Info("Generated local preview URL") // Update preview with local info if err := lps.common.GetStore().UpdatePreviewVPS(ctx, preview.ID, vpsID, ipAddress, previewURL); err != nil { lps.entry.WithField("preview_id", preview.ID).Errorf("Failed to update preview info: %v", err) } // Step 3: Deploy locally lps.entry.WithField("preview_id", preview.ID).Info("Starting local container deployment") deployLogs, err := lps.deployLocally(ctx, imageNames, app, previewIDStr) if err != nil { lps.entry.WithField("preview_id", preview.ID).Errorf("Failed to deploy locally: %v", err) lps.common.UpdatePreviewStatus(ctx, preview.ID, models.PreviewStatusFailed, fmt.Sprintf("Failed to deploy locally: %v", err)) lps.common.UpdatePreviewDeployLogs(ctx, preview.ID, deployLogs) lps.common.GetStore().UpdateAppStatus(ctx, app.ID, models.AppStatusFailed, fmt.Sprintf("Local deployment failed: %v", err)) return } lps.entry.WithField("preview_id", preview.ID).Info("Local deployment completed successfully") lps.common.UpdatePreviewDeployLogs(ctx, preview.ID, deployLogs) lps.common.UpdatePreviewStatus(ctx, preview.ID, models.PreviewStatusRunning, "") // Update app status to ready with preview info lps.common.GetStore().UpdateAppPreview(ctx, app.ID, preview.ID, previewURL) lps.entry.WithField("preview_id", preview.ID).WithField("preview_url", previewURL).Info("Local preview deployment completed successfully") } func (lps *LocalPreviewService) deployLocally(ctx context.Context, imageNames []string, app *models.App, previewIDStr string) (string, error) { var logs strings.Builder lps.entry.WithField("app_id", app.ID).WithField("app_name", app.Name).Info("Starting local deployment") logs.WriteString("Starting local deployment...\n") // Generate docker-compose content composeContent, err := lps.generatePreviewDockerCompose(ctx, imageNames, app, previewIDStr) if err != nil { lps.entry.WithField("app_id", app.ID).Errorf("Failed to generate compose file: %v", err) if _, ok := err.(models.CustomError); !ok { err = models.NewErrInternalServer("failed to generate compose file", err) } return logs.String(), err } // Save docker-compose.yml locally (temp file for execution) composeFile := filepath.Join(os.TempDir(), fmt.Sprintf("docker-compose-preview-%s.yml", app.Name)) // Also save to a persistent debug location debugDir := "/tmp/byop-debug" if err := os.MkdirAll(debugDir, 0755); err != nil { lps.entry.WithField("app_id", app.ID).Warnf("Failed to create debug directory: %v", err) } debugComposeFile := filepath.Join(debugDir, fmt.Sprintf("docker-compose-app-%d-preview-%d.yml", app.ID, time.Now().Unix())) // Write the temporary file if err := os.WriteFile(composeFile, []byte(composeContent), 0644); err != nil { lps.entry.WithField("app_id", app.ID).Errorf("Failed to write compose file: %v", err) return logs.String(), models.NewErrInternalServer(fmt.Sprintf("failed to write compose file %s", composeFile), err) } defer os.Remove(composeFile) // Write the debug file (persistent) if err := os.WriteFile(debugComposeFile, []byte(composeContent), 0644); err != nil { lps.entry.WithField("app_id", app.ID).Warnf("Failed to write debug compose file: %v", err) } else { lps.entry.WithField("app_id", app.ID).WithField("debug_file", debugComposeFile).Info("Wrote debug compose file for inspection") logs.WriteString(fmt.Sprintf("Debug compose file saved to: %s\n", debugComposeFile)) } logs.WriteString(fmt.Sprintf("Generated compose file: %s\n", composeFile)) logs.WriteString(fmt.Sprintf("Compose content:\n%s\n", composeContent)) // Check if Traefik network exists, create if it doesn't lps.entry.WithField("app_id", app.ID).Info("Checking/creating Traefik network") cmdCtx, cancelCmd := context.WithTimeout(ctx, 15*time.Second) defer cancelCmd() cmd := exec.CommandContext(cmdCtx, "docker", "network", "create", "traefik") if err := cmd.Run(); err != nil { lps.entry.WithField("app_id", app.ID).Warnf("Failed to create traefik network (may already exist): %v", err) logs.WriteString(fmt.Sprintf("Network creation output: %v (this is normal if network exists)\n", err)) } else { lps.entry.WithField("app_id", app.ID).Info("Created traefik network") logs.WriteString("Created traefik network\n") } // Start containers using docker-compose lps.entry.WithField("app_id", app.ID).WithField("compose_file", composeFile).Info("Starting containers with docker-compose") cmdCtxComposeUp, cancelComposeUp := context.WithTimeout(ctx, 2*time.Minute) defer cancelComposeUp() cmd = exec.CommandContext(cmdCtxComposeUp, "docker-compose", "-f", composeFile, "up", "-d") cmd.Dir = os.TempDir() output, err := cmd.CombinedOutput() logs.WriteString(fmt.Sprintf("Docker-compose output:\n%s\n", string(output))) if err != nil { lps.entry.WithField("app_id", app.ID).Errorf("Failed to start containers: %v", err) lps.entry.WithField("app_id", app.ID).Errorf("Docker-compose error output: %s", string(output)) logs.WriteString(fmt.Sprintf("ERROR: Docker-compose failed with: %v\n", err)) return logs.String(), models.NewErrInternalServer(fmt.Sprintf("docker-compose up failed for app %d", app.ID), err) } lps.entry.WithField("app_id", app.ID).Info("Successfully started containers") // Verify containers are running cmdCtxPs, cancelPs := context.WithTimeout(ctx, 30*time.Second) defer cancelPs() cmd = exec.CommandContext(cmdCtxPs, "docker-compose", "-f", composeFile, "ps") output, err = cmd.CombinedOutput() if err != nil { lps.entry.WithField("app_id", app.ID).Warnf("Failed to check container status: %v", err) logs.WriteString(fmt.Sprintf("Warning: failed to check container status: %v\n", err)) } else { logs.WriteString(fmt.Sprintf("Container status:\n%s\n", string(output))) } logs.WriteString("Local deployment completed successfully\n") logs.WriteString(fmt.Sprintf("Debug compose file: %s\n", debugComposeFile)) return logs.String(), nil } func (lps *LocalPreviewService) generatePreviewDockerCompose(ctx context.Context, imageNames []string, app *models.App, previewIDStr string) (string, error) { lps.entry.WithField("app_id", app.ID).WithField("image_count", len(imageNames)).Info("Generating docker-compose content") compose := "services:\n" for i, imageName := range imageNames { serviceName := fmt.Sprintf("service-%d", i) compose += fmt.Sprintf(" %s:\n", serviceName) compose += fmt.Sprintf(" image: %s\n", imageName) compose += " restart: unless-stopped\n" compose += " environment:\n" compose += " - NODE_ENV=preview\n" compose += fmt.Sprintf(" - APP_NAME=%s\n", app.Name) compose += " labels:\n" compose += " - \"byop.preview=true\"\n" compose += fmt.Sprintf(" - \"byop.preview.id=%s\"\n", previewIDStr) compose += fmt.Sprintf(" - \"byop.app.id=%d\"\n", app.ID) compose += fmt.Sprintf(" - \"byop.app.name=%s\"\n", app.Name) if i == 0 { previewDomain := fmt.Sprintf("%s.%s", previewIDStr, lps.config.PreviewTLD) routerName := fmt.Sprintf("local-preview-%s", previewIDStr) compose += " - \"traefik.enable=true\"\n" compose += fmt.Sprintf(" - \"traefik.http.routers.%s.rule=Host(`%s`)\"\n", routerName, previewDomain) compose += fmt.Sprintf(" - \"traefik.http.routers.%s.entrypoints=websecure\"\n", routerName) compose += fmt.Sprintf(" - \"traefik.http.routers.%s.tls=true\"\n", routerName) compose += fmt.Sprintf(" - \"traefik.http.routers.%s.tls.certresolver=tlsresolver\"\n", routerName) compose += " - \"traefik.docker.network=traefik\"\n" } compose += " networks:\n" compose += " - traefik\n" compose += "\n" } compose += "networks:\n" compose += " traefik:\n" compose += " external: true\n" return compose, nil } // DeletePreview deletes a local preview func (lps *LocalPreviewService) DeletePreview(ctx context.Context, appID int) error { preview, err := lps.common.GetStore().GetPreviewByAppID(ctx, appID) if err != nil { if models.IsErrNotFound(err) { return models.NewErrNotFound(fmt.Sprintf("preview for app ID %d not found for deletion", appID), err) } return models.NewErrInternalServer(fmt.Sprintf("failed to get preview by app ID %d for deletion", appID), err) } if preview == nil { return models.NewErrNotFound(fmt.Sprintf("preview with app ID %d not found for deletion (unexpected nil)", appID), nil) } lps.entry.WithField("preview_id", preview.ID).Info("Deleting local preview") lps.common.CleanupByAppID(ctx, appID) if err := lps.common.GetStore().DeletePreview(ctx, preview.ID); err != nil { if models.IsErrNotFound(err) { return models.NewErrNotFound(fmt.Sprintf("preview %d not found for deletion from DB", preview.ID), err) } return models.NewErrInternalServer(fmt.Sprintf("failed to delete preview %d from database", preview.ID), err) } lps.entry.WithField("preview_id", preview.ID).Info("Successfully deleted local preview") return nil } // StopPreview stops a local preview func (lps *LocalPreviewService) StopPreview(ctx context.Context, previewID int) error { preview, err := lps.common.GetStore().GetPreviewByID(ctx, previewID) if err != nil { if models.IsErrNotFound(err) { return models.NewErrNotFound(fmt.Sprintf("preview with ID %d not found for stopping", previewID), err) } return models.NewErrInternalServer(fmt.Sprintf("failed to get preview by ID %d for stopping", previewID), err) } if preview == nil { return models.NewErrNotFound(fmt.Sprintf("preview with ID %d not found for stopping (unexpected nil)", previewID), nil) } lps.common.CleanupByAppID(ctx, preview.AppID) err = lps.common.GetStore().UpdatePreviewStatus(ctx, previewID, models.PreviewStatusStopped, "") if err != nil { if models.IsErrNotFound(err) { return models.NewErrNotFound(fmt.Sprintf("preview %d not found for status update to stopped", previewID), err) } return models.NewErrInternalServer(fmt.Sprintf("failed to update preview %d status to stopped", previewID), err) } return nil }