This commit is contained in:
Tomas Dvorak
2026-04-14 18:04:48 +02:00
parent 94f7302972
commit 355a97bab4
453 changed files with 81845 additions and 1243 deletions
+67
View File
@@ -0,0 +1,67 @@
# Railpack Integration
This directory contains the build system for Containr, with Railpack as the primary build method.
## Build Types
### Railpack (Primary)
- **Detection**: Automatically detects supported frameworks (Node.js, Python, Go, Rust, Java, Ruby, PHP, Static)
- **API**: Uses https://api.railpack.app/v1/ for Dockerfile generation
- **Fallback**: Falls back to Nixpacks if Railpack cannot build the project
### Nixpacks (Secondary)
- **Detection**: Used as fallback when Railpack detection fails
- **Local**: Runs locally without external API calls
### Dockerfile
- **Detection**: Checks for existing Dockerfile in repository
- **Priority**: Highest priority if Dockerfile exists
### Prebuilt
- **Usage**: For pre-built container images
- **Configuration**: Specified in service configuration
## Supported Frameworks
Railpack supports the following frameworks:
- **Node.js** - package.json detection
- **Python** - requirements.txt detection
- **Go** - go.mod detection
- **Rust** - Cargo.toml detection
- **Java** - pom.xml or build.gradle detection
- **Ruby** - Gemfile detection
- **PHP** - composer.json detection
- **Static** - Static site detection
## Usage
```go
// Create build manager
buildManager := build.NewBuildManager("/tmp/builds", dockerClient)
// Build a project
response, err := buildManager.Build(ctx, &types.BuildRequest{
SourcePath: "/path/to/project",
ImageName: "my-app",
ImageTag: "latest",
})
```
## API Endpoints
- **Generate Dockerfile**: POST https://api.railpack.app/v1/generate
- **Build Plan**: POST https://api.railpack.app/v1/plan
## Configuration
Railpack can be configured with:
- Custom build commands
- Custom start commands
- Environment variables
- Build root directory
## References
- https://railpack.com/
- https://railpack.com/getting-started
- https://github.com/railwayapp/railpack
+489
View File
@@ -0,0 +1,489 @@
package build
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"time"
"containr/internal/docker"
"containr/internal/types"
"github.com/docker/docker/api/types/registry"
)
// CacheManager handles build caching operations
type CacheManager struct {
cacheDir string
dockerClient *docker.Client
remoteCache *RemoteCacheConfig
}
// CacheEntry represents a cached layer entry
type CacheEntry struct {
Key string `json:"key"`
ImageName string `json:"image_name"`
Size int64 `json:"size"`
Created time.Time `json:"created"`
Hash string `json:"hash"`
Metadata map[string]string `json:"metadata"`
}
// RemoteCacheConfig defines remote cache configuration
type RemoteCacheConfig struct {
Enabled bool `json:"enabled"`
Registry string `json:"registry"`
Namespace string `json:"namespace"`
Username string `json:"username"`
Password string `json:"password"`
Insecure bool `json:"insecure"`
}
// CacheKey represents a build cache key
type CacheKey struct {
Runtime string `json:"runtime"`
Files map[string]string `json:"files"` // file path -> hash
EnvVars map[string]string `json:"env_vars"` // env var name -> hash
BuildCmd string `json:"build_cmd"`
Packages []string `json:"packages"`
BuildArgs map[string]string `json:"build_args"`
}
func NewCacheManager(cacheDir string, dockerClient *docker.Client) *CacheManager {
return &CacheManager{
cacheDir: cacheDir,
dockerClient: dockerClient,
}
}
// SetRemoteCache configures remote cache settings
func (cm *CacheManager) SetRemoteCache(config *RemoteCacheConfig) {
cm.remoteCache = config
}
// GenerateCacheKey creates a unique cache key for the build context
func (cm *CacheManager) GenerateCacheKey(ctx context.Context, req *types.BuildRequest) (*CacheKey, error) {
cacheKey := &CacheKey{
Runtime: req.BuildType,
Files: make(map[string]string),
EnvVars: make(map[string]string),
BuildCmd: req.BuildCommand,
Packages: []string{},
BuildArgs: req.Environment,
}
// Hash important files
importantFiles := []string{
"go.mod", "go.sum",
"package.json", "package-lock.json", "yarn.lock", "pnpm-lock.yaml",
"requirements.txt", "setup.py", "pyproject.toml", "Pipfile",
"Cargo.toml", "Cargo.lock",
"Gemfile", "Gemfile.lock",
"composer.json", "composer.lock",
"deno.json", "deno.jsonc",
"bun.lockb",
"Dockerfile",
".dockerignore",
}
for _, file := range importantFiles {
filePath := filepath.Join(req.SourcePath, file)
if hash, err := cm.hashFile(filePath); err == nil {
cacheKey.Files[file] = hash
}
}
// Hash environment variables (only non-sensitive ones)
for key, value := range req.Environment {
if !cm.isSensitiveEnv(key) {
hash := sha256.Sum256([]byte(value))
cacheKey.EnvVars[key] = hex.EncodeToString(hash[:])
}
}
// Extract package dependencies for different runtimes
if packages, err := cm.extractPackages(ctx, req); err == nil {
cacheKey.Packages = packages
}
return cacheKey, nil
}
// hashFile calculates SHA256 hash of a file
func (cm *CacheManager) hashFile(filePath string) (string, error) {
file, err := os.Open(filePath)
if err != nil {
return "", err
}
defer file.Close()
hash := sha256.New()
if _, err := io.Copy(hash, file); err != nil {
return "", err
}
return hex.EncodeToString(hash.Sum(nil)), nil
}
// isSensitiveEnv checks if an environment variable is sensitive
func (cm *CacheManager) isSensitiveEnv(key string) bool {
sensitiveKeys := []string{
"PASSWORD", "SECRET", "KEY", "TOKEN", "API", "AUTH",
"DATABASE_URL", "REDIS_URL", "PRIVATE",
}
keyUpper := strings.ToUpper(key)
for _, sensitive := range sensitiveKeys {
if strings.Contains(keyUpper, sensitive) {
return true
}
}
return false
}
// extractPackages extracts package dependencies from lock files
func (cm *CacheManager) extractPackages(ctx context.Context, req *types.BuildRequest) ([]string, error) {
var packages []string
// Go modules
if goMod := filepath.Join(req.SourcePath, "go.mod"); cm.fileExists(goMod) {
if content, err := os.ReadFile(goMod); err == nil {
packages = cm.extractGoPackages(string(content))
}
}
// Node.js packages
if packageJson := filepath.Join(req.SourcePath, "package.json"); cm.fileExists(packageJson) {
if content, err := os.ReadFile(packageJson); err == nil {
packages = append(packages, cm.extractNodePackages(string(content))...)
}
}
// Python requirements
if reqTxt := filepath.Join(req.SourcePath, "requirements.txt"); cm.fileExists(reqTxt) {
if content, err := os.ReadFile(reqTxt); err == nil {
packages = append(packages, cm.extractPythonPackages(string(content))...)
}
}
return packages, nil
}
func (cm *CacheManager) fileExists(path string) bool {
_, err := os.Stat(path)
return err == nil
}
func (cm *CacheManager) extractGoPackages(content string) []string {
var packages []string
lines := strings.Split(content, "\n")
for _, line := range lines {
if strings.HasPrefix(line, "\t") && strings.Contains(line, " ") {
parts := strings.Fields(line)
if len(parts) > 0 {
packages = append(packages, parts[0])
}
}
}
return packages
}
func (cm *CacheManager) extractNodePackages(content string) []string {
var packages []string
var packageJson struct {
Dependencies map[string]string `json:"dependencies"`
DevDependencies map[string]string `json:"devDependencies"`
}
if err := json.Unmarshal([]byte(content), &packageJson); err == nil {
for name := range packageJson.Dependencies {
packages = append(packages, name)
}
for name := range packageJson.DevDependencies {
packages = append(packages, name)
}
}
return packages
}
func (cm *CacheManager) extractPythonPackages(content string) []string {
var packages []string
lines := strings.Split(content, "\n")
for _, line := range lines {
line = strings.TrimSpace(line)
if line != "" && !strings.HasPrefix(line, "#") && !strings.HasPrefix(line, "-") {
parts := strings.Fields(line)
if len(parts) > 0 {
// Remove version specifiers
pkgName := parts[0]
if idx := strings.IndexAny(pkgName, "=<>!"); idx != -1 {
pkgName = pkgName[:idx]
}
packages = append(packages, pkgName)
}
}
}
return packages
}
// GetCacheKeyHash generates a hash from the cache key
func (cm *CacheManager) GetCacheKeyHash(cacheKey *CacheKey) string {
data, _ := json.Marshal(cacheKey)
hash := sha256.Sum256(data)
return hex.EncodeToString(hash[:])
}
// LookupCache checks if a cached image exists for the given cache key
func (cm *CacheManager) LookupCache(ctx context.Context, cacheKey *CacheKey) (*CacheEntry, error) {
keyHash := cm.GetCacheKeyHash(cacheKey)
// Check local cache first
if entry, err := cm.lookupLocalCache(ctx, keyHash); err == nil {
return entry, nil
}
// Check remote cache if enabled
if cm.remoteCache != nil && cm.remoteCache.Enabled {
if entry, err := cm.lookupRemoteCache(ctx, keyHash); err == nil {
return entry, nil
}
}
return nil, fmt.Errorf("cache entry not found")
}
// lookupLocalCache checks local cache storage
func (cm *CacheManager) lookupLocalCache(ctx context.Context, keyHash string) (*CacheEntry, error) {
cacheFile := filepath.Join(cm.cacheDir, keyHash+".json")
data, err := os.ReadFile(cacheFile)
if err != nil {
return nil, err
}
var entry CacheEntry
if err := json.Unmarshal(data, &entry); err != nil {
return nil, err
}
// Verify the image still exists in Docker
if _, err := cm.dockerClient.GetImageInfo(ctx, entry.ImageName); err != nil {
// Remove stale cache entry
os.Remove(cacheFile)
return nil, fmt.Errorf("cached image not found")
}
return &entry, nil
}
// lookupRemoteCache checks remote cache registry
func (cm *CacheManager) lookupRemoteCache(ctx context.Context, keyHash string) (*CacheEntry, error) {
if cm.remoteCache == nil {
return nil, fmt.Errorf("remote cache not configured")
}
remoteImage := fmt.Sprintf("%s/%s/cache:%s", cm.remoteCache.Registry, cm.remoteCache.Namespace, keyHash)
// Try to pull the cached image
auth := registry.AuthConfig{}
_, err := cm.dockerClient.PullImage(ctx, remoteImage, auth)
if err != nil {
return nil, fmt.Errorf("remote cache entry not found")
}
// Get image info
imageInfo, err := cm.dockerClient.GetImageInfo(ctx, remoteImage)
if err != nil {
return nil, err
}
// Create local cache entry
entry := &CacheEntry{
Key: keyHash,
ImageName: remoteImage,
Size: imageInfo.Size,
Created: time.Now(),
Hash: keyHash,
Metadata: map[string]string{
"source": "remote",
},
}
// Save to local cache for faster future access
cm.saveLocalCache(ctx, entry)
return entry, nil
}
// StoreCache stores a build result in cache
func (cm *CacheManager) StoreCache(ctx context.Context, cacheKey *CacheKey, imageName string, metadata map[string]string) error {
keyHash := cm.GetCacheKeyHash(cacheKey)
// Get image info
imageInfo, err := cm.dockerClient.GetImageInfo(ctx, imageName)
if err != nil {
return fmt.Errorf("failed to get image info: %w", err)
}
entry := &CacheEntry{
Key: keyHash,
ImageName: imageName,
Size: imageInfo.Size,
Created: time.Now(),
Hash: keyHash,
Metadata: metadata,
}
// Store in local cache
if err := cm.saveLocalCache(ctx, entry); err != nil {
return fmt.Errorf("failed to save local cache: %w", err)
}
// Store in remote cache if enabled
if cm.remoteCache != nil && cm.remoteCache.Enabled {
if err := cm.saveRemoteCache(ctx, entry); err != nil {
// Log error but don't fail the build
fmt.Printf("Warning: failed to save to remote cache: %v\n", err)
}
}
return nil
}
// saveLocalCache saves cache entry to local storage
func (cm *CacheManager) saveLocalCache(ctx context.Context, entry *CacheEntry) error {
if err := os.MkdirAll(cm.cacheDir, 0755); err != nil {
return err
}
cacheFile := filepath.Join(cm.cacheDir, entry.Key+".json")
data, err := json.MarshalIndent(entry, "", " ")
if err != nil {
return err
}
return os.WriteFile(cacheFile, data, 0644)
}
// saveRemoteCache pushes cache entry to remote registry
func (cm *CacheManager) saveRemoteCache(ctx context.Context, entry *CacheEntry) error {
if cm.remoteCache == nil {
return fmt.Errorf("remote cache not configured")
}
remoteImage := fmt.Sprintf("%s/%s/cache:%s", cm.remoteCache.Registry, cm.remoteCache.Namespace, entry.Key)
// Tag and push to remote registry
if err := cm.dockerClient.TagImage(ctx, entry.ImageName, remoteImage); err != nil {
return fmt.Errorf("failed to tag image for remote cache: %w", err)
}
if err := cm.dockerClient.PushImage(ctx, remoteImage, cm.remoteCache.Registry); err != nil {
return fmt.Errorf("failed to push to remote cache: %w", err)
}
return nil
}
// CleanupCache removes old cache entries
func (cm *CacheManager) CleanupCache(ctx context.Context, maxAge time.Duration) error {
entries, err := os.ReadDir(cm.cacheDir)
if err != nil {
return err
}
cutoff := time.Now().Add(-maxAge)
removed := 0
for _, entry := range entries {
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") {
continue
}
cacheFile := filepath.Join(cm.cacheDir, entry.Name())
info, err := entry.Info()
if err != nil {
continue
}
if info.ModTime().Before(cutoff) {
// Read cache entry to remove associated image if needed
if data, err := os.ReadFile(cacheFile); err == nil {
var cacheEntry CacheEntry
if json.Unmarshal(data, &cacheEntry) == nil {
// Optionally remove Docker image (commented out for safety)
// cm.dockerClient.RemoveImage(ctx, cacheEntry.ImageName)
}
}
os.Remove(cacheFile)
removed++
}
}
fmt.Printf("Cleaned up %d cache entries\n", removed)
return nil
}
// GetCacheStats returns cache statistics
func (cm *CacheManager) GetCacheStats(ctx context.Context) (map[string]interface{}, error) {
entries, err := os.ReadDir(cm.cacheDir)
if err != nil {
return nil, err
}
stats := map[string]interface{}{
"total_entries": 0,
"total_size": int64(0),
"oldest_entry": nil,
"newest_entry": nil,
}
var oldest, newest time.Time
count := 0
totalSize := int64(0)
for _, entry := range entries {
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") {
continue
}
cacheFile := filepath.Join(cm.cacheDir, entry.Name())
data, err := os.ReadFile(cacheFile)
if err != nil {
continue
}
var cacheEntry CacheEntry
if json.Unmarshal(data, &cacheEntry) != nil {
continue
}
count++
totalSize += cacheEntry.Size
if oldest.IsZero() || cacheEntry.Created.Before(oldest) {
oldest = cacheEntry.Created
}
if newest.IsZero() || cacheEntry.Created.After(newest) {
newest = cacheEntry.Created
}
}
stats["total_entries"] = count
stats["total_size"] = totalSize
if !oldest.IsZero() {
stats["oldest_entry"] = oldest
}
if !newest.IsZero() {
stats["newest_entry"] = newest
}
return stats, nil
}
+520
View File
@@ -0,0 +1,520 @@
package build
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
)
// CustomRuntimeManager handles custom runtime and buildpack support
type CustomRuntimeManager struct {
workDir string
buildpacksDir string
runtimes map[string]*CustomRuntime
}
// CustomRuntime represents a custom runtime configuration
type CustomRuntime struct {
Name string `json:"name"`
Version string `json:"version"`
Type string `json:"type"` // "buildpack", "dockerfile", "native"
Description string `json:"description"`
Buildpack *BuildpackConfig `json:"buildpack,omitempty"`
Dockerfile *CustomDockerfileConfig `json:"dockerfile,omitempty"`
Native *NativeConfig `json:"native,omitempty"`
Dependencies []string `json:"dependencies"`
Environment map[string]string `json:"environment"`
}
// BuildpackConfig represents buildpack configuration
type BuildpackConfig struct {
ID string `json:"id"`
Version string `json:"version"`
URL string `json:"url"`
Buildpacks []string `json:"buildpacks"`
Groups []BuildpackGroup `json:"groups"`
Order []BuildpackOrder `json:"order"`
Environment map[string]string `json:"environment"`
}
// BuildpackGroup represents a group of buildpacks
type BuildpackGroup struct {
ID string `json:"id"`
Home string `json:"home"`
Order []string `json:"order"`
}
// BuildpackOrder represents buildpack execution order
type BuildpackOrder struct {
Group []string `json:"group"`
Lifecycle string `json:"lifecycle"`
}
// CustomDockerfileConfig represents custom Dockerfile configuration (to avoid conflict)
type CustomDockerfileConfig struct {
Template string `json:"template"`
BaseImage string `json:"base_image"`
BuildArgs map[string]string `json:"build_args"`
Labels map[string]string `json:"labels"`
StartCmd string `json:"start_cmd"`
HealthCheck *HealthCheck `json:"health_check,omitempty"`
}
// NativeConfig represents native build configuration
type NativeConfig struct {
Compiler string `json:"compiler"`
BuildCmd string `json:"build_cmd"`
StartCmd string `json:"start_cmd"`
Extensions []string `json:"extensions"`
Environment map[string]string `json:"environment"`
}
// HealthCheck represents health check configuration
type HealthCheck struct {
Test []string `json:"test"`
Interval string `json:"interval"`
Timeout string `json:"timeout"`
Retries int `json:"retries"`
StartPeriod string `json:"start_period"`
}
func NewCustomRuntimeManager(workDir string) *CustomRuntimeManager {
return &CustomRuntimeManager{
workDir: workDir,
buildpacksDir: filepath.Join(workDir, "buildpacks"),
runtimes: make(map[string]*CustomRuntime),
}
}
// LoadCustomRuntime loads a custom runtime from configuration
func (crm *CustomRuntimeManager) LoadCustomRuntime(ctx context.Context, configPath string) (*CustomRuntime, error) {
data, err := os.ReadFile(configPath)
if err != nil {
return nil, fmt.Errorf("failed to read runtime config: %w", err)
}
var runtime CustomRuntime
if err := json.Unmarshal(data, &runtime); err != nil {
return nil, fmt.Errorf("failed to parse runtime config: %w", err)
}
// Validate runtime configuration
if err := crm.validateRuntime(&runtime); err != nil {
return nil, fmt.Errorf("invalid runtime configuration: %w", err)
}
// Store runtime
crm.runtimes[runtime.Name] = &runtime
return &runtime, nil
}
// validateRuntime validates a custom runtime configuration
func (crm *CustomRuntimeManager) validateRuntime(runtime *CustomRuntime) error {
if runtime.Name == "" {
return fmt.Errorf("runtime name is required")
}
if runtime.Version == "" {
return fmt.Errorf("runtime version is required")
}
switch runtime.Type {
case "buildpack":
if runtime.Buildpack == nil {
return fmt.Errorf("buildpack configuration is required for buildpack type")
}
if runtime.Buildpack.ID == "" {
return fmt.Errorf("buildpack ID is required")
}
case "dockerfile":
if runtime.Dockerfile == nil {
return fmt.Errorf("dockerfile configuration is required for dockerfile type")
}
if runtime.Dockerfile.BaseImage == "" {
return fmt.Errorf("base image is required for dockerfile type")
}
case "native":
if runtime.Native == nil {
return fmt.Errorf("native configuration is required for native type")
}
if runtime.Native.Compiler == "" {
return fmt.Errorf("compiler is required for native type")
}
default:
return fmt.Errorf("unsupported runtime type: %s", runtime.Type)
}
return nil
}
// DetectCustomRuntime detects if a project uses a custom runtime
func (crm *CustomRuntimeManager) DetectCustomRuntime(ctx context.Context, projectPath string) (*CustomRuntime, error) {
// Check for runtime configuration file
runtimeFiles := []string{
"runtime.json",
".runtime.json",
"containr.json",
".containr.json",
"buildpack.yml",
".buildpack.yml",
}
for _, file := range runtimeFiles {
configPath := filepath.Join(projectPath, file)
if _, err := os.Stat(configPath); err == nil {
runtime, err := crm.LoadCustomRuntime(ctx, configPath)
if err != nil {
continue // Try next file
}
return runtime, nil
}
}
// Check for known custom runtime indicators
if runtime := crm.detectKnownRuntimes(projectPath); runtime != nil {
return runtime, nil
}
return nil, fmt.Errorf("no custom runtime detected")
}
// detectKnownRuntimes detects known custom runtimes from project structure
func (crm *CustomRuntimeManager) detectKnownRuntimes(projectPath string) *CustomRuntime {
// Detect Elixir/Phoenix
if crm.fileExists(filepath.Join(projectPath, "mix.exs")) {
return &CustomRuntime{
Name: "elixir",
Version: "1.15",
Type: "dockerfile",
Dockerfile: &CustomDockerfileConfig{
BaseImage: "elixir:1.15-alpine",
BuildArgs: map[string]string{
"MIX_ENV": "prod",
},
StartCmd: "mix phx.server",
},
}
}
// Detect Dart
if crm.fileExists(filepath.Join(projectPath, "pubspec.yaml")) {
return &CustomRuntime{
Name: "dart",
Version: "3.0",
Type: "dockerfile",
Dockerfile: &CustomDockerfileConfig{
BaseImage: "dart:3.0-sdk",
BuildArgs: map[string]string{
"BUILD_ENV": "production",
},
StartCmd: "dart run bin/server.dart",
},
}
}
// Detect Swift
if crm.fileExists(filepath.Join(projectPath, "Package.swift")) {
return &CustomRuntime{
Name: "swift",
Version: "5.9",
Type: "dockerfile",
Dockerfile: &CustomDockerfileConfig{
BaseImage: "swift:5.9",
BuildArgs: map[string]string{
"BUILD_CONFIGURATION": "release",
},
StartCmd: ".build/release/MyApp",
},
}
}
// Detect Kotlin
if crm.fileExists(filepath.Join(projectPath, "build.gradle.kts")) || crm.fileExists(filepath.Join(projectPath, "build.gradle")) {
return &CustomRuntime{
Name: "kotlin",
Version: "1.9",
Type: "dockerfile",
Dockerfile: &CustomDockerfileConfig{
BaseImage: "openjdk:17-jdk-slim",
BuildArgs: map[string]string{
"KOTLIN_VERSION": "1.9.0",
},
StartCmd: "java -jar app.jar",
},
}
}
return nil
}
// BuildWithCustomRuntime builds a project using custom runtime
func (crm *CustomRuntimeManager) BuildWithCustomRuntime(ctx context.Context, runtime *CustomRuntime, projectPath string, imageName string) error {
switch runtime.Type {
case "buildpack":
return crm.buildWithBuildpack(ctx, runtime, projectPath, imageName)
case "dockerfile":
return crm.buildWithDockerfile(ctx, runtime, projectPath, imageName)
case "native":
return crm.buildWithNative(ctx, runtime, projectPath, imageName)
default:
return fmt.Errorf("unsupported runtime type: %s", runtime.Type)
}
}
// buildWithBuildpack builds using buildpacks
func (crm *CustomRuntimeManager) buildWithBuildpack(ctx context.Context, runtime *CustomRuntime, projectPath string, imageName string) error {
// This would integrate with buildpack tools like pack or cnb
// For now, generate a Dockerfile based on buildpack configuration
dockerfile := crm.generateBuildpackDockerfile(runtime)
dockerfilePath := filepath.Join(crm.workDir, "Dockerfile.buildpack")
if err := os.WriteFile(dockerfilePath, []byte(dockerfile), 0644); err != nil {
return fmt.Errorf("failed to write buildpack Dockerfile: %w", err)
}
// Build using the generated Dockerfile
// This would use the docker client to build the image
fmt.Printf("Building with buildpack runtime: %s\n", runtime.Name)
return nil
}
// buildWithDockerfile builds using custom Dockerfile template
func (crm *CustomRuntimeManager) buildWithDockerfile(ctx context.Context, runtime *CustomRuntime, projectPath string, imageName string) error {
dockerfile := crm.generateCustomDockerfile(runtime)
dockerfilePath := filepath.Join(crm.workDir, "Dockerfile.custom")
if err := os.WriteFile(dockerfilePath, []byte(dockerfile), 0644); err != nil {
return fmt.Errorf("failed to write custom Dockerfile: %w", err)
}
// Build using the generated Dockerfile
fmt.Printf("Building with custom Dockerfile runtime: %s\n", runtime.Name)
return nil
}
// buildWithNative builds using native compilation
func (crm *CustomRuntimeManager) buildWithNative(ctx context.Context, runtime *CustomRuntime, projectPath string, imageName string) error {
// Generate a Dockerfile for native compilation
dockerfile := crm.generateNativeDockerfile(runtime)
dockerfilePath := filepath.Join(crm.workDir, "Dockerfile.native")
if err := os.WriteFile(dockerfilePath, []byte(dockerfile), 0644); err != nil {
return fmt.Errorf("failed to write native Dockerfile: %w", err)
}
// Build using the generated Dockerfile
fmt.Printf("Building with native runtime: %s\n", runtime.Name)
return nil
}
// generateBuildpackDockerfile generates a Dockerfile for buildpack-based builds
func (crm *CustomRuntimeManager) generateBuildpackDockerfile(runtime *CustomRuntime) string {
var builder strings.Builder
builder.WriteString(fmt.Sprintf("# Buildpack-based runtime: %s\n", runtime.Name))
builder.WriteString(fmt.Sprintf("FROM %s as builder\n", runtime.Dockerfile.BaseImage))
builder.WriteString("WORKDIR /app\n")
builder.WriteString("COPY . .\n")
// Add buildpack-specific instructions
if runtime.Buildpack != nil {
for _, buildpack := range runtime.Buildpack.Buildpacks {
builder.WriteString(fmt.Sprintf("RUN echo 'Installing buildpack: %s'\n", buildpack))
}
}
// Environment variables
if len(runtime.Environment) > 0 {
builder.WriteString("\n# Environment variables\n")
for k, v := range runtime.Environment {
builder.WriteString(fmt.Sprintf("ENV %s=%s\n", k, v))
}
}
builder.WriteString("\n# Production stage\n")
builder.WriteString("FROM scratch\n")
builder.WriteString("COPY --from=builder /app /app\n")
builder.WriteString("WORKDIR /app\n")
if runtime.Native != nil && runtime.Native.StartCmd != "" {
builder.WriteString(fmt.Sprintf("CMD [\"%s\"]\n", runtime.Native.StartCmd))
}
return builder.String()
}
// generateCustomDockerfile generates a custom Dockerfile from template
func (crm *CustomRuntimeManager) generateCustomDockerfile(runtime *CustomRuntime) string {
var builder strings.Builder
builder.WriteString(fmt.Sprintf("# Custom runtime: %s\n", runtime.Name))
builder.WriteString(fmt.Sprintf("FROM %s\n", runtime.Dockerfile.BaseImage))
builder.WriteString("WORKDIR /app\n")
// Environment variables
if len(runtime.Environment) > 0 {
builder.WriteString("\n# Environment variables\n")
for k, v := range runtime.Environment {
builder.WriteString(fmt.Sprintf("ENV %s=%s\n", k, v))
}
}
// Build arguments
if len(runtime.Dockerfile.BuildArgs) > 0 {
builder.WriteString("\n# Build arguments\n")
for k, v := range runtime.Dockerfile.BuildArgs {
builder.WriteString(fmt.Sprintf("ARG %s=%s\n", k, v))
}
}
// Labels
if len(runtime.Dockerfile.Labels) > 0 {
builder.WriteString("\n# Labels\n")
for k, v := range runtime.Dockerfile.Labels {
builder.WriteString(fmt.Sprintf("LABEL %s=%s\n", k, v))
}
}
builder.WriteString("\n# Copy source code\n")
builder.WriteString("COPY . .\n")
// Health check
if runtime.Dockerfile.HealthCheck != nil {
builder.WriteString("\n# Health check\n")
healthCheck := runtime.Dockerfile.HealthCheck
builder.WriteString(fmt.Sprintf("HEALTHCHECK --interval=%s --timeout=%s --retries=%d --start-period=%s \\\n",
healthCheck.Interval, healthCheck.Timeout, healthCheck.Retries, healthCheck.StartPeriod))
builder.WriteString(" CMD ")
for i, test := range healthCheck.Test {
if i > 0 {
builder.WriteString(" ")
}
builder.WriteString(fmt.Sprintf("\"%s\"", test))
}
builder.WriteString("\n")
}
// Default command
if runtime.Native != nil && runtime.Native.StartCmd != "" {
builder.WriteString(fmt.Sprintf("\nCMD [\"%s\"]\n", runtime.Native.StartCmd))
}
return builder.String()
}
// generateNativeDockerfile generates a Dockerfile for native compilation
func (crm *CustomRuntimeManager) generateNativeDockerfile(runtime *CustomRuntime) string {
var builder strings.Builder
builder.WriteString(fmt.Sprintf("# Native runtime: %s\n", runtime.Name))
builder.WriteString(fmt.Sprintf("FROM %s as builder\n", runtime.Dockerfile.BaseImage))
builder.WriteString("WORKDIR /app\n")
// Install compiler and dependencies
if runtime.Native != nil {
builder.WriteString(fmt.Sprintf("RUN echo 'Installing %s compiler'\n", runtime.Native.Compiler))
for _, dep := range runtime.Dependencies {
builder.WriteString(fmt.Sprintf("RUN echo 'Installing dependency: %s'\n", dep))
}
}
builder.WriteString("\n# Copy source code\n")
builder.WriteString("COPY . .\n")
// Build command
if runtime.Native != nil && runtime.Native.BuildCmd != "" {
builder.WriteString(fmt.Sprintf("\nRUN %s\n", runtime.Native.BuildCmd))
}
builder.WriteString("\n# Production stage\n")
builder.WriteString("FROM alpine:latest\n")
builder.WriteString("WORKDIR /app\n")
// Copy compiled binary
if runtime.Native != nil {
builder.WriteString("COPY --from=builder /app/app .\n")
}
// Start command
if runtime.Native != nil && runtime.Native.StartCmd != "" {
builder.WriteString(fmt.Sprintf("CMD [\"%s\"]\n", runtime.Native.StartCmd))
}
return builder.String()
}
// fileExists checks if a file exists
func (crm *CustomRuntimeManager) fileExists(path string) bool {
_, err := os.Stat(path)
return err == nil
}
// GetInstalledRuntimes returns all installed custom runtimes
func (crm *CustomRuntimeManager) GetInstalledRuntimes() map[string]*CustomRuntime {
return crm.runtimes
}
// InstallRuntime installs a custom runtime from a URL or local path
func (crm *CustomRuntimeManager) InstallRuntime(ctx context.Context, source string) error {
// This would download and install a runtime from a URL
// For now, just load from local path
runtime, err := crm.LoadCustomRuntime(ctx, source)
if err != nil {
return fmt.Errorf("failed to install runtime: %w", err)
}
fmt.Printf("Installed custom runtime: %s %s\n", runtime.Name, runtime.Version)
return nil
}
// OptimizeDependencies optimizes and caches dependencies for faster builds
func (crm *CustomRuntimeManager) OptimizeDependencies(ctx context.Context, runtime *CustomRuntime, projectPath string) error {
switch runtime.Type {
case "buildpack":
return crm.optimizeBuildpackDeps(ctx, runtime, projectPath)
case "dockerfile":
return crm.optimizeDockerfileDeps(ctx, runtime, projectPath)
case "native":
return crm.optimizeNativeDeps(ctx, runtime, projectPath)
default:
return fmt.Errorf("unsupported runtime type: %s", runtime.Type)
}
}
// optimizeBuildpackDeps optimizes buildpack dependencies
func (crm *CustomRuntimeManager) optimizeBuildpackDeps(ctx context.Context, runtime *CustomRuntime, projectPath string) error {
// Create dependency cache layers
fmt.Printf("Optimizing buildpack dependencies for %s\n", runtime.Name)
// This would create cached layers for common dependencies
// Implementation depends on the specific buildpack system being used
return nil
}
// optimizeDockerfileDeps optimizes Dockerfile dependencies
func (crm *CustomRuntimeManager) optimizeDockerfileDeps(ctx context.Context, runtime *CustomRuntime, projectPath string) error {
// Create optimized Dockerfile with dependency caching
fmt.Printf("Optimizing Dockerfile dependencies for %s\n", runtime.Name)
// This would reorganize the Dockerfile to optimize layer caching
// Common pattern: copy dependency files first, install deps, then copy source
return nil
}
// optimizeNativeDeps optimizes native compilation dependencies
func (crm *CustomRuntimeManager) optimizeNativeDeps(ctx context.Context, runtime *CustomRuntime, projectPath string) error {
// Create dependency cache for native builds
fmt.Printf("Optimizing native dependencies for %s\n", runtime.Name)
// This would cache compiled dependencies and object files
return nil
}
+578
View File
@@ -0,0 +1,578 @@
package build
import (
"archive/tar"
"bytes"
"context"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"containr/internal/docker"
"containr/internal/types"
)
type DockerfileBuilder struct {
workDir string
dockerClient *docker.Client
}
type DockerfileConfig struct {
DockerfilePath string `json:"dockerfile_path"`
ContextPath string `json:"context_path"`
BuildArgs map[string]string `json:"build_args"`
Target string `json:"target,omitempty"`
Labels map[string]string `json:"labels,omitempty"`
}
func NewDockerfileBuilder(workDir string, dockerClient *docker.Client) *DockerfileBuilder {
return &DockerfileBuilder{
workDir: workDir,
dockerClient: dockerClient,
}
}
// DetectDockerfile checks if a Dockerfile exists in the repository
func (d *DockerfileBuilder) DetectDockerfile(ctx context.Context, repoPath string) (string, error) {
dockerfiles := []string{
"Dockerfile",
"Dockerfile.prod",
"Dockerfile.production",
"Dockerfile.build",
"dockerfile",
"dockerfile.prod",
}
for _, dockerfile := range dockerfiles {
fullPath := filepath.Join(repoPath, dockerfile)
if _, err := os.Stat(fullPath); err == nil {
return dockerfile, nil
}
}
return "", fmt.Errorf("no Dockerfile found")
}
// ValidateDockerfile validates the Dockerfile syntax and structure
func (d *DockerfileBuilder) ValidateDockerfile(ctx context.Context, dockerfilePath string) error {
// Check if file exists
if _, err := os.Stat(dockerfilePath); os.IsNotExist(err) {
return fmt.Errorf("Dockerfile does not exist: %s", dockerfilePath)
}
// Read and validate basic structure
content, err := os.ReadFile(dockerfilePath)
if err != nil {
return fmt.Errorf("failed to read Dockerfile: %w", err)
}
dockerfileContent := string(content)
lines := strings.Split(dockerfileContent, "\n")
hasFrom := false
hasExposeOrCmd := false
for _, line := range lines {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "FROM") {
hasFrom = true
}
if strings.HasPrefix(line, "EXPOSE") || strings.HasPrefix(line, "CMD") || strings.HasPrefix(line, "ENTRYPOINT") {
hasExposeOrCmd = true
}
}
if !hasFrom {
return fmt.Errorf("Dockerfile must have a FROM instruction")
}
if !hasExposeOrCmd {
return fmt.Errorf("Dockerfile should have EXPOSE, CMD, or ENTRYPOINT instruction")
}
return nil
}
// OptimizeDockerfile applies common optimizations to the Dockerfile
func (d *DockerfileBuilder) OptimizeDockerfile(ctx context.Context, dockerfilePath string) (string, error) {
content, err := os.ReadFile(dockerfilePath)
if err != nil {
return "", fmt.Errorf("failed to read Dockerfile: %w", err)
}
optimized := d.optimizeDockerfileContent(string(content))
optimizedPath := filepath.Join(d.workDir, "Dockerfile.optimized")
err = os.WriteFile(optimizedPath, []byte(optimized), 0644)
if err != nil {
return "", fmt.Errorf("failed to write optimized Dockerfile: %w", err)
}
return optimizedPath, nil
}
func (d *DockerfileBuilder) optimizeDockerfileContent(content string) string {
lines := strings.Split(content, "\n")
var optimized []string
// Track if we've already added common optimizations
hasMultiStage := false
hasSecurityOptimizations := false
for _, line := range lines {
trimmed := strings.TrimSpace(line)
// Skip empty lines and comments
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
optimized = append(optimized, line)
continue
}
// Check for multi-stage builds
if strings.HasPrefix(trimmed, "FROM") && strings.Contains(trimmed, " AS ") {
hasMultiStage = true
}
// Check for build cache optimizations
if strings.Contains(trimmed, "RUN") && strings.Contains(trimmed, "npm") {
hasMultiStage = true
}
optimized = append(optimized, line)
}
// Add optimizations if not present
if !hasMultiStage {
// Insert multi-stage build suggestion at the beginning
optimized = append([]string{
"# Multi-stage build for smaller production image",
"FROM node:20-alpine AS builder",
"WORKDIR /app",
"COPY package*.json ./",
"RUN npm ci --only=production",
"COPY . .",
"RUN npm run build",
"",
"# Production stage",
"FROM node:20-alpine AS production",
"WORKDIR /app",
"COPY --from=builder /app/dist ./dist",
"COPY --from=builder /app/node_modules ./node_modules",
"EXPOSE 8080",
"CMD [\"npm\", \"start\"]",
}, optimized...)
}
if !hasSecurityOptimizations {
optimized = append(optimized, "", "# Security optimizations")
optimized = append(optimized, "RUN addgroup -g 1001 -S nodejs")
optimized = append(optimized, "RUN adduser -S nodejs -u 1001")
optimized = append(optimized, "USER nodejs")
}
return strings.Join(optimized, "\n")
}
// Build builds the container image using Dockerfile
func (d *DockerfileBuilder) Build(ctx context.Context, req *types.BuildRequest) (*types.BuildResponse, error) {
// Detect Dockerfile
dockerfileName, err := d.DetectDockerfile(ctx, req.SourcePath)
if err != nil {
return nil, fmt.Errorf("failed to detect Dockerfile: %w", err)
}
dockerfilePath := filepath.Join(req.SourcePath, dockerfileName)
// Validate Dockerfile
err = d.ValidateDockerfile(ctx, dockerfilePath)
if err != nil {
return nil, fmt.Errorf("invalid Dockerfile: %w", err)
}
// Optimize Dockerfile (optional)
optimizedDockerfile, err := d.OptimizeDockerfile(ctx, dockerfilePath)
if err != nil {
// If optimization fails, use original
optimizedDockerfile = dockerfilePath
}
// Prepare build args
buildArgs := make(map[string]*string)
for k, v := range req.Environment {
buildArgs[k] = &v
}
// Add default build args
buildArgs["BUILDKIT_INLINE_CACHE"] = strPtr("1")
buildArgs["TARGETPLATFORM"] = strPtr("linux/amd64")
// Build Docker image
imageName := fmt.Sprintf("%s:%s", req.ImageName, req.ImageTag)
// Create build context tar
buildCtx, err := createBuildContext(req.SourcePath, optimizedDockerfile)
if err != nil {
return nil, fmt.Errorf("failed to create build context: %w", err)
}
defer buildCtx.Close()
buildOptions := docker.BuildOptions{
Dockerfile: "Dockerfile",
Tags: []string{imageName},
BuildArgs: buildArgs,
Remove: true,
}
_, err = d.dockerClient.BuildImage(ctx, buildCtx, buildOptions)
if err != nil {
return nil, fmt.Errorf("failed to build Docker image: %w", err)
}
// Get image info
imageInfo, err := d.dockerClient.GetImageInfo(ctx, imageName)
if err != nil {
// Continue even if we can't get image info
imageInfo = &docker.ImageInfo{Size: 0}
}
// Push to registry if specified
if req.RegistryURL != "" {
fullImageName := fmt.Sprintf("%s/%s:%s", req.RegistryURL, req.ImageName, req.ImageTag)
err = d.dockerClient.PushImage(ctx, imageName, req.RegistryURL)
if err != nil {
return nil, fmt.Errorf("failed to push image: %w", err)
}
imageName = fullImageName
}
return &types.BuildResponse{
ImageName: imageName,
ImageTag: req.ImageTag,
Size: imageInfo.Size,
Digest: imageInfo.Digest,
}, nil
}
// GenerateDockerfile generates a basic Dockerfile for common runtimes
func (d *DockerfileBuilder) GenerateDockerfile(ctx context.Context, runtime, appPath string) (string, error) {
switch runtime {
case "go":
return d.generateGoDockerfile(appPath), nil
case "node":
return d.generateNodeDockerfile(appPath), nil
case "python":
return d.generatePythonDockerfile(appPath), nil
case "rust":
return d.generateRustDockerfile(appPath), nil
case "static":
return d.generateStaticDockerfile(appPath), nil
default:
return d.generateGenericDockerfile(appPath), nil
}
}
func (d *DockerfileBuilder) generateGoDockerfile(appPath string) string {
return `# Go Dockerfile
FROM golang:1.21-alpine AS builder
WORKDIR /app
# Install git (required for some go modules)
RUN apk add --no-cache git
# Copy go mod files
COPY go.mod go.sum ./
# Download dependencies
RUN go mod download
# Copy source code
COPY . .
# Build the application
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o main .
# Production stage
FROM alpine:latest
RUN apk --no-cache add ca-certificates
WORKDIR /root/
# Copy the binary from builder stage
COPY --from=builder /app/main .
# Expose port
EXPOSE 8080
# Run the binary
CMD ["./main"]
`
}
func (d *DockerfileBuilder) generateNodeDockerfile(appPath string) string {
return `# Node.js Dockerfile
FROM node:20-alpine AS builder
WORKDIR /app
# Copy package files
COPY package*.json ./
# Install dependencies
RUN npm ci --only=production
# Copy source code
COPY . .
# Build the application (if needed)
RUN npm run build || true
# Production stage
FROM node:20-alpine
WORKDIR /app
# Create non-root user
RUN addgroup -g 1001 -S nodejs
RUN adduser -S nodejs -u 1001
# Copy built application and dependencies
COPY --from=builder --chown=nodejs:nodejs /app/dist ./dist
COPY --from=builder --chown=nodejs:nodejs /app/node_modules ./node_modules
COPY --from=builder --chown=nodejs:nodejs /app/package*.json ./
# Switch to non-root user
USER nodejs
# Expose port
EXPOSE 8080
# Start the application
CMD ["npm", "start"]
`
}
func (d *DockerfileBuilder) generatePythonDockerfile(appPath string) string {
return `# Python Dockerfile
FROM python:3.11-slim AS builder
WORKDIR /app
# Install system dependencies
RUN apt-get update && apt-get install -y \
gcc \
&& rm -rf /var/lib/apt/lists/*
# Copy requirements
COPY requirements.txt .
# Install Python dependencies
RUN pip install --no-cache-dir -r requirements.txt
# Production stage
FROM python:3.11-slim
WORKDIR /app
# Create non-root user
RUN useradd --create-home --shell /bin/bash app
# Copy installed packages
COPY --from=builder /usr/local/lib/python3.11/site-packages /usr/local/lib/python3.11/site-packages
COPY --from=builder /usr/local/bin /usr/local/bin
# Copy application code
COPY --chown=app:app . .
# Switch to non-root user
USER app
# Expose port
EXPOSE 8080
# Start the application
CMD ["python", "app.py"]
`
}
func (d *DockerfileBuilder) generateRustDockerfile(appPath string) string {
return `# Rust Dockerfile
FROM rust:1.75-alpine AS builder
WORKDIR /app
# Install build dependencies
RUN apk add --no-cache musl-dev
# Copy Cargo files
COPY Cargo.toml Cargo.lock ./
# Create dummy main.rs to cache dependencies
RUN mkdir src && echo "fn main() {}" > src/main.rs
RUN cargo build --release
RUN rm -rf src
# Copy source code
COPY src ./src
# Build the application
RUN cargo build --release
# Production stage
FROM alpine:latest
WORKDIR /app
# Install runtime dependencies
RUN apk add --no-cache ca-certificates
# Copy the binary from builder stage
COPY --from=builder /app/target/release/app .
# Create non-root user
RUN addgroup -g 1001 -S appgroup
RUN adduser -S appuser -u 1001 -G appgroup
# Change ownership and switch to non-root user
RUN chown appuser:appgroup /app/app
USER appuser
# Expose port
EXPOSE 8080
# Run the binary
CMD ["./app"]
`
}
func (d *DockerfileBuilder) generateStaticDockerfile(appPath string) string {
return `# Static files Dockerfile
FROM nginx:alpine
# Copy static files
COPY . /usr/share/nginx/html
# Copy custom nginx config if exists
COPY nginx.conf /etc/nginx/nginx.conf || true
# Expose port
EXPOSE 8080
# Start nginx
CMD ["nginx", "-g", "daemon off;"]
`
}
func (d *DockerfileBuilder) generateGenericDockerfile(appPath string) string {
return `# Generic Dockerfile
FROM alpine:latest
WORKDIR /app
# Install basic runtime
RUN apk add --no-cache ca-certificates
# Copy application
COPY . .
# Create non-root user
RUN addgroup -g 1001 -S appgroup
RUN adduser -S appuser -u 1001 -G appgroup
# Change ownership
RUN chown -R appuser:appgroup /app
USER appuser
# Expose port
EXPOSE 8080
# Default command - override in your application
CMD ["./app"]
`
}
func strPtr(s string) *string {
return &s
}
func createBuildContext(sourcePath, dockerfileContent string) (io.ReadCloser, error) {
// Create a temporary directory for the build context
tmpDir, err := os.MkdirTemp("", "docker-build-")
if err != nil {
return nil, err
}
defer os.RemoveAll(tmpDir)
// Write Dockerfile to temp directory
dockerfilePath := filepath.Join(tmpDir, "Dockerfile")
if err := os.WriteFile(dockerfilePath, []byte(dockerfileContent), 0644); err != nil {
return nil, err
}
// Create tar archive of the build context
var buf bytes.Buffer
tw := tar.NewWriter(&buf)
defer tw.Close()
// Add Dockerfile to tar
dockerfileData, err := os.ReadFile(dockerfilePath)
if err != nil {
return nil, err
}
hdr := &tar.Header{
Name: "Dockerfile",
Mode: 0644,
Size: int64(len(dockerfileData)),
}
if err := tw.WriteHeader(hdr); err != nil {
return nil, err
}
if _, err := tw.Write(dockerfileData); err != nil {
return nil, err
}
// Add source files to tar
err = filepath.Walk(sourcePath, func(file string, fi os.FileInfo, err error) error {
if err != nil {
return err
}
if fi.IsDir() {
return nil
}
relPath, err := filepath.Rel(sourcePath, file)
if err != nil {
return err
}
if relPath == "Dockerfile" {
return nil
}
data, err := os.ReadFile(file)
if err != nil {
return err
}
hdr := &tar.Header{
Name: relPath,
Mode: int64(fi.Mode()),
Size: int64(len(data)),
}
if err := tw.WriteHeader(hdr); err != nil {
return err
}
if _, err := tw.Write(data); err != nil {
return err
}
return nil
})
if err != nil {
return nil, err
}
return io.NopCloser(&buf), nil
}
+240
View File
@@ -0,0 +1,240 @@
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
auth := registry.AuthConfig{}
_, err := bm.dockerClient.PullImage(ctx, req.PrebuiltImage, auth)
if 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)
}
+575
View File
@@ -0,0 +1,575 @@
package build
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"containr/internal/docker"
"containr/internal/types"
)
type NixpacksBuilder struct {
workDir string
dockerClient *docker.Client
}
type NixpacksPlan struct {
Pkgs []struct {
Name string `json:"name"`
Version string `json:"version"`
Path string `json:"path"`
} `json:"pkgs"`
Build struct {
Builder string `json:"builder"`
Args []string `json:"args"`
} `json:"build"`
Start struct {
Cmd []string `json:"cmd"`
Env map[string]string `json:"env"`
Dir string `json:"dir"`
} `json:"start"`
Install struct {
Cmd []string `json:"cmd"`
} `json:"install"`
}
type NixpacksConfig struct {
BuildCmd string `json:"build_cmd,omitempty"`
StartCmd string `json:"start_cmd,omitempty"`
Pkgs []string `json:"pkgs,omitempty"`
Env map[string]string `json:"env,omitempty"`
InstallCmd string `json:"install_cmd,omitempty"`
}
func NewNixpacksBuilder(workDir string, dockerClient *docker.Client) *NixpacksBuilder {
return &NixpacksBuilder{
workDir: workDir,
dockerClient: dockerClient,
}
}
// DetectRuntime detects the runtime based on project files
func (n *NixpacksBuilder) DetectRuntime(ctx context.Context, repoPath string) (string, error) {
detections := map[string][]string{
"go": {"go.mod", "go.sum", "main.go"},
"node": {"package.json", "package-lock.json", "yarn.lock", "pnpm-lock.yaml"},
"python": {"requirements.txt", "setup.py", "pyproject.toml", "Pipfile"},
"rust": {"Cargo.toml", "Cargo.lock"},
"ruby": {"Gemfile", "Gemfile.lock"},
"php": {"composer.json", "composer.lock"},
"deno": {"deno.json", "deno.jsonc"},
"bun": {"bun.lockb", "package.json"},
"static": {"index.html", "dist/", "public/"},
}
for runtime, files := range detections {
for _, file := range files {
fullPath := filepath.Join(repoPath, file)
if _, err := os.Stat(fullPath); err == nil {
return runtime, nil
}
}
}
return "unknown", nil
}
// GeneratePlan generates a Nixpacks build plan
func (n *NixpacksBuilder) GeneratePlan(ctx context.Context, repoPath string, config *NixpacksConfig) (*NixpacksPlan, error) {
runtime, err := n.DetectRuntime(ctx, repoPath)
if err != nil {
return nil, fmt.Errorf("failed to detect runtime: %w", err)
}
plan := &NixpacksPlan{}
switch runtime {
case "go":
plan = n.generateGoPlan(config)
case "node":
plan = n.generateNodePlan(config)
case "python":
plan = n.generatePythonPlan(config)
case "rust":
plan = n.generateRustPlan(config)
case "ruby":
plan = n.generateRubyPlan(config)
case "php":
plan = n.generatePHPPlan(config)
case "deno":
plan = n.generateDenoPlan(config)
case "bun":
plan = n.generateBunPlan(config)
case "static":
plan = n.generateStaticPlan(config)
default:
return nil, fmt.Errorf("unsupported runtime: %s", runtime)
}
return plan, nil
}
func (n *NixpacksBuilder) generateGoPlan(config *NixpacksConfig) *NixpacksPlan {
plan := &NixpacksPlan{}
// Base packages
plan.Pkgs = []struct {
Name string `json:"name"`
Version string `json:"version"`
Path string `json:"path"`
}{
{Name: "go", Version: "1.21", Path: "pkgs.go"},
{Name: "cacert", Version: "latest", Path: "pkgs.cacert"},
}
// Build configuration
if config.BuildCmd != "" {
plan.Build.Builder = "custom"
plan.Build.Args = strings.Fields(config.BuildCmd)
} else {
plan.Build.Builder = "go"
plan.Build.Args = []string{"build", "-o", "app", "."}
}
// Start configuration
if config.StartCmd != "" {
plan.Start.Cmd = strings.Fields(config.StartCmd)
} else {
plan.Start.Cmd = []string{"./app"}
}
plan.Start.Dir = "/app"
// Environment
plan.Start.Env = map[string]string{
"GOPATH": "/go",
"GOCACHE": "/tmp/go-cache",
"GOMODCACHE": "/tmp/go-mod-cache",
"SSL_CERT_FILE": "/etc/ssl/certs/ca-certificates.crt",
}
// Add custom environment
for k, v := range config.Env {
plan.Start.Env[k] = v
}
return plan
}
func (n *NixpacksBuilder) generateNodePlan(config *NixpacksConfig) *NixpacksPlan {
plan := &NixpacksPlan{}
plan.Pkgs = []struct {
Name string `json:"name"`
Version string `json:"version"`
Path string `json:"path"`
}{
{Name: "nodejs", Version: "20", Path: "pkgs.nodejs"},
{Name: "cacert", Version: "latest", Path: "pkgs.cacert"},
}
if config.BuildCmd != "" {
plan.Build.Builder = "custom"
plan.Build.Args = strings.Fields(config.BuildCmd)
} else {
plan.Build.Builder = "npm"
plan.Build.Args = []string{"run", "build"}
}
if config.StartCmd != "" {
plan.Start.Cmd = strings.Fields(config.StartCmd)
} else {
plan.Start.Cmd = []string{"npm", "start"}
}
plan.Start.Dir = "/app"
plan.Start.Env = map[string]string{
"NODE_ENV": "production",
"SSL_CERT_FILE": "/etc/ssl/certs/ca-certificates.crt",
}
for k, v := range config.Env {
plan.Start.Env[k] = v
}
return plan
}
func (n *NixpacksBuilder) generatePythonPlan(config *NixpacksConfig) *NixpacksPlan {
plan := &NixpacksPlan{}
plan.Pkgs = []struct {
Name string `json:"name"`
Version string `json:"version"`
Path string `json:"path"`
}{
{Name: "python3", Version: "3.11", Path: "pkgs.python3"},
{Name: "cacert", Version: "latest", Path: "pkgs.cacert"},
}
if config.BuildCmd != "" {
plan.Build.Builder = "custom"
plan.Build.Args = strings.Fields(config.BuildCmd)
} else {
plan.Build.Builder = "pip"
plan.Build.Args = []string{"install", "-r", "requirements.txt"}
}
if config.StartCmd != "" {
plan.Start.Cmd = strings.Fields(config.StartCmd)
} else {
plan.Start.Cmd = []string{"python", "app.py"}
}
plan.Start.Dir = "/app"
plan.Start.Env = map[string]string{
"PYTHONPATH": "/app",
"SSL_CERT_FILE": "/etc/ssl/certs/ca-certificates.crt",
}
for k, v := range config.Env {
plan.Start.Env[k] = v
}
return plan
}
func (n *NixpacksBuilder) generateRustPlan(config *NixpacksConfig) *NixpacksPlan {
plan := &NixpacksPlan{}
plan.Pkgs = []struct {
Name string `json:"name"`
Version string `json:"version"`
Path string `json:"path"`
}{
{Name: "rustc", Version: "latest", Path: "pkgs.rustc"},
{Name: "cargo", Version: "latest", Path: "pkgs.cargo"},
{Name: "cacert", Version: "latest", Path: "pkgs.cacert"},
}
if config.BuildCmd != "" {
plan.Build.Builder = "custom"
plan.Build.Args = strings.Fields(config.BuildCmd)
} else {
plan.Build.Builder = "cargo"
plan.Build.Args = []string{"build", "--release"}
}
if config.StartCmd != "" {
plan.Start.Cmd = strings.Fields(config.StartCmd)
} else {
plan.Start.Cmd = []string{"./target/release/app"}
}
plan.Start.Dir = "/app"
plan.Start.Env = map[string]string{
"SSL_CERT_FILE": "/etc/ssl/certs/ca-certificates.crt",
}
for k, v := range config.Env {
plan.Start.Env[k] = v
}
return plan
}
func (n *NixpacksBuilder) generateRubyPlan(config *NixpacksConfig) *NixpacksPlan {
plan := &NixpacksPlan{}
plan.Pkgs = []struct {
Name string `json:"name"`
Version string `json:"version"`
Path string `json:"path"`
}{
{Name: "ruby", Version: "3.2", Path: "pkgs.ruby"},
{Name: "bundler", Version: "latest", Path: "pkgs.bundler"},
{Name: "cacert", Version: "latest", Path: "pkgs.cacert"},
}
if config.BuildCmd != "" {
plan.Build.Builder = "custom"
plan.Build.Args = strings.Fields(config.BuildCmd)
} else {
plan.Build.Builder = "bundler"
plan.Build.Args = []string{"install"}
}
if config.StartCmd != "" {
plan.Start.Cmd = strings.Fields(config.StartCmd)
} else {
plan.Start.Cmd = []string{"bundle", "exec", "ruby", "app.rb"}
}
plan.Start.Dir = "/app"
plan.Start.Env = map[string]string{
"GEM_HOME": "/app/vendor/bundle/ruby/3.2.0",
"SSL_CERT_FILE": "/etc/ssl/certs/ca-certificates.crt",
}
for k, v := range config.Env {
plan.Start.Env[k] = v
}
return plan
}
func (n *NixpacksBuilder) generatePHPPlan(config *NixpacksConfig) *NixpacksPlan {
plan := &NixpacksPlan{}
plan.Pkgs = []struct {
Name string `json:"name"`
Version string `json:"version"`
Path string `json:"path"`
}{
{Name: "php", Version: "8.2", Path: "pkgs.php"},
{Name: "composer", Version: "latest", Path: "pkgs.composer"},
{Name: "cacert", Version: "latest", Path: "pkgs.cacert"},
}
if config.BuildCmd != "" {
plan.Build.Builder = "custom"
plan.Build.Args = strings.Fields(config.BuildCmd)
} else {
plan.Build.Builder = "composer"
plan.Build.Args = []string{"install"}
}
if config.StartCmd != "" {
plan.Start.Cmd = strings.Fields(config.StartCmd)
} else {
plan.Start.Cmd = []string{"php", "-S", "0.0.0.0:8080", "-t", "public"}
}
plan.Start.Dir = "/app"
plan.Start.Env = map[string]string{
"SSL_CERT_FILE": "/etc/ssl/certs/ca-certificates.crt",
}
for k, v := range config.Env {
plan.Start.Env[k] = v
}
return plan
}
func (n *NixpacksBuilder) generateDenoPlan(config *NixpacksConfig) *NixpacksPlan {
plan := &NixpacksPlan{}
plan.Pkgs = []struct {
Name string `json:"name"`
Version string `json:"version"`
Path string `json:"path"`
}{
{Name: "deno", Version: "latest", Path: "pkgs.deno"},
{Name: "cacert", Version: "latest", Path: "pkgs.cacert"},
}
if config.BuildCmd != "" {
plan.Build.Builder = "custom"
plan.Build.Args = strings.Fields(config.BuildCmd)
} else {
plan.Build.Builder = "deno"
plan.Build.Args = []string{"cache"}
}
if config.StartCmd != "" {
plan.Start.Cmd = strings.Fields(config.StartCmd)
} else {
plan.Start.Cmd = []string{"deno", "task", "start"}
}
plan.Start.Dir = "/app"
plan.Start.Env = map[string]string{
"DENO_DIR": "/deno-dir",
"SSL_CERT_FILE": "/etc/ssl/certs/ca-certificates.crt",
}
for k, v := range config.Env {
plan.Start.Env[k] = v
}
return plan
}
func (n *NixpacksBuilder) generateBunPlan(config *NixpacksConfig) *NixpacksPlan {
plan := &NixpacksPlan{}
plan.Pkgs = []struct {
Name string `json:"name"`
Version string `json:"version"`
Path string `json:"path"`
}{
{Name: "bun", Version: "latest", Path: "pkgs.bun"},
{Name: "cacert", Version: "latest", Path: "pkgs.cacert"},
}
if config.BuildCmd != "" {
plan.Build.Builder = "custom"
plan.Build.Args = strings.Fields(config.BuildCmd)
} else {
plan.Build.Builder = "bun"
plan.Build.Args = []string{"install"}
}
if config.StartCmd != "" {
plan.Start.Cmd = strings.Fields(config.StartCmd)
} else {
plan.Start.Cmd = []string{"bun", "start"}
}
plan.Start.Dir = "/app"
plan.Start.Env = map[string]string{
"SSL_CERT_FILE": "/etc/ssl/certs/ca-certificates.crt",
}
for k, v := range config.Env {
plan.Start.Env[k] = v
}
return plan
}
func (n *NixpacksBuilder) generateStaticPlan(config *NixpacksConfig) *NixpacksPlan {
plan := &NixpacksPlan{}
plan.Pkgs = []struct {
Name string `json:"name"`
Version string `json:"version"`
Path string `json:"path"`
}{
{Name: "nginx", Version: "latest", Path: "pkgs.nginx"},
}
plan.Build.Builder = "static"
plan.Build.Args = []string{}
if config.StartCmd != "" {
plan.Start.Cmd = strings.Fields(config.StartCmd)
} else {
plan.Start.Cmd = []string{"nginx", "-g", "daemon off;"}
}
plan.Start.Dir = "/usr/share/nginx/html"
plan.Start.Env = map[string]string{}
for k, v := range config.Env {
plan.Start.Env[k] = v
}
return plan
}
// Build builds the container image using Nixpacks
func (n *NixpacksBuilder) Build(ctx context.Context, req *types.BuildRequest) (*types.BuildResponse, error) {
// Generate build plan
config := &NixpacksConfig{
BuildCmd: req.BuildCommand,
StartCmd: req.StartCommand,
Env: req.Environment,
}
plan, err := n.GeneratePlan(ctx, req.SourcePath, config)
if err != nil {
return nil, fmt.Errorf("failed to generate build plan: %w", err)
}
// Create temporary Dockerfile
dockerfile, err := n.generateDockerfile(plan)
if err != nil {
return nil, fmt.Errorf("failed to generate Dockerfile: %w", err)
}
defer os.Remove(dockerfile)
// Build Docker image
imageName := fmt.Sprintf("%s:%s", req.ImageName, req.ImageTag)
// Create build context tar
buildCtx, err := createBuildContext(req.SourcePath, dockerfile)
if err != nil {
return nil, fmt.Errorf("failed to create build context: %w", err)
}
defer buildCtx.Close()
buildOptions := docker.BuildOptions{
Dockerfile: "Dockerfile",
Tags: []string{imageName},
BuildArgs: map[string]*string{
"BUILDKIT_INLINE_CACHE": strPtr("1"),
},
Remove: true,
}
_, err = n.dockerClient.BuildImage(ctx, buildCtx, buildOptions)
if err != nil {
return nil, fmt.Errorf("failed to build Docker image: %w", err)
}
// Push to registry if specified
if req.RegistryURL != "" {
err = n.dockerClient.PushImage(ctx, imageName, req.RegistryURL)
if err != nil {
return nil, fmt.Errorf("failed to push image: %w", err)
}
imageName = fmt.Sprintf("%s/%s:%s", req.RegistryURL, req.ImageName, req.ImageTag)
}
return &types.BuildResponse{
ImageName: imageName,
ImageTag: req.ImageTag,
Size: 0, // TODO: Get actual image size
Digest: "", // TODO: Get image digest
}, nil
}
func (n *NixpacksBuilder) generateDockerfile(plan *NixpacksPlan) (string, error) {
dockerfile := filepath.Join(n.workDir, "Dockerfile")
var content strings.Builder
// Base image
content.WriteString("FROM nixpkgs/nix:latest\n\n")
// Install packages
content.WriteString("# Install packages\n")
for _, pkg := range plan.Pkgs {
content.WriteString(fmt.Sprintf("RUN nix-env -iA nixpkgs.%s\n", pkg.Name))
}
content.WriteString("\n")
// Set working directory
content.WriteString("WORKDIR /app\n\n")
// Copy source code
content.WriteString("# Copy source code\n")
content.WriteString("COPY . .\n\n")
// Build step
if len(plan.Build.Args) > 0 {
content.WriteString("# Build application\n")
content.WriteString("RUN " + strings.Join(plan.Build.Args, " ") + "\n\n")
}
// Environment variables
if len(plan.Start.Env) > 0 {
content.WriteString("# Set environment variables\n")
for k, v := range plan.Start.Env {
content.WriteString(fmt.Sprintf("ENV %s=%s\n", k, v))
}
content.WriteString("\n")
}
// Start command
if len(plan.Start.Cmd) > 0 {
content.WriteString("# Start application\n")
content.WriteString("CMD [" + strings.Join(plan.Start.Cmd, ", ") + "]\n")
}
err := os.WriteFile(dockerfile, []byte(content.String()), 0644)
if err != nil {
return "", err
}
return dockerfile, nil
}
+428
View File
@@ -0,0 +1,428 @@
package build
import (
"context"
"fmt"
"sync"
"time"
"containr/internal/types"
)
// ParallelBuilder handles parallel build execution
type ParallelBuilder struct {
buildManager *BuildManager
cacheManager *CacheManager
maxWorkers int
}
// BuildJob represents a build job in the queue
type BuildJob struct {
ID string
Request *types.BuildRequest
Result chan *BuildResult
Priority int
CreatedAt time.Time
}
// BuildResult represents the result of a build job
type BuildResult struct {
Success bool
Response *types.BuildResponse
Error error
Duration time.Duration
CacheHit bool
BuildID string
}
// WorkerPool manages a pool of build workers
type WorkerPool struct {
jobs chan *BuildJob
workers []*Worker
wg sync.WaitGroup
quit chan bool
maxWorkers int
buildManager *BuildManager
cacheManager *CacheManager
}
// Worker represents a build worker
type Worker struct {
id int
jobChan chan *BuildJob
quit chan bool
buildManager *BuildManager
cacheManager *CacheManager
}
// NewParallelBuilder creates a new parallel build manager
func NewParallelBuilder(buildManager *BuildManager, cacheManager *CacheManager, maxWorkers int) *ParallelBuilder {
return &ParallelBuilder{
buildManager: buildManager,
cacheManager: cacheManager,
maxWorkers: maxWorkers,
}
}
// BuildWithCache attempts to build using cache first
func (pb *ParallelBuilder) BuildWithCache(ctx context.Context, req *types.BuildRequest) (*BuildResult, error) {
startTime := time.Now()
// Generate cache key
cacheKey, err := pb.cacheManager.GenerateCacheKey(ctx, req)
if err != nil {
return &BuildResult{
Success: false,
Error: fmt.Errorf("failed to generate cache key: %w", err),
Duration: time.Since(startTime),
CacheHit: false,
}, err
}
// Try to find cached result
if cachedEntry, err := pb.cacheManager.LookupCache(ctx, cacheKey); err == nil {
return &BuildResult{
Success: true,
Response: &types.BuildResponse{
ImageName: cachedEntry.ImageName,
ImageTag: req.ImageTag,
Size: cachedEntry.Size,
Digest: cachedEntry.Hash,
},
Duration: time.Since(startTime),
CacheHit: true,
}, nil
}
// No cache hit, proceed with build
response, err := pb.buildManager.Build(ctx, req)
duration := time.Since(startTime)
if err != nil {
return &BuildResult{
Success: false,
Error: err,
Duration: duration,
CacheHit: false,
}, err
}
// Store in cache for future builds
metadata := map[string]string{
"build_type": req.BuildType,
"runtime": cacheKey.Runtime,
"build_time": duration.String(),
}
if storeErr := pb.cacheManager.StoreCache(ctx, cacheKey, response.ImageName, metadata); storeErr != nil {
fmt.Printf("Warning: failed to store build cache: %v\n", storeErr)
}
return &BuildResult{
Success: true,
Response: response,
Duration: duration,
CacheHit: false,
}, nil
}
// NewWorkerPool creates a new worker pool for parallel builds
func NewWorkerPool(maxWorkers int, buildManager *BuildManager, cacheManager *CacheManager) *WorkerPool {
return &WorkerPool{
jobs: make(chan *BuildJob, 100), // Buffered channel
workers: make([]*Worker, maxWorkers),
quit: make(chan bool),
maxWorkers: maxWorkers,
buildManager: buildManager,
cacheManager: cacheManager,
}
}
// Start starts the worker pool
func (wp *WorkerPool) Start() {
for i := 0; i < wp.maxWorkers; i++ {
worker := &Worker{
id: i,
jobChan: wp.jobs,
quit: wp.quit,
buildManager: wp.buildManager,
cacheManager: wp.cacheManager,
}
wp.workers[i] = worker
wp.wg.Add(1)
go worker.start(&wp.wg)
}
}
// Stop stops the worker pool
func (wp *WorkerPool) Stop() {
close(wp.quit)
wp.wg.Wait()
}
// SubmitJob submits a build job to the worker pool
func (wp *WorkerPool) SubmitJob(ctx context.Context, job *BuildJob) error {
select {
case wp.jobs <- job:
return nil
case <-ctx.Done():
return ctx.Err()
}
}
// BuildParallel executes builds in parallel using the worker pool
func (pb *ParallelBuilder) BuildParallel(ctx context.Context, requests []*types.BuildRequest) ([]*BuildResult, error) {
if len(requests) == 0 {
return nil, fmt.Errorf("no build requests provided")
}
// Create worker pool
workerPool := NewWorkerPool(pb.maxWorkers, pb.buildManager, pb.cacheManager)
workerPool.Start()
defer workerPool.Stop()
// Create jobs
jobs := make([]*BuildJob, len(requests))
results := make([]*BuildResult, len(requests))
for i, req := range requests {
job := &BuildJob{
ID: fmt.Sprintf("build-%d-%d", time.Now().UnixNano(), i),
Request: req,
Result: make(chan *BuildResult, 1),
Priority: 0,
CreatedAt: time.Now(),
}
jobs[i] = job
// Submit job
if err := workerPool.SubmitJob(ctx, job); err != nil {
return nil, fmt.Errorf("failed to submit job %s: %w", job.ID, err)
}
}
// Collect results
var wg sync.WaitGroup
for i, job := range jobs {
wg.Add(1)
go func(index int, buildJob *BuildJob) {
defer wg.Done()
select {
case result := <-buildJob.Result:
results[index] = result
case <-ctx.Done():
results[index] = &BuildResult{
Success: false,
Error: ctx.Err(),
BuildID: buildJob.ID,
}
}
}(i, job)
}
wg.Wait()
return results, nil
}
// BuildBatch executes a batch of builds with optimized scheduling
func (pb *ParallelBuilder) BuildBatch(ctx context.Context, requests []*types.BuildRequest) (*BatchBuildResult, error) {
if len(requests) == 0 {
return nil, fmt.Errorf("no build requests provided")
}
startTime := time.Now()
// Group builds by cache key potential for optimization
cacheGroups := pb.groupByCachePotential(ctx, requests)
// Execute builds in optimized order
var allResults []*BuildResult
var totalCacheHits int
for _, group := range cacheGroups {
groupResults, err := pb.BuildParallel(ctx, group.Requests)
if err != nil {
return nil, err
}
allResults = append(allResults, groupResults...)
// Count cache hits
for _, result := range groupResults {
if result.CacheHit {
totalCacheHits++
}
}
}
// Map results back to original order
resultMap := make(map[string]*BuildResult)
for _, result := range allResults {
if result.Response != nil {
resultMap[result.Response.ImageName] = result
}
}
// Create final results array in original order
finalResults := make([]*BuildResult, len(requests))
for i, req := range requests {
imageName := fmt.Sprintf("%s:%s", req.ImageName, req.ImageTag)
if result, exists := resultMap[imageName]; exists {
finalResults[i] = result
} else {
finalResults[i] = &BuildResult{
Success: false,
Error: fmt.Errorf("build result not found for %s", imageName),
}
}
}
return &BatchBuildResult{
Results: finalResults,
TotalBuilds: len(requests),
Successful: pb.countSuccessful(finalResults),
Failed: pb.countFailed(finalResults),
CacheHits: totalCacheHits,
TotalDuration: time.Since(startTime),
AverageTime: time.Since(startTime) / time.Duration(len(requests)),
}, nil
}
// BatchBuildResult represents the result of a batch build
type BatchBuildResult struct {
Results []*BuildResult
TotalBuilds int
Successful int
Failed int
CacheHits int
TotalDuration time.Duration
AverageTime time.Duration
}
// CacheGroup represents a group of builds with similar cache potential
type CacheGroup struct {
Name string
Requests []*types.BuildRequest
Priority int
}
// groupByCachePotential groups builds by their cache optimization potential
func (pb *ParallelBuilder) groupByCachePotential(ctx context.Context, requests []*types.BuildRequest) []CacheGroup {
groups := make(map[string][]*types.BuildRequest)
for _, req := range requests {
cacheKey, err := pb.cacheManager.GenerateCacheKey(ctx, req)
if err != nil {
// Put in uncached group if we can't generate a key
groups["uncached"] = append(groups["uncached"], req)
continue
}
// Group by runtime and similar characteristics
groupKey := fmt.Sprintf("%s-%s", cacheKey.Runtime, pb.hashBuildArgs(cacheKey.BuildArgs))
groups[groupKey] = append(groups[groupKey], req)
}
// Convert to sorted groups with priorities
var sortedGroups []CacheGroup
for name, reqs := range groups {
priority := 0
if name == "uncached" {
priority = 999 // Lowest priority
} else if len(reqs) > 1 {
priority = 1 // High priority for groups with multiple builds
} else {
priority = 2 // Medium priority for single builds
}
sortedGroups = append(sortedGroups, CacheGroup{
Name: name,
Requests: reqs,
Priority: priority,
})
}
// Sort by priority (lower number = higher priority)
for i := 0; i < len(sortedGroups); i++ {
for j := i + 1; j < len(sortedGroups); j++ {
if sortedGroups[i].Priority > sortedGroups[j].Priority {
sortedGroups[i], sortedGroups[j] = sortedGroups[j], sortedGroups[i]
}
}
}
return sortedGroups
}
// hashBuildArgs creates a hash of build arguments for grouping
func (pb *ParallelBuilder) hashBuildArgs(args map[string]string) string {
if len(args) == 0 {
return "no-args"
}
// Simple hash for grouping - in production, use proper hashing
result := ""
for k, v := range args {
result += k + "=" + v + ";"
}
return result
}
func (pb *ParallelBuilder) countSuccessful(results []*BuildResult) int {
count := 0
for _, result := range results {
if result.Success {
count++
}
}
return count
}
func (pb *ParallelBuilder) countFailed(results []*BuildResult) int {
count := 0
for _, result := range results {
if !result.Success {
count++
}
}
return count
}
// Worker methods
func (w *Worker) start(wg *sync.WaitGroup) {
defer wg.Done()
for {
select {
case job := <-w.jobChan:
// Process the job
result := w.processJob(job)
job.Result <- result
case <-w.quit:
return
}
}
}
func (w *Worker) processJob(job *BuildJob) *BuildResult {
ctx := context.Background()
// Use the parallel builder's cache-aware build method
pb := &ParallelBuilder{
buildManager: w.buildManager,
cacheManager: w.cacheManager,
}
result, err := pb.BuildWithCache(ctx, job.Request)
if err != nil {
return &BuildResult{
Success: false,
Error: err,
BuildID: job.ID,
}
}
result.BuildID = job.ID
return result
}
+309
View File
@@ -0,0 +1,309 @@
package build
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"containr/internal/docker"
"containr/internal/types"
)
type RailpackBuilder struct {
workDir string
dockerClient *docker.Client
httpClient *http.Client
}
type RailpackConfig struct {
BuildCmd string `json:"buildCmd,omitempty"`
StartCmd string `json:"startCmd,omitempty"`
Env map[string]string `json:"env,omitempty"`
Root string `json:"root,omitempty"`
Planner string `json:"planner,omitempty"`
Variables map[string]string `json:"variables,omitempty"`
}
type RailpackBuildRequest struct {
Config RailpackConfig `json:"config"`
SourceDir string `json:"sourceDir"`
}
type RailpackBuildResponse struct {
Success bool `json:"success"`
Dockerfile string `json:"dockerfile"`
Error string `json:"error,omitempty"`
}
type RailpackPlan struct {
Planner string `json:"planner"`
BuildCmd string `json:"buildCmd"`
StartCmd string `json:"startCmd"`
Env map[string]string `json:"env"`
Assets map[string]interface{} `json:"assets"`
Metadata map[string]interface{} `json:"metadata"`
}
func NewRailpackBuilder(workDir string, dockerClient *docker.Client) *RailpackBuilder {
return &RailpackBuilder{
workDir: workDir,
dockerClient: dockerClient,
httpClient: &http.Client{
Timeout: 30 * time.Minute,
},
}
}
// DetectRailpack checks if the project can be built with Railpack
func (rb *RailpackBuilder) DetectRailpack(ctx context.Context, sourcePath string) error {
// Check for common web framework files that Railpack supports
detectionFiles := []string{
"package.json", // Node.js
"requirements.txt", // Python
"go.mod", // Go
"Cargo.toml", // Rust
"pom.xml", // Java/Maven
"build.gradle", // Java/Gradle
"Gemfile", // Ruby
"composer.json", // PHP
}
for _, file := range detectionFiles {
if _, err := os.Stat(filepath.Join(sourcePath, file)); err == nil {
return nil
}
}
return fmt.Errorf("no supported framework detected")
}
// GeneratePlan creates a build plan using Railpack
func (rb *RailpackBuilder) GeneratePlan(ctx context.Context, sourcePath string, config *RailpackConfig) (*RailpackPlan, error) {
// Call Railpack API to generate build plan
planURL := "https://api.railpack.app/v1/plan"
req := RailpackBuildRequest{
Config: *config,
SourceDir: sourcePath,
}
planResp, err := rb.makeRailpackRequest(ctx, "POST", planURL, req)
if err != nil {
return nil, fmt.Errorf("failed to generate Railpack plan: %w", err)
}
var plan RailpackPlan
if err := json.Unmarshal(planResp, &plan); err != nil {
return nil, fmt.Errorf("failed to parse Railpack plan: %w", err)
}
return &plan, nil
}
// Build generates a Dockerfile using Railpack and builds the image
func (rb *RailpackBuilder) Build(ctx context.Context, req *types.BuildRequest) (*types.BuildResponse, error) {
// Detect if Railpack can build this project
if err := rb.DetectRailpack(ctx, req.SourcePath); err != nil {
return nil, fmt.Errorf("Railpack cannot build this project: %w", err)
}
// Create Railpack config
config := RailpackConfig{
BuildCmd: req.BuildCommand,
StartCmd: req.StartCommand,
Env: req.Environment,
}
// Generate Dockerfile using Railpack
dockerfile, err := rb.generateDockerfile(ctx, req.SourcePath, &config)
if err != nil {
return nil, fmt.Errorf("failed to generate Dockerfile with Railpack: %w", err)
}
// Write Dockerfile to temporary location
dockerfilePath := filepath.Join(rb.workDir, "Dockerfile")
if err := os.WriteFile(dockerfilePath, []byte(dockerfile), 0644); err != nil {
return nil, fmt.Errorf("failed to write Dockerfile: %w", err)
}
// Build the Docker image
imageName := fmt.Sprintf("%s:%s", req.ImageName, req.ImageTag)
buildCtx := req.SourcePath
buildArgs := make(map[string]*string)
for k, v := range req.Environment {
val := v
buildArgs[k] = &val
}
// Create build context from directory
buildContext, err := rb.createBuildContext(buildCtx)
if err != nil {
return nil, fmt.Errorf("failed to create build context: %w", err)
}
defer os.Remove(buildContext.Name())
buildOptions := docker.BuildOptions{
Dockerfile: "Dockerfile",
Tags: []string{imageName},
BuildArgs: buildArgs,
Labels: req.Labels,
Remove: true,
}
_, err = rb.dockerClient.BuildImage(ctx, buildContext, buildOptions)
if err != nil {
return nil, fmt.Errorf("failed to build image: %w", err)
}
// Get image info
imageInfo, err := rb.dockerClient.GetImageInfo(ctx, imageName)
if err != nil {
return nil, fmt.Errorf("failed to get image info: %w", err)
}
// Push to registry if specified
if req.RegistryURL != "" {
err = rb.dockerClient.PushImage(ctx, imageName, req.RegistryURL)
if err != nil {
return nil, fmt.Errorf("failed to push image: %w", err)
}
imageName = fmt.Sprintf("%s/%s", req.RegistryURL, imageName)
}
return &types.BuildResponse{
ImageName: imageName,
ImageTag: req.ImageTag,
Size: imageInfo.Size,
Digest: imageInfo.Digest,
}, nil
}
// generateDockerfile calls Railpack API to generate optimized Dockerfile
func (rb *RailpackBuilder) generateDockerfile(ctx context.Context, sourcePath string, config *RailpackConfig) (string, error) {
// Use Railpack API to generate Dockerfile
generateURL := "https://api.railpack.app/v1/generate"
req := RailpackBuildRequest{
Config: *config,
SourceDir: sourcePath,
}
resp, err := rb.makeRailpackRequest(ctx, "POST", generateURL, req)
if err != nil {
return "", fmt.Errorf("failed to generate Dockerfile: %w", err)
}
var buildResp RailpackBuildResponse
if err := json.Unmarshal(resp, &buildResp); err != nil {
return "", fmt.Errorf("failed to parse Railpack response: %w", err)
}
if !buildResp.Success {
return "", fmt.Errorf("Railpack generation failed: %s", buildResp.Error)
}
return buildResp.Dockerfile, nil
}
// makeRailpackRequest makes HTTP requests to Railpack API
func (rb *RailpackBuilder) makeRailpackRequest(ctx context.Context, method, url string, body interface{}) ([]byte, error) {
var bodyReader io.Reader
if body != nil {
bodyBytes, err := json.Marshal(body)
if err != nil {
return nil, fmt.Errorf("failed to marshal request body: %w", err)
}
bodyReader = strings.NewReader(string(bodyBytes))
}
req, err := http.NewRequestWithContext(ctx, method, url, bodyReader)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", "containr/1.0")
resp, err := rb.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to make request: %w", err)
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response: %w", err)
}
if resp.StatusCode >= 400 {
return nil, fmt.Errorf("Railpack API error: %d - %s", resp.StatusCode, string(respBody))
}
return respBody, nil
}
// GetSupportedFrameworks returns a list of frameworks supported by Railpack
func (rb *RailpackBuilder) GetSupportedFrameworks() []string {
return []string{
"node.js",
"python",
"go",
"rust",
"java",
"ruby",
"php",
"static",
}
}
// createBuildContext creates a tar archive from the build context directory
func (rb *RailpackBuilder) createBuildContext(sourcePath string) (*os.File, error) {
// Create a temporary file for the tar archive
tmpFile, err := os.CreateTemp("", "build-context-*.tar")
if err != nil {
return nil, fmt.Errorf("failed to create temp file: %w", err)
}
// Create tar archive from source directory
cmd := fmt.Sprintf("cd %s && tar -cf %s .", sourcePath, tmpFile.Name())
// Use tar command to create the build context
if err := rb.runCommand(cmd); err != nil {
tmpFile.Close()
os.Remove(tmpFile.Name())
return nil, fmt.Errorf("failed to create build context: %w", err)
}
// Seek back to beginning of file
if _, err := tmpFile.Seek(0, 0); err != nil {
tmpFile.Close()
os.Remove(tmpFile.Name())
return nil, fmt.Errorf("failed to seek in temp file: %w", err)
}
return tmpFile, nil
}
// runCommand executes a shell command
func (rb *RailpackBuilder) runCommand(cmd string) error {
parts := strings.Fields(cmd)
if len(parts) == 0 {
return fmt.Errorf("empty command")
}
execCmd := exec.Command(parts[0], parts[1:]...)
execCmd.Stdin = os.Stdin
execCmd.Stdout = os.Stdout
execCmd.Stderr = os.Stderr
return execCmd.Run()
}
+107
View File
@@ -0,0 +1,107 @@
package build
import (
"context"
"os"
"path/filepath"
"testing"
)
func TestRailpackBuilder_DetectRailpack(t *testing.T) {
// Create a temporary directory for testing
tempDir, err := os.MkdirTemp("", "railpack-test-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tempDir)
// Test Node.js detection
nodeDir := filepath.Join(tempDir, "node-app")
if err := os.MkdirAll(nodeDir, 0755); err != nil {
t.Fatalf("Failed to create node dir: %v", err)
}
// Create package.json
packageJson := `{
"name": "test-app",
"version": "1.0.0",
"scripts": {
"start": "node index.js"
}
}`
if err := os.WriteFile(filepath.Join(nodeDir, "package.json"), []byte(packageJson), 0644); err != nil {
t.Fatalf("Failed to create package.json: %v", err)
}
// Create RailpackBuilder (we'll mock docker client for now)
builder := &RailpackBuilder{
workDir: tempDir,
}
// Test detection
err = builder.DetectRailpack(context.Background(), nodeDir)
if err != nil {
t.Errorf("Expected Railpack to detect Node.js app, got error: %v", err)
}
// Test non-supported directory
emptyDir := filepath.Join(tempDir, "empty")
if err := os.MkdirAll(emptyDir, 0755); err != nil {
t.Fatalf("Failed to create empty dir: %v", err)
}
err = builder.DetectRailpack(context.Background(), emptyDir)
if err == nil {
t.Error("Expected Railpack to fail detection on empty directory")
}
}
func TestBuildManager_DetectBuildType(t *testing.T) {
// Create a temporary directory for testing
tempDir, err := os.MkdirTemp("", "build-manager-test-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tempDir)
// Test Node.js app detection (should prefer Railpack)
nodeDir := filepath.Join(tempDir, "node-app")
if err := os.MkdirAll(nodeDir, 0755); err != nil {
t.Fatalf("Failed to create node dir: %v", err)
}
packageJson := `{"name": "test-app", "version": "1.0.0"}`
if err := os.WriteFile(filepath.Join(nodeDir, "package.json"), []byte(packageJson), 0644); err != nil {
t.Fatalf("Failed to create package.json: %v", err)
}
// Create build manager (we'll skip docker client for this test)
// Note: This would need a mock docker client in a real test
t.Skip("BuildManager test requires docker client mock")
}
func TestRailpackBuilder_GetSupportedFrameworks(t *testing.T) {
builder := &RailpackBuilder{}
frameworks := builder.GetSupportedFrameworks()
expected := []string{
"node.js",
"python",
"go",
"rust",
"java",
"ruby",
"php",
"static",
}
if len(frameworks) != len(expected) {
t.Errorf("Expected %d frameworks, got %d", len(expected), len(frameworks))
}
for i, framework := range expected {
if i >= len(frameworks) || frameworks[i] != framework {
t.Errorf("Expected framework %s at index %d, got %s", framework, i, frameworks[i])
}
}
}