Files
Containr/internal/build/railpack.go
T
Tomas Dvorak 355a97bab4 overhaul
2026-04-14 18:04:48 +02:00

310 lines
8.5 KiB
Go

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()
}