123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348 |
- 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
- }
|