app_importer.go 9.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284
  1. package services
  2. import (
  3. "context"
  4. "fmt"
  5. "os"
  6. "strings"
  7. "git.linuxforward.com/byop/byop-engine/dbstore"
  8. "git.linuxforward.com/byop/byop-engine/models"
  9. git "github.com/go-git/go-git/v5"
  10. "github.com/go-git/go-git/v5/plumbing"
  11. "github.com/sirupsen/logrus"
  12. )
  13. // AppImporter handles the import of applications from docker-compose files
  14. type AppImporter struct {
  15. store *dbstore.SQLiteStore
  16. parser *ComposeParser
  17. builderSvc *Builder
  18. entry *logrus.Entry
  19. registryURL string
  20. }
  21. // NewAppImporter creates a new AppImporter instance
  22. func NewAppImporter(store *dbstore.SQLiteStore, builderSvc *Builder, registryURL string) *AppImporter {
  23. return &AppImporter{
  24. store: store,
  25. parser: NewComposeParser(),
  26. builderSvc: builderSvc,
  27. entry: logrus.WithField("service", "AppImporter"),
  28. registryURL: registryURL,
  29. }
  30. }
  31. // ReviewComposeFile reviews a docker-compose file from a git repository
  32. func (ai *AppImporter) ReviewComposeFile(ctx context.Context, req models.AppImportRequest) (*models.AppImportReview, error) {
  33. ai.entry.Infof("Reviewing compose file from %s (branch: %s, path: %s)", req.SourceURL, req.Branch, req.ComposePath)
  34. // Create temporary directory for cloning
  35. tempDir, err := os.MkdirTemp("", "byop-import-*")
  36. if err != nil {
  37. return nil, fmt.Errorf("failed to create temp directory: %w", err)
  38. }
  39. defer os.RemoveAll(tempDir)
  40. // Clone the repository
  41. if err := ai.cloneRepository(req.SourceURL, req.Branch, tempDir); err != nil {
  42. return &models.AppImportReview{
  43. Valid: false,
  44. Error: fmt.Sprintf("Failed to clone repository: %v", err),
  45. }, nil
  46. }
  47. // Set default compose path if not provided
  48. composePath := req.ComposePath
  49. if composePath == "" {
  50. composePath = "docker-compose.yml"
  51. }
  52. // Parse the compose file
  53. review, err := ai.parser.ParseComposeFile(tempDir, composePath)
  54. if err != nil {
  55. return nil, fmt.Errorf("failed to parse compose file: %w", err)
  56. }
  57. // Override app name if provided in request
  58. if req.AppName != "" {
  59. review.AppName = req.AppName
  60. }
  61. return review, nil
  62. }
  63. // CreateAppFromCompose creates an app and its components from a docker-compose import
  64. func (ai *AppImporter) CreateAppFromCompose(ctx context.Context, req models.AppImportCreateRequest, userID uint) (*models.App, error) {
  65. ai.entry.Infof("Creating app '%s' from compose import for user %d", req.ConfirmedAppName, userID)
  66. // First, review the compose file to get the services
  67. review, err := ai.ReviewComposeFile(ctx, req.AppImportRequest)
  68. if err != nil {
  69. return nil, fmt.Errorf("failed to review compose file: %w", err)
  70. }
  71. if !review.Valid {
  72. return nil, fmt.Errorf("invalid compose file: %s", review.Error)
  73. }
  74. // Create the app
  75. app := &models.App{
  76. UserID: userID,
  77. Name: req.ConfirmedAppName,
  78. Description: fmt.Sprintf("Imported from docker-compose.yml from %s", req.SourceURL),
  79. Status: models.AppStatusBuilding,
  80. Components: "[]", // Will be updated after creating components
  81. }
  82. if err := ai.store.CreateApp(ctx, app); err != nil {
  83. return nil, fmt.Errorf("failed to create app: %w", err)
  84. }
  85. ai.entry.Infof("Created app ID %d: %s", app.ID, app.Name)
  86. // Create components for each service
  87. var componentIDs []uint
  88. for _, service := range review.Services {
  89. component, err := ai.createComponentFromService(ctx, app.ID, userID, service, req)
  90. if err != nil {
  91. ai.entry.Errorf("Failed to create component for service %s: %v", service.Name, err)
  92. // Continue with other services, don't fail the entire import
  93. continue
  94. }
  95. componentIDs = append(componentIDs, component.ID)
  96. }
  97. // Update app with component IDs
  98. if err := ai.updateAppComponents(ctx, app.ID, componentIDs); err != nil {
  99. ai.entry.Errorf("Failed to update app components: %v", err)
  100. // Non-fatal error, the app and components are still created
  101. }
  102. // Update app status
  103. if len(componentIDs) > 0 {
  104. app.Status = models.AppStatusBuilding
  105. } else {
  106. app.Status = models.AppStatusFailed
  107. app.ErrorMsg = "No components could be created from the compose file"
  108. }
  109. if err := ai.store.UpdateAppStatus(ctx, app.ID, app.Status, app.ErrorMsg); err != nil {
  110. ai.entry.Errorf("Failed to update app status: %v", err)
  111. }
  112. ai.entry.Infof("Successfully created app %s with %d components", app.Name, len(componentIDs))
  113. return app, nil
  114. }
  115. // createComponentFromService creates a component from a compose service
  116. func (ai *AppImporter) createComponentFromService(ctx context.Context, appID, userID uint, service models.ComposeService, req models.AppImportCreateRequest) (*models.Component, error) {
  117. ai.entry.Infof("Creating component for service: %s (source: %s)", service.Name, service.Source)
  118. // Determine component type based on service configuration
  119. componentType := "web"
  120. if len(service.Ports) == 0 {
  121. componentType = "worker"
  122. }
  123. // Resolve build context for docker-compose imports
  124. buildContext := service.BuildContext
  125. if buildContext == "" {
  126. buildContext = "."
  127. }
  128. // Clean up the build context path to remove ./ prefix and ensure it's relative
  129. buildContext = strings.TrimPrefix(buildContext, "./")
  130. // Create component
  131. component := &models.Component{
  132. UserID: userID,
  133. Name: service.Name,
  134. Description: fmt.Sprintf("Service from docker-compose: %s", service.Name),
  135. Type: componentType,
  136. Status: "pending",
  137. Repository: req.SourceURL,
  138. Branch: req.Branch,
  139. SourceType: "docker-compose",
  140. ServiceName: service.Name,
  141. BuildContext: buildContext,
  142. DockerfilePath: service.Dockerfile,
  143. }
  144. if err := ai.store.CreateComponent(ctx, component); err != nil {
  145. return nil, fmt.Errorf("failed to create component: %w", err)
  146. }
  147. ai.entry.Infof("Created component ID %d: %s", component.ID, component.Name)
  148. // Handle the component based on its source type
  149. switch service.Source {
  150. case "build":
  151. // Queue a build job for this component
  152. if err := ai.queueBuildJob(ctx, component, service, req); err != nil {
  153. ai.entry.Errorf("Failed to queue build job for component %d: %v", component.ID, err)
  154. ai.store.UpdateComponentStatus(ctx, component.ID, "failed", fmt.Sprintf("Failed to queue build: %v", err))
  155. }
  156. case "image":
  157. // Mark component as ready and set image info
  158. ai.store.UpdateComponentStatus(ctx, component.ID, "ready", "")
  159. ai.store.UpdateComponentImageInfo(ctx, component.ID, "latest", service.Image)
  160. ai.entry.Infof("Component %d marked as ready with image: %s", component.ID, service.Image)
  161. default:
  162. ai.entry.Warnf("Unknown service source type: %s", service.Source)
  163. ai.store.UpdateComponentStatus(ctx, component.ID, "failed", "Unknown service source type")
  164. }
  165. return component, nil
  166. }
  167. // queueBuildJob queues a build job for a component that needs to be built
  168. func (ai *AppImporter) queueBuildJob(ctx context.Context, component *models.Component, service models.ComposeService, req models.AppImportCreateRequest) error {
  169. // Use the build context already stored in the component
  170. buildContext := component.BuildContext
  171. if buildContext == "" {
  172. buildContext = "."
  173. }
  174. ai.entry.Infof("Using build context for service %s: %s", service.Name, buildContext)
  175. // Prepare build request
  176. buildReq := models.BuildRequest{
  177. ComponentID: component.ID,
  178. SourceURL: req.SourceURL,
  179. Version: req.Branch,
  180. ImageName: fmt.Sprintf("byop-component-%d", component.ID),
  181. RegistryURL: ai.registryURL,
  182. BuildContext: buildContext,
  183. Dockerfile: component.DockerfilePath,
  184. Source: "docker-compose",
  185. }
  186. // Set default dockerfile if not specified
  187. if buildReq.Dockerfile == "" {
  188. buildReq.Dockerfile = "Dockerfile"
  189. }
  190. _, err := ai.builderSvc.QueueBuildJob(ctx, buildReq)
  191. if err != nil {
  192. return fmt.Errorf("failed to queue build job: %w", err)
  193. }
  194. ai.entry.Infof("Queued build job for component %d (service: %s)", component.ID, service.Name)
  195. return nil
  196. }
  197. // updateAppComponents updates the app's components field with the component IDs
  198. func (ai *AppImporter) updateAppComponents(ctx context.Context, appID uint, componentIDs []uint) error {
  199. // Convert component IDs to JSON string
  200. componentsJSON := "["
  201. for i, id := range componentIDs {
  202. if i > 0 {
  203. componentsJSON += ","
  204. }
  205. componentsJSON += fmt.Sprintf("%d", id)
  206. }
  207. componentsJSON += "]"
  208. // Update the app in the database
  209. app, err := ai.store.GetAppByID(ctx, appID)
  210. if err != nil {
  211. return fmt.Errorf("failed to get app: %w", err)
  212. }
  213. app.Components = componentsJSON
  214. if err := ai.store.UpdateApp(ctx, app); err != nil {
  215. return fmt.Errorf("failed to update app components: %w", err)
  216. }
  217. return nil
  218. }
  219. // cloneRepository clones a Git repository to the specified directory
  220. func (ai *AppImporter) cloneRepository(repoURL, branch, targetDir string) error {
  221. ai.entry.Infof("Cloning repository %s (branch: %s) to %s", repoURL, branch, targetDir)
  222. // Clone options
  223. cloneOptions := &git.CloneOptions{
  224. URL: repoURL,
  225. Progress: nil, // We could add progress tracking here
  226. }
  227. // Set branch if specified
  228. if branch != "" {
  229. cloneOptions.ReferenceName = plumbing.NewBranchReferenceName(branch)
  230. cloneOptions.SingleBranch = true
  231. }
  232. // Clone the repository
  233. _, err := git.PlainClone(targetDir, false, cloneOptions)
  234. if err != nil {
  235. return fmt.Errorf("failed to clone repository: %w", err)
  236. }
  237. ai.entry.Infof("Successfully cloned repository to %s", targetDir)
  238. return nil
  239. }