builder.go 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448
  1. package services
  2. import (
  3. "context"
  4. "encoding/json"
  5. "fmt"
  6. "os" // Added import
  7. "path/filepath"
  8. "strings"
  9. "time"
  10. "git.linuxforward.com/byop/byop-engine/clients"
  11. "git.linuxforward.com/byop/byop-engine/dbstore"
  12. "git.linuxforward.com/byop/byop-engine/models"
  13. git "github.com/go-git/go-git/v5"
  14. "github.com/go-git/go-git/v5/plumbing"
  15. "github.com/sirupsen/logrus"
  16. )
  17. const (
  18. defaultBuildQueueSize = 100
  19. defaultImageTag = "latest"
  20. defaultDockerfilePath = "Dockerfile"
  21. defaultBuildContext = "."
  22. )
  23. // Builder handles the queuing and processing of application build jobs.
  24. type Builder struct {
  25. store *dbstore.SQLiteStore
  26. buildMachineClient clients.BuildMachineClient
  27. registryClient clients.RegistryClient
  28. maxConcurrentBuild int
  29. buildChan chan uint
  30. entry *logrus.Entry
  31. }
  32. // NewBuilderService creates a new Builder service.
  33. func NewBuilderService(store *dbstore.SQLiteStore, buildMachineClient clients.BuildMachineClient, registryClient clients.RegistryClient, maxConcurrentBuild int) *Builder {
  34. svc := &Builder{
  35. store: store,
  36. buildMachineClient: buildMachineClient,
  37. registryClient: registryClient,
  38. maxConcurrentBuild: maxConcurrentBuild,
  39. buildChan: make(chan uint, maxConcurrentBuild),
  40. entry: logrus.WithField("service", "Builder"),
  41. }
  42. go svc.startBuildQueueProcessor() // Start a goroutine to process the queue
  43. svc.entry.Info("Builder service initialized and build queue processor started.")
  44. return svc
  45. }
  46. // QueueBuildJob adds a new build job to the queue.
  47. // It will first create a BuildJob entry in the database.
  48. func (s *Builder) QueueBuildJob(ctx context.Context, req models.BuildRequest) (*models.BuildJob, error) {
  49. s.entry.Infof("Received build request for ComponentID: %d, SourceURL: %s", req.ComponentID, req.SourceURL)
  50. // Debug: Check if DockerfileContent is present
  51. if req.DockerfileContent != "" {
  52. lines := strings.Split(req.DockerfileContent, "\n")
  53. if len(lines) > 5 {
  54. lines = lines[:5]
  55. }
  56. s.entry.Infof("DockerfileContent received for ComponentID %d (first 5 lines):\n%s", req.ComponentID, strings.Join(lines, "\n"))
  57. } else {
  58. s.entry.Warnf("DockerfileContent is EMPTY for ComponentID %d", req.ComponentID)
  59. }
  60. // 1. Validate request
  61. if req.ComponentID == 0 || req.SourceURL == "" || req.ImageName == "" {
  62. err := fmt.Errorf("invalid build request: ComponentID, SourceURL, and ImageName are required. Got ComponentID: %d, SourceURL: '%s', ImageName: '%s'", req.ComponentID, req.SourceURL, req.ImageName)
  63. s.entry.Error(err)
  64. return nil, err
  65. }
  66. // 2. Create BuildJob model
  67. imageTag := req.Version
  68. if imageTag == "" {
  69. imageTag = defaultImageTag
  70. }
  71. fullImageURI := fmt.Sprintf("%s:%s", req.ImageName, imageTag)
  72. if req.RegistryURL != "" {
  73. fullImageURI = fmt.Sprintf("%s/%s:%s", req.RegistryURL, req.ImageName, imageTag)
  74. }
  75. buildArgsJSON := ""
  76. if len(req.BuildArgs) > 0 {
  77. jsonBytes, err := json.Marshal(req.BuildArgs)
  78. if err != nil {
  79. s.entry.Errorf("Failed to marshal build args for ComponentID %d: %v", req.ComponentID, err)
  80. return nil, fmt.Errorf("failed to marshal build args: %w", err)
  81. }
  82. buildArgsJSON = string(jsonBytes)
  83. }
  84. dockerfilePath := req.Dockerfile
  85. if dockerfilePath == "" {
  86. dockerfilePath = defaultDockerfilePath
  87. }
  88. buildContext := req.BuildContext
  89. if buildContext == "" {
  90. buildContext = defaultBuildContext
  91. }
  92. job := models.BuildJob{
  93. ComponentID: req.ComponentID,
  94. RequestID: fmt.Sprintf("build-%d-%s", req.ComponentID, time.Now().Format("20060102150405")), // Unique ID for idempotency
  95. SourceURL: req.SourceURL,
  96. Status: models.BuildStatusPending, // Corrected: Queued is the initial status set by dbstore.CreateBuildJob
  97. ImageName: req.ImageName,
  98. ImageTag: imageTag,
  99. FullImageURI: fullImageURI,
  100. RegistryURL: req.RegistryURL,
  101. RegistryUser: req.RegistryUser, // Added
  102. RegistryPassword: req.RegistryPassword, // Added
  103. BuildContext: buildContext,
  104. Dockerfile: dockerfilePath,
  105. Source: req.Source, // Added
  106. DockerfileContent: req.DockerfileContent, // NEW: Generated Dockerfile content
  107. NoCache: req.NoCache,
  108. BuildArgs: buildArgsJSON,
  109. RequestedAt: time.Now(),
  110. }
  111. // 3. Save BuildJob to database
  112. if err := s.store.CreateBuildJob(ctx, &job); err != nil {
  113. s.entry.Errorf("Failed to save build job for ComponentID %d to database: %v", req.ComponentID, err)
  114. return nil, fmt.Errorf("failed to save build job: %w", err)
  115. }
  116. // Debug: Verify the job was saved with DockerfileContent
  117. if job.DockerfileContent != "" {
  118. s.entry.Infof("Build job ID %d saved with DockerfileContent (length: %d chars)", job.ID, len(job.DockerfileContent))
  119. } else {
  120. s.entry.Warnf("Build job ID %d saved with EMPTY DockerfileContent", job.ID)
  121. }
  122. // 4. Send to buildQueue
  123. select {
  124. case s.buildChan <- job.ID: // Non-blocking send to channel
  125. s.entry.Infof("Build job ID %d for ComponentID %d sent to internal queue.", job.ID, job.ComponentID)
  126. default:
  127. s.entry.Errorf("Build queue is full. Failed to queue job ID %d for ComponentID %d.", job.ID, job.ComponentID)
  128. return &job, fmt.Errorf("build queue is full, cannot process job ID %d at this time", job.ID)
  129. }
  130. return &job, nil
  131. }
  132. // startBuildQueueProcessor runs in a goroutine, picking jobs from buildQueue.
  133. func (s *Builder) startBuildQueueProcessor() {
  134. s.entry.Info("Build queue processor started. Waiting for jobs...")
  135. for jobId := range s.buildChan {
  136. s.entry.Info("Processing build job from queue.")
  137. go s.processJob(context.Background(), jobId)
  138. }
  139. s.entry.Info("Build queue processor stopped.")
  140. }
  141. // processJob handles the lifecycle of a single build job.
  142. func (s *Builder) processJob(ctx context.Context, jobID uint) {
  143. // Implementation for processing a job
  144. job, err := s.store.GetBuildJobByID(ctx, jobID)
  145. if err != nil {
  146. s.entry.Errorf("Failed to retrieve build job ID %d from database: %v", jobID, err)
  147. return
  148. }
  149. s.entry.Infof("Processing build job ID %d for ComponentID %d. Source: %s, BuildContext: %s", job.ID, job.ComponentID, job.Source, job.BuildContext)
  150. // Clone repository and resolve build context for docker-compose builds
  151. resolvedBuildContext := job.BuildContext
  152. var tempRepoDir string
  153. if job.Source == "docker-compose" {
  154. var err error
  155. resolvedBuildContext, err = s.cloneRepositoryAndResolveBuildContext(ctx, job)
  156. if err != nil {
  157. s.entry.Errorf("Failed to clone repository and resolve build context for job ID %d: %v", job.ID, err)
  158. s.updateJobStatus(ctx, job.ID, job.ComponentID, models.BuildStatusFailed, fmt.Sprintf("Failed to resolve build context: %v", err))
  159. s.appendJobLog(ctx, job.ID, fmt.Sprintf("Build failed: %v", err))
  160. return
  161. }
  162. // Extract temp repo directory for cleanup
  163. if strings.Contains(resolvedBuildContext, job.BuildContext) {
  164. // The resolved path contains our build context as suffix, extract the temp repo dir
  165. tempRepoDir = strings.TrimSuffix(resolvedBuildContext, job.BuildContext)
  166. tempRepoDir = strings.TrimSuffix(tempRepoDir, "/")
  167. } else if job.BuildContext == "." || job.BuildContext == "" {
  168. tempRepoDir = resolvedBuildContext
  169. }
  170. }
  171. // Ensure cleanup of cloned repository after processing
  172. if tempRepoDir != "" && tempRepoDir != defaultBuildContext {
  173. defer func() {
  174. s.entry.Infof("Cleaning up cloned repository directory: %s for job ID %d", tempRepoDir, job.ID)
  175. if err := os.RemoveAll(tempRepoDir); err != nil {
  176. s.entry.Errorf("Failed to clean up repository directory %s for job ID %d: %v", tempRepoDir, job.ID, err)
  177. } else {
  178. s.entry.Infof("Successfully cleaned up repository directory: %s for job ID %d", tempRepoDir, job.ID)
  179. }
  180. }()
  181. }
  182. s.entry.Infof("Using resolved build context for job ID %d: %s", job.ID, resolvedBuildContext)
  183. // Debug: Check if DockerfileContent was retrieved from database
  184. if job.DockerfileContent != "" {
  185. s.entry.Infof("Job %d retrieved with DockerfileContent (length: %d chars)", job.ID, len(job.DockerfileContent))
  186. lines := strings.Split(job.DockerfileContent, "\n")
  187. if len(lines) > 3 {
  188. lines = lines[:3]
  189. }
  190. s.entry.Infof("Job %d DockerfileContent first 3 lines:\n%s", job.ID, strings.Join(lines, "\n"))
  191. } else {
  192. s.entry.Warnf("Job %d retrieved with EMPTY DockerfileContent", job.ID)
  193. }
  194. // Update job status to InProgress
  195. s.updateJobStatus(ctx, job.ID, job.ComponentID, models.BuildStatusBuilding, "")
  196. // Componentend log for job start
  197. s.appendJobLog(ctx, job.ID, fmt.Sprintf("Starting build for ComponentID %d from source %s", job.ComponentID, job.SourceURL))
  198. // Parse build arguments
  199. buildArgs, err := s.parseBuildArgs(job.BuildArgs)
  200. if err != nil {
  201. s.entry.Errorf("Failed to parse build args for job ID %d: %v", job.ID, err)
  202. s.updateJobStatus(ctx, job.ID, job.ComponentID, models.BuildStatusFailed, fmt.Sprintf("Failed to parse build args: %v", err))
  203. s.appendJobLog(ctx, job.ID, fmt.Sprintf("Build failed: %v", err))
  204. return
  205. }
  206. // Use resolved build context for the build
  207. dockerfilePath := job.Dockerfile
  208. if job.Source == "docker-compose" && dockerfilePath != "" && !filepath.IsAbs(dockerfilePath) {
  209. // For docker-compose builds, resolve dockerfile path relative to build context
  210. dockerfilePath = filepath.Join(resolvedBuildContext, dockerfilePath)
  211. }
  212. // For registry builds, do build and push in one step for efficiency
  213. if job.RegistryURL != "" {
  214. s.entry.Infof("Building and pushing image %s to registry %s in one step", job.FullImageURI, job.RegistryURL)
  215. buildOutput, err := s.buildAndPushImage(ctx, job, dockerfilePath, resolvedBuildContext, buildArgs)
  216. if err != nil {
  217. s.entry.Errorf("Build and push failed for job ID %d: %v", job.ID, err)
  218. s.updateJobStatus(ctx, job.ID, job.ComponentID, models.BuildStatusFailed, fmt.Sprintf("Build and push failed: %v", err))
  219. s.appendJobLog(ctx, job.ID, fmt.Sprintf("Build and push failed: %v", err))
  220. return
  221. }
  222. s.entry.Infof("Build and push completed successfully for job ID %d. Output: %s", job.ID, buildOutput)
  223. } else {
  224. // Local build only
  225. buildOutput, err := s.buildMachineClient.BuildImage(ctx, *job, dockerfilePath, resolvedBuildContext, job.ImageName, job.ImageTag, job.NoCache, buildArgs)
  226. if err != nil {
  227. s.entry.Errorf("Build failed for job ID %d: %v", job.ID, err)
  228. s.updateJobStatus(ctx, job.ID, job.ComponentID, models.BuildStatusFailed, fmt.Sprintf("Build failed: %v", err))
  229. s.appendJobLog(ctx, job.ID, fmt.Sprintf("Build failed: %v", err))
  230. return
  231. }
  232. s.entry.Infof("Build completed successfully for job ID %d. Output: %s", job.ID, buildOutput)
  233. }
  234. // Debug registry push configuration
  235. s.entry.Infof("Registry URL configured: %s", job.RegistryURL)
  236. // For registry builds, the build-and-push was already done above
  237. // For local builds, no push is needed
  238. // Finalize job with success status
  239. s.finalizeJob(ctx, job.ID, job.ComponentID, models.BuildStatusSuccess, "")
  240. s.appendJobLog(ctx, job.ID, "Build job completed successfully.")
  241. s.entry.Infof("Build job ID %d for ComponentID %d completed successfully.", job.ID, job.ComponentID)
  242. }
  243. // buildAndPushImage builds and pushes an image in one step for docker-compose builds
  244. func (s *Builder) buildAndPushImage(ctx context.Context, job *models.BuildJob, dockerfilePath, buildContext string, buildArgs map[string]string) (string, error) {
  245. // Create a temporary job with the resolved build context for the build operation
  246. tempJob := *job
  247. tempJob.BuildContext = buildContext
  248. // For DockerfileBuilder, we can call BuildImage with push enabled
  249. if dockerfileBuilder, ok := s.buildMachineClient.(*clients.DockerfileBuilder); ok {
  250. return s.buildAndPushWithDockerfileBuilder(ctx, dockerfileBuilder, &tempJob, dockerfilePath, buildContext, buildArgs)
  251. }
  252. // Fallback: build first, then push (original approach)
  253. buildOutput, err := s.buildMachineClient.BuildImage(ctx, tempJob, dockerfilePath, buildContext, job.ImageName, job.ImageTag, job.NoCache, buildArgs)
  254. if err != nil {
  255. return "", fmt.Errorf("build failed: %w", err)
  256. }
  257. // Push the image
  258. if err := s.registryClient.PushImage(ctx, tempJob, job.FullImageURI, job.RegistryURL, job.RegistryUser, job.RegistryPassword); err != nil {
  259. return buildOutput, fmt.Errorf("push failed: %w", err)
  260. }
  261. return buildOutput, nil
  262. }
  263. // buildAndPushWithDockerfileBuilder builds and pushes using DockerfileBuilder with combined build+push
  264. func (s *Builder) buildAndPushWithDockerfileBuilder(ctx context.Context, builder *clients.DockerfileBuilder, job *models.BuildJob, dockerfilePath, buildContext string, buildArgs map[string]string) (string, error) {
  265. // We'll create a custom build-and-push operation that uses BuildKit's ability to export directly to registry
  266. return builder.BuildImageWithPush(ctx, *job, dockerfilePath, buildContext, job.ImageName, job.ImageTag, job.NoCache, buildArgs, job.FullImageURI, job.RegistryURL, job.RegistryUser, job.RegistryPassword)
  267. }
  268. // updateJobStatus updates the job's status in the database.
  269. func (s *Builder) updateJobStatus(ctx context.Context, jobID uint, componentId uint, status models.BuildStatus, errorMessage string) {
  270. if err := s.store.UpdateBuildJobStatus(ctx, jobID, status, errorMessage); err != nil {
  271. s.entry.Errorf("Error updating status for build job ID %d to %s: %v", jobID, status, err)
  272. } else {
  273. s.entry.Infof("Updated status for build job ID %d to %s.", jobID, status)
  274. }
  275. var componentStatus string
  276. switch status {
  277. case models.BuildStatusSuccess:
  278. componentStatus = "ready"
  279. case models.BuildStatusFailed:
  280. componentStatus = "failed"
  281. default:
  282. componentStatus = "in_progress"
  283. }
  284. if updateErr := s.store.UpdateComponentStatus(ctx, componentId, componentStatus, errorMessage); updateErr != nil {
  285. s.entry.Errorf("Error updating component status for job ID %d: %v", jobID, updateErr)
  286. } else {
  287. s.entry.Infof("Updated component status for job ID %d to %s.", jobID, status)
  288. }
  289. }
  290. // appendJobLog appends a log message to the job's logs in the database.
  291. func (s *Builder) appendJobLog(ctx context.Context, jobID uint, message string) {
  292. if err := s.store.AppendBuildJobLog(ctx, jobID, message); err != nil {
  293. s.entry.Errorf("Error appending log for build job ID %d: %v", jobID, err)
  294. s.entry.Infof("[Job %d Log]: %s", jobID, message)
  295. }
  296. }
  297. // finalizeJob sets the final status of the job (Success or Failed) and records FinishedAt.
  298. func (s *Builder) finalizeJob(ctx context.Context, jobID uint, componentId uint, status models.BuildStatus, errorMessage string) {
  299. if err := s.store.UpdateBuildJobStatus(ctx, jobID, status, errorMessage); err != nil {
  300. s.entry.Errorf("Error finalizing build job ID %d with status %s: %v", jobID, status, err)
  301. } else {
  302. s.entry.Infof("Finalized build job ID %d with status %s.", jobID, status)
  303. }
  304. var componentStatus string
  305. switch status {
  306. case models.BuildStatusSuccess:
  307. componentStatus = "ready"
  308. // Update component with image information if build was successful
  309. job, err := s.store.GetBuildJobByID(ctx, jobID)
  310. if err != nil {
  311. s.entry.Errorf("Error retrieving build job ID %d to update component image info: %v", jobID, err)
  312. } else {
  313. // Update component with the built image information
  314. if err := s.store.UpdateComponentImageInfo(ctx, componentId, job.ImageTag, job.FullImageURI); err != nil {
  315. s.entry.Errorf("Error updating component image info for component ID %d after successful build: %v", componentId, err)
  316. } else {
  317. s.entry.Infof("Successfully updated component ID %d with image tag %s and URI %s", componentId, job.ImageTag, job.FullImageURI)
  318. }
  319. }
  320. case models.BuildStatusFailed:
  321. componentStatus = "failed"
  322. default:
  323. componentStatus = "in_progress"
  324. }
  325. if updateErr := s.store.UpdateComponentStatus(ctx, componentId, componentStatus, errorMessage); updateErr != nil {
  326. s.entry.Errorf("Error updating component status for job ID %d: %v", jobID, updateErr)
  327. } else {
  328. s.entry.Infof("Updated component status for job ID %d to %s.", jobID, status)
  329. }
  330. }
  331. // parseBuildArgs converts a JSON string of build arguments into a map.
  332. func (s *Builder) parseBuildArgs(argsStr string) (map[string]string, error) {
  333. if argsStr == "" {
  334. return nil, nil
  335. }
  336. var argsMap map[string]string
  337. err := json.Unmarshal([]byte(argsStr), &argsMap)
  338. if err != nil {
  339. return nil, fmt.Errorf("error unmarshalling build args JSON: %w. JSON string was: %s", err, argsStr)
  340. }
  341. return argsMap, nil
  342. }
  343. // cloneRepositoryAndResolveBuildContext clones the repository and resolves the build context
  344. // Returns the absolute path to the build context directory
  345. func (s *Builder) cloneRepositoryAndResolveBuildContext(ctx context.Context, job *models.BuildJob) (string, error) {
  346. // For docker-compose source builds, we need to clone the repository first
  347. if job.Source != "docker-compose" {
  348. // For non-compose builds, assume build context is already correctly set
  349. return job.BuildContext, nil
  350. }
  351. // Create temporary directory for cloning
  352. tempDir, err := os.MkdirTemp("", fmt.Sprintf("byop-build-%d-*", job.ID))
  353. if err != nil {
  354. return "", fmt.Errorf("failed to create temp directory: %w", err)
  355. }
  356. s.entry.Infof("Job %d: Cloning repository %s to %s", job.ID, job.SourceURL, tempDir)
  357. // Clone options
  358. cloneOptions := &git.CloneOptions{
  359. URL: job.SourceURL,
  360. Progress: nil,
  361. }
  362. // Set branch if specified in version
  363. if job.ImageTag != "" && job.ImageTag != "latest" {
  364. cloneOptions.ReferenceName = plumbing.NewBranchReferenceName(job.ImageTag)
  365. cloneOptions.SingleBranch = true
  366. }
  367. // Clone the repository
  368. _, err = git.PlainClone(tempDir, false, cloneOptions)
  369. if err != nil {
  370. os.RemoveAll(tempDir)
  371. return "", fmt.Errorf("failed to clone repository: %w", err)
  372. }
  373. // Resolve build context relative to the cloned repository
  374. var buildContextPath string
  375. if job.BuildContext == "" || job.BuildContext == "." {
  376. buildContextPath = tempDir
  377. } else {
  378. buildContextPath = filepath.Join(tempDir, job.BuildContext)
  379. }
  380. // Check if build context directory exists
  381. if _, err := os.Stat(buildContextPath); os.IsNotExist(err) {
  382. os.RemoveAll(tempDir)
  383. return "", fmt.Errorf("build context directory does not exist: %s", job.BuildContext)
  384. }
  385. s.entry.Infof("Job %d: Resolved build context to %s", job.ID, buildContextPath)
  386. return buildContextPath, nil
  387. }