Files
Containr/app/backend/internal/build/manager.go
T
Tomas DvorakandDevin 912f6f5142 feat(services): builder selection, resource reservations, static builder (C3)
services.builder picks the build strategy per service: auto detects from
repo contents (Dockerfile → railpack → nixpacks), explicit overrides force
a builder, and static builds assets in a node stage then serves them from
an nginx image (static_build_cmd/static_dir, port 80 default).

- cpu_reserve/memory_reserve → Docker CPU shares + MemoryReservation;
  cpu/memory stay hard caps (NanoCPUs/Memory). Empty string clears.
- app_settings.default_cpu/default_memory override service-create
  fallbacks; capacity_policy=block fails deploys whose requested memory
  exceeds node capacity (default warn logs via slog).
- Resource specs are validated — "512mb" fails instead of silently
  dropping the limit.
- Fixes: deployments.go hardcoded nixpacks (auto-detect was dead code);
  DockerfileBuilder passed the Dockerfile *path* as build-context content;
  BuildImage never drained the daemon stream so builds could abort and
  errors went unseen — stream is now consumed and errorDetail surfaced.
- CLI: --builder/--cpu-reserve/--memory-reserve/--static-cmd/--static-dir
  on create+update; MCP args on services create/update; UI Build &
  Resources card; OpenAPI + llms.txt + CLI.md.

Verified live: static build of a real repo → nginx image → deployed;
reserves reach HostConfig (shares=256, MemoryReservation=128Mi);
validation rejects bad specs; builder persists + clears.

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-08 17:08:35 +02:00

246 lines
7.5 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
staticBuilder *StaticBuilder
dockerClient *docker.Client
cacheManager *CacheManager
parallelBuilder *ParallelBuilder
workDir string
}
type BuildType string
const (
BuildTypeRailpack BuildType = "railpack"
BuildTypeNixpacks BuildType = "nixpacks"
BuildTypeDockerfile BuildType = "dockerfile"
BuildTypeStatic BuildType = "static"
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),
staticBuilder: NewStaticBuilder(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 BuildTypeStatic:
return bm.staticBuilder.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)
}