From 2e7da73b1c0d62cb6c3b16c158c3b10e226ac196 Mon Sep 17 00:00:00 2001 From: Tomas Dvorak Date: Thu, 17 Sep 2026 20:32:22 +0200 Subject: [PATCH] fix: un-ignore internal/build package The build/ rule shadowed app/backend/internal/build; the !internal/build/ negation only matched the repo root. Package was never committed, breaking CI backend tests and the Docker image build. Authored By: TDvorak Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .gitignore | 2 +- app/backend/internal/build/README.md | 67 +++ app/backend/internal/build/cache.go | 489 +++++++++++++++++ app/backend/internal/build/custom.go | 520 ++++++++++++++++++ app/backend/internal/build/dockerfile.go | 578 ++++++++++++++++++++ app/backend/internal/build/manager.go | 240 ++++++++ app/backend/internal/build/nixpacks.go | 575 +++++++++++++++++++ app/backend/internal/build/parallel.go | 428 +++++++++++++++ app/backend/internal/build/railpack.go | 309 +++++++++++ app/backend/internal/build/railpack_test.go | 107 ++++ 10 files changed, 3314 insertions(+), 1 deletion(-) create mode 100644 app/backend/internal/build/README.md create mode 100644 app/backend/internal/build/cache.go create mode 100644 app/backend/internal/build/custom.go create mode 100644 app/backend/internal/build/dockerfile.go create mode 100644 app/backend/internal/build/manager.go create mode 100644 app/backend/internal/build/nixpacks.go create mode 100644 app/backend/internal/build/parallel.go create mode 100644 app/backend/internal/build/railpack.go create mode 100644 app/backend/internal/build/railpack_test.go diff --git a/.gitignore b/.gitignore index b30d24b..beabec8 100644 --- a/.gitignore +++ b/.gitignore @@ -13,7 +13,7 @@ build/ *.tsbuildinfo # Keep internal build system code -!internal/build/ +!**/internal/build/ # Environment variables .env diff --git a/app/backend/internal/build/README.md b/app/backend/internal/build/README.md new file mode 100644 index 0000000..349d239 --- /dev/null +++ b/app/backend/internal/build/README.md @@ -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 diff --git a/app/backend/internal/build/cache.go b/app/backend/internal/build/cache.go new file mode 100644 index 0000000..2f790dd --- /dev/null +++ b/app/backend/internal/build/cache.go @@ -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 +} diff --git a/app/backend/internal/build/custom.go b/app/backend/internal/build/custom.go new file mode 100644 index 0000000..d134102 --- /dev/null +++ b/app/backend/internal/build/custom.go @@ -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 +} diff --git a/app/backend/internal/build/dockerfile.go b/app/backend/internal/build/dockerfile.go new file mode 100644 index 0000000..73434dd --- /dev/null +++ b/app/backend/internal/build/dockerfile.go @@ -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 +} diff --git a/app/backend/internal/build/manager.go b/app/backend/internal/build/manager.go new file mode 100644 index 0000000..c46de1e --- /dev/null +++ b/app/backend/internal/build/manager.go @@ -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) +} diff --git a/app/backend/internal/build/nixpacks.go b/app/backend/internal/build/nixpacks.go new file mode 100644 index 0000000..32aa000 --- /dev/null +++ b/app/backend/internal/build/nixpacks.go @@ -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 +} diff --git a/app/backend/internal/build/parallel.go b/app/backend/internal/build/parallel.go new file mode 100644 index 0000000..3725348 --- /dev/null +++ b/app/backend/internal/build/parallel.go @@ -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 +} diff --git a/app/backend/internal/build/railpack.go b/app/backend/internal/build/railpack.go new file mode 100644 index 0000000..7da929b --- /dev/null +++ b/app/backend/internal/build/railpack.go @@ -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() +} diff --git a/app/backend/internal/build/railpack_test.go b/app/backend/internal/build/railpack_test.go new file mode 100644 index 0000000..9dbb2aa --- /dev/null +++ b/app/backend/internal/build/railpack_test.go @@ -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]) + } + } +}