Files
Containr/app/backend/internal/build/manager.go
T
Tomas DvorakandDevin ff356230fe feat: real service runtime — private networks, replicas, lifecycle, rollback
Services now reconcile into actual Docker containers instead of
bookkeeping rows:

- Per-project bridge network with service-name DNS aliases and
  ${{service.KEY}} env expansion (HOST/PORT builtins)
- Replicas, published ports, Traefik domain routing, health checks,
  restart policies persisted on the service spec; spec-hash drift
  recreates containers on redeploy
- Lifecycle API: start/stop/restart/redeploy, live runtime state,
  real image rollback (replaces the fake sleep)
- Manual scale and PUT replicas reconcile live containers; service
  and project delete remove their containers and the project network
- Canvas: real URLs, replica badges, env filter, right-click
  deploy/restart/stop/delete menu, empty state
- Service detail: real lifecycle buttons, live replica/URL strip,
  Networking editor, ${{ }} variable helper
- Fix prebuilt-image pulls racing the tag (drain the pull stream)

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-21 19:31:19 +02:00

241 lines
7.3 KiB
Go

package build
import (
"context"
"fmt"
"path/filepath"
"time"
"containr/internal/docker"
"containr/internal/types"
"github.com/docker/docker/api/types/registry"
)
type BuildManager struct {
railpackBuilder *RailpackBuilder
nixpacksBuilder *NixpacksBuilder
dockerfileBuilder *DockerfileBuilder
dockerClient *docker.Client
cacheManager *CacheManager
parallelBuilder *ParallelBuilder
workDir string
}
type BuildType string
const (
BuildTypeRailpack BuildType = "railpack"
BuildTypeNixpacks BuildType = "nixpacks"
BuildTypeDockerfile BuildType = "dockerfile"
BuildTypePrebuilt BuildType = "prebuilt"
)
func NewBuildManager(workDir string, dockerClient *docker.Client) *BuildManager {
cacheManager := NewCacheManager(filepath.Join(workDir, "cache"), dockerClient)
parallelBuilder := NewParallelBuilder(nil, cacheManager, 4) // Default 4 workers
return &BuildManager{
railpackBuilder: NewRailpackBuilder(workDir, dockerClient),
nixpacksBuilder: NewNixpacksBuilder(workDir, dockerClient),
dockerfileBuilder: NewDockerfileBuilder(workDir, dockerClient),
dockerClient: dockerClient,
cacheManager: cacheManager,
parallelBuilder: parallelBuilder,
workDir: workDir,
}
}
// DetectBuildType automatically detects the build type based on repository contents
func (bm *BuildManager) DetectBuildType(ctx context.Context, repoPath string) (BuildType, error) {
// Check for Dockerfile first
if _, err := bm.dockerfileBuilder.DetectDockerfile(ctx, repoPath); err == nil {
return BuildTypeDockerfile, nil
}
// Check if Railpack can build this project (primary choice)
if err := bm.railpackBuilder.DetectRailpack(ctx, repoPath); err == nil {
return BuildTypeRailpack, nil
}
// Default to Nixpacks as fallback
return BuildTypeNixpacks, nil
}
// Build executes the build process using the appropriate builder
func (bm *BuildManager) Build(ctx context.Context, req *types.BuildRequest) (*types.BuildResponse, error) {
// Detect build type if not specified
if req.BuildType == "" {
detectedType, err := bm.DetectBuildType(ctx, req.SourcePath)
if err != nil {
return nil, fmt.Errorf("failed to detect build type: %w", err)
}
req.BuildType = string(detectedType)
}
switch BuildType(req.BuildType) {
case BuildTypeRailpack:
return bm.railpackBuilder.Build(ctx, req)
case BuildTypeNixpacks:
return bm.nixpacksBuilder.Build(ctx, req)
case BuildTypeDockerfile:
return bm.dockerfileBuilder.Build(ctx, req)
case BuildTypePrebuilt:
return bm.buildPrebuilt(ctx, req)
default:
return nil, fmt.Errorf("unsupported build type: %s", req.BuildType)
}
}
// buildPrebuilt handles prebuilt image deployments
func (bm *BuildManager) buildPrebuilt(ctx context.Context, req *types.BuildRequest) (*types.BuildResponse, error) {
if req.PrebuiltImage == "" {
return nil, fmt.Errorf("prebuilt image not specified")
}
// Pull the prebuilt image — must drain the pull stream or the daemon
// aborts it and the tag below races a missing image.
auth := registry.AuthConfig{}
if err := bm.dockerClient.PullImageWait(ctx, req.PrebuiltImage, auth); err != nil {
return nil, fmt.Errorf("failed to pull prebuilt image: %w", err)
}
// Tag with our desired name and tag if different
if req.ImageName != "" && req.ImageTag != "" {
targetImage := fmt.Sprintf("%s:%s", req.ImageName, req.ImageTag)
if targetImage != req.PrebuiltImage {
err := bm.dockerClient.TagImage(ctx, req.PrebuiltImage, targetImage)
if err != nil {
return nil, fmt.Errorf("failed to tag image: %w", err)
}
req.PrebuiltImage = targetImage
}
}
// Push to registry if specified
if req.RegistryURL != "" {
err := bm.dockerClient.PushImage(ctx, req.PrebuiltImage, req.RegistryURL)
if err != nil {
return nil, fmt.Errorf("failed to push image: %w", err)
}
req.PrebuiltImage = fmt.Sprintf("%s/%s", req.RegistryURL, req.PrebuiltImage)
}
// Get image info
imageInfo, err := bm.dockerClient.GetImageInfo(ctx, req.PrebuiltImage)
if err != nil {
// Continue even if we can't get image info
imageInfo = &docker.ImageInfo{Size: 0}
}
return &types.BuildResponse{
ImageName: req.PrebuiltImage,
ImageTag: req.ImageTag,
Size: imageInfo.Size,
Digest: imageInfo.Digest,
}, nil
}
// ValidateBuildRequest validates the build request parameters
func (bm *BuildManager) ValidateBuildRequest(ctx context.Context, req *types.BuildRequest) error {
if req.SourcePath == "" && req.PrebuiltImage == "" {
return fmt.Errorf("either source path or prebuilt image must be specified")
}
if req.ImageName == "" {
return fmt.Errorf("image name is required")
}
if req.ImageTag == "" {
return fmt.Errorf("image tag is required")
}
// Validate source path exists if specified
if req.SourcePath != "" {
if _, err := filepath.Abs(req.SourcePath); err != nil {
return fmt.Errorf("invalid source path: %w", err)
}
}
return nil
}
// GetBuildPlan returns the build plan for inspection without building
func (bm *BuildManager) GetBuildPlan(ctx context.Context, req *types.BuildRequest) (interface{}, error) {
switch BuildType(req.BuildType) {
case BuildTypeRailpack:
config := &RailpackConfig{
BuildCmd: req.BuildCommand,
StartCmd: req.StartCommand,
Env: req.Environment,
}
return bm.railpackBuilder.GeneratePlan(ctx, req.SourcePath, config)
case BuildTypeNixpacks:
config := &NixpacksConfig{
BuildCmd: req.BuildCommand,
StartCmd: req.StartCommand,
Env: req.Environment,
}
return bm.nixpacksBuilder.GeneratePlan(ctx, req.SourcePath, config)
case BuildTypeDockerfile:
dockerfile, err := bm.dockerfileBuilder.DetectDockerfile(ctx, req.SourcePath)
if err != nil {
return nil, err
}
return map[string]string{
"dockerfile": dockerfile,
"context": req.SourcePath,
}, nil
case BuildTypePrebuilt:
return map[string]string{
"image": req.PrebuiltImage,
}, nil
default:
return nil, fmt.Errorf("unsupported build type: %s", req.BuildType)
}
}
// BuildWithCache attempts to build using cache first
func (bm *BuildManager) BuildWithCache(ctx context.Context, req *types.BuildRequest) (*types.BuildResponse, error) {
bm.parallelBuilder.buildManager = bm
result, err := bm.parallelBuilder.BuildWithCache(ctx, req)
if err != nil {
return nil, err
}
return result.Response, nil
}
// BuildParallel executes multiple builds in parallel
func (bm *BuildManager) BuildParallel(ctx context.Context, requests []*types.BuildRequest) ([]*types.BuildResponse, error) {
bm.parallelBuilder.buildManager = bm
results, err := bm.parallelBuilder.BuildParallel(ctx, requests)
if err != nil {
return nil, err
}
responses := make([]*types.BuildResponse, len(results))
for i, result := range results {
if result.Success {
responses[i] = result.Response
} else {
return nil, result.Error
}
}
return responses, nil
}
// SetRemoteCache configures remote cache settings
func (bm *BuildManager) SetRemoteCache(config *RemoteCacheConfig) {
bm.cacheManager.SetRemoteCache(config)
}
// GetCacheStats returns cache statistics
func (bm *BuildManager) GetCacheStats(ctx context.Context) (map[string]interface{}, error) {
return bm.cacheManager.GetCacheStats(ctx)
}
// CleanupCache removes old cache entries
func (bm *BuildManager) CleanupCache(ctx context.Context, maxAge time.Duration) error {
return bm.cacheManager.CleanupCache(ctx, maxAge)
}