mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-06-03 20:12:58 +00:00
overhaul
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user