package services import ( "context" "fmt" "os" "strings" "git.linuxforward.com/byop/byop-engine/dbstore" "git.linuxforward.com/byop/byop-engine/models" git "github.com/go-git/go-git/v5" "github.com/go-git/go-git/v5/plumbing" "github.com/sirupsen/logrus" ) // AppImporter handles the import of applications from docker-compose files type AppImporter struct { store *dbstore.SQLiteStore parser *ComposeParser builderSvc *Builder entry *logrus.Entry registryURL string } // NewAppImporter creates a new AppImporter instance func NewAppImporter(store *dbstore.SQLiteStore, builderSvc *Builder, registryURL string) *AppImporter { return &AppImporter{ store: store, parser: NewComposeParser(), builderSvc: builderSvc, entry: logrus.WithField("service", "AppImporter"), registryURL: registryURL, } } // ReviewComposeFile reviews a docker-compose file from a git repository func (ai *AppImporter) ReviewComposeFile(ctx context.Context, req models.AppImportRequest) (*models.AppImportReview, error) { ai.entry.Infof("Reviewing compose file from %s (branch: %s, path: %s)", req.SourceURL, req.Branch, req.ComposePath) // Create temporary directory for cloning tempDir, err := os.MkdirTemp("", "byop-import-*") if err != nil { return nil, fmt.Errorf("failed to create temp directory: %w", err) } defer os.RemoveAll(tempDir) // Clone the repository if err := ai.cloneRepository(req.SourceURL, req.Branch, tempDir); err != nil { return &models.AppImportReview{ Valid: false, Error: fmt.Sprintf("Failed to clone repository: %v", err), }, nil } // Set default compose path if not provided composePath := req.ComposePath if composePath == "" { composePath = "docker-compose.yml" } // Parse the compose file review, err := ai.parser.ParseComposeFile(tempDir, composePath) if err != nil { return nil, fmt.Errorf("failed to parse compose file: %w", err) } // Override app name if provided in request if req.AppName != "" { review.AppName = req.AppName } return review, nil } // CreateAppFromCompose creates an app and its components from a docker-compose import func (ai *AppImporter) CreateAppFromCompose(ctx context.Context, req models.AppImportCreateRequest, userID uint) (*models.App, error) { ai.entry.Infof("Creating app '%s' from compose import for user %d", req.ConfirmedAppName, userID) // First, review the compose file to get the services review, err := ai.ReviewComposeFile(ctx, req.AppImportRequest) if err != nil { return nil, fmt.Errorf("failed to review compose file: %w", err) } if !review.Valid { return nil, fmt.Errorf("invalid compose file: %s", review.Error) } // Create the app app := &models.App{ UserID: userID, Name: req.ConfirmedAppName, Description: fmt.Sprintf("Imported from docker-compose.yml from %s", req.SourceURL), Status: models.AppStatusBuilding, Components: "[]", // Will be updated after creating components } if err := ai.store.CreateApp(ctx, app); err != nil { return nil, fmt.Errorf("failed to create app: %w", err) } ai.entry.Infof("Created app ID %d: %s", app.ID, app.Name) // Create components for each service var componentIDs []uint for _, service := range review.Services { component, err := ai.createComponentFromService(ctx, app.ID, userID, service, req) if err != nil { ai.entry.Errorf("Failed to create component for service %s: %v", service.Name, err) // Continue with other services, don't fail the entire import continue } componentIDs = append(componentIDs, component.ID) } // Update app with component IDs if err := ai.updateAppComponents(ctx, app.ID, componentIDs); err != nil { ai.entry.Errorf("Failed to update app components: %v", err) // Non-fatal error, the app and components are still created } // Update app status if len(componentIDs) > 0 { app.Status = models.AppStatusBuilding } else { app.Status = models.AppStatusFailed app.ErrorMsg = "No components could be created from the compose file" } if err := ai.store.UpdateAppStatus(ctx, app.ID, app.Status, app.ErrorMsg); err != nil { ai.entry.Errorf("Failed to update app status: %v", err) } ai.entry.Infof("Successfully created app %s with %d components", app.Name, len(componentIDs)) return app, nil } // createComponentFromService creates a component from a compose service func (ai *AppImporter) createComponentFromService(ctx context.Context, appID, userID uint, service models.ComposeService, req models.AppImportCreateRequest) (*models.Component, error) { ai.entry.Infof("Creating component for service: %s (source: %s)", service.Name, service.Source) // Determine component type based on service configuration componentType := "web" if len(service.Ports) == 0 { componentType = "worker" } // Resolve build context for docker-compose imports buildContext := service.BuildContext if buildContext == "" { buildContext = "." } // Clean up the build context path to remove ./ prefix and ensure it's relative buildContext = strings.TrimPrefix(buildContext, "./") // Create component component := &models.Component{ UserID: userID, Name: service.Name, Description: fmt.Sprintf("Service from docker-compose: %s", service.Name), Type: componentType, Status: "pending", Repository: req.SourceURL, Branch: req.Branch, SourceType: "docker-compose", ServiceName: service.Name, BuildContext: buildContext, DockerfilePath: service.Dockerfile, } if err := ai.store.CreateComponent(ctx, component); err != nil { return nil, fmt.Errorf("failed to create component: %w", err) } ai.entry.Infof("Created component ID %d: %s", component.ID, component.Name) // Handle the component based on its source type switch service.Source { case "build": // Queue a build job for this component if err := ai.queueBuildJob(ctx, component, service, req); err != nil { ai.entry.Errorf("Failed to queue build job for component %d: %v", component.ID, err) ai.store.UpdateComponentStatus(ctx, component.ID, "failed", fmt.Sprintf("Failed to queue build: %v", err)) } case "image": // Mark component as ready and set image info ai.store.UpdateComponentStatus(ctx, component.ID, "ready", "") ai.store.UpdateComponentImageInfo(ctx, component.ID, "latest", service.Image) ai.entry.Infof("Component %d marked as ready with image: %s", component.ID, service.Image) default: ai.entry.Warnf("Unknown service source type: %s", service.Source) ai.store.UpdateComponentStatus(ctx, component.ID, "failed", "Unknown service source type") } return component, nil } // queueBuildJob queues a build job for a component that needs to be built func (ai *AppImporter) queueBuildJob(ctx context.Context, component *models.Component, service models.ComposeService, req models.AppImportCreateRequest) error { // Use the build context already stored in the component buildContext := component.BuildContext if buildContext == "" { buildContext = "." } ai.entry.Infof("Using build context for service %s: %s", service.Name, buildContext) // Prepare build request buildReq := models.BuildRequest{ ComponentID: component.ID, SourceURL: req.SourceURL, Version: req.Branch, ImageName: fmt.Sprintf("byop-component-%d", component.ID), RegistryURL: ai.registryURL, BuildContext: buildContext, Dockerfile: component.DockerfilePath, Source: "docker-compose", } // Set default dockerfile if not specified if buildReq.Dockerfile == "" { buildReq.Dockerfile = "Dockerfile" } _, err := ai.builderSvc.QueueBuildJob(ctx, buildReq) if err != nil { return fmt.Errorf("failed to queue build job: %w", err) } ai.entry.Infof("Queued build job for component %d (service: %s)", component.ID, service.Name) return nil } // updateAppComponents updates the app's components field with the component IDs func (ai *AppImporter) updateAppComponents(ctx context.Context, appID uint, componentIDs []uint) error { // Convert component IDs to JSON string componentsJSON := "[" for i, id := range componentIDs { if i > 0 { componentsJSON += "," } componentsJSON += fmt.Sprintf("%d", id) } componentsJSON += "]" // Update the app in the database app, err := ai.store.GetAppByID(ctx, appID) if err != nil { return fmt.Errorf("failed to get app: %w", err) } app.Components = componentsJSON if err := ai.store.UpdateApp(ctx, app); err != nil { return fmt.Errorf("failed to update app components: %w", err) } return nil } // cloneRepository clones a Git repository to the specified directory func (ai *AppImporter) cloneRepository(repoURL, branch, targetDir string) error { ai.entry.Infof("Cloning repository %s (branch: %s) to %s", repoURL, branch, targetDir) // Clone options cloneOptions := &git.CloneOptions{ URL: repoURL, Progress: nil, // We could add progress tracking here } // Set branch if specified if branch != "" { cloneOptions.ReferenceName = plumbing.NewBranchReferenceName(branch) cloneOptions.SingleBranch = true } // Clone the repository _, err := git.PlainClone(targetDir, false, cloneOptions) if err != nil { return fmt.Errorf("failed to clone repository: %w", err) } ai.entry.Infof("Successfully cloned repository to %s", targetDir) return nil }