feat(php): add container build support (Docker + LinuxKit)

Add container commands to pkg/php:
- core php build - Docker/LinuxKit image builds
- core php serve --production - run production container
- core php shell - shell into running container

Features:
- Auto-generate FrankenPHP Dockerfile from project
- Detect PHP extensions from composer.json
- Multi-stage builds with frontend assets
- Laravel optimizations (config/route/view caching)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Snider 2026-01-28 19:24:11 +00:00
parent 96d394435a
commit 5568f86768
4 changed files with 1746 additions and 0 deletions

View file

@ -60,6 +60,9 @@ func AddPHPCommands(parent *clir.Cli) {
addPHPStopCommand(phpCmd)
addPHPStatusCommand(phpCmd)
addPHPSSLCommand(phpCmd)
addPHPBuildCommand(phpCmd)
addPHPServeCommand(phpCmd)
addPHPShellCommand(phpCmd)
}
func addPHPDevCommand(parent *clir.Command) {
@ -526,3 +529,288 @@ func containsService(services []php.DetectedService, target php.DetectedService)
}
return false
}
func addPHPBuildCommand(parent *clir.Command) {
var (
buildType string
imageName string
tag string
platform string
dockerfile string
outputPath string
format string
template string
noCache bool
)
buildCmd := parent.NewSubCommand("build", "Build Docker or LinuxKit image")
buildCmd.LongDescription("Build a production-ready container image for the PHP project.\n\n" +
"By default, builds a Docker image using FrankenPHP.\n" +
"Use --type linuxkit to build a LinuxKit VM image instead.\n\n" +
"Examples:\n" +
" core php build # Build Docker image\n" +
" core php build --name myapp --tag v1.0 # Build with custom name/tag\n" +
" core php build --type linuxkit # Build LinuxKit image\n" +
" core php build --type linuxkit --format iso # Build ISO image")
buildCmd.StringFlag("type", "Build type: docker (default) or linuxkit", &buildType)
buildCmd.StringFlag("name", "Image name (default: project directory name)", &imageName)
buildCmd.StringFlag("tag", "Image tag (default: latest)", &tag)
buildCmd.StringFlag("platform", "Target platform (e.g., linux/amd64, linux/arm64)", &platform)
buildCmd.StringFlag("dockerfile", "Path to custom Dockerfile", &dockerfile)
buildCmd.StringFlag("output", "Output path for LinuxKit image", &outputPath)
buildCmd.StringFlag("format", "LinuxKit output format: qcow2 (default), iso, raw, vmdk", &format)
buildCmd.StringFlag("template", "LinuxKit template name (default: server-php)", &template)
buildCmd.BoolFlag("no-cache", "Build without cache", &noCache)
buildCmd.Action(func() error {
cwd, err := os.Getwd()
if err != nil {
return fmt.Errorf("failed to get working directory: %w", err)
}
ctx := context.Background()
switch strings.ToLower(buildType) {
case "linuxkit":
return runPHPBuildLinuxKit(ctx, cwd, linuxKitBuildOptions{
OutputPath: outputPath,
Format: format,
Template: template,
})
default:
return runPHPBuildDocker(ctx, cwd, dockerBuildOptions{
ImageName: imageName,
Tag: tag,
Platform: platform,
Dockerfile: dockerfile,
NoCache: noCache,
})
}
})
}
type dockerBuildOptions struct {
ImageName string
Tag string
Platform string
Dockerfile string
NoCache bool
}
type linuxKitBuildOptions struct {
OutputPath string
Format string
Template string
}
func runPHPBuildDocker(ctx context.Context, projectDir string, opts dockerBuildOptions) error {
if !php.IsPHPProject(projectDir) {
return fmt.Errorf("not a PHP project (missing composer.json)")
}
fmt.Printf("%s Building Docker image...\n\n", dimStyle.Render("PHP:"))
// Show detected configuration
config, err := php.DetectDockerfileConfig(projectDir)
if err != nil {
return fmt.Errorf("failed to detect project configuration: %w", err)
}
fmt.Printf("%s %s\n", dimStyle.Render("PHP Version:"), config.PHPVersion)
fmt.Printf("%s %v\n", dimStyle.Render("Laravel:"), config.IsLaravel)
fmt.Printf("%s %v\n", dimStyle.Render("Octane:"), config.HasOctane)
fmt.Printf("%s %v\n", dimStyle.Render("Frontend:"), config.HasAssets)
if len(config.PHPExtensions) > 0 {
fmt.Printf("%s %s\n", dimStyle.Render("Extensions:"), strings.Join(config.PHPExtensions, ", "))
}
fmt.Println()
// Build options
buildOpts := php.DockerBuildOptions{
ProjectDir: projectDir,
ImageName: opts.ImageName,
Tag: opts.Tag,
Platform: opts.Platform,
Dockerfile: opts.Dockerfile,
NoBuildCache: opts.NoCache,
Output: os.Stdout,
}
if buildOpts.ImageName == "" {
buildOpts.ImageName = php.GetLaravelAppName(projectDir)
if buildOpts.ImageName == "" {
buildOpts.ImageName = "php-app"
}
// Sanitize for Docker
buildOpts.ImageName = strings.ToLower(strings.ReplaceAll(buildOpts.ImageName, " ", "-"))
}
if buildOpts.Tag == "" {
buildOpts.Tag = "latest"
}
fmt.Printf("%s %s:%s\n", dimStyle.Render("Image:"), buildOpts.ImageName, buildOpts.Tag)
if opts.Platform != "" {
fmt.Printf("%s %s\n", dimStyle.Render("Platform:"), opts.Platform)
}
fmt.Println()
if err := php.BuildDocker(ctx, buildOpts); err != nil {
return fmt.Errorf("build failed: %w", err)
}
fmt.Printf("\n%s Docker image built successfully\n", successStyle.Render("Done:"))
fmt.Printf("%s docker run -p 80:80 -p 443:443 %s:%s\n",
dimStyle.Render("Run with:"),
buildOpts.ImageName, buildOpts.Tag)
return nil
}
func runPHPBuildLinuxKit(ctx context.Context, projectDir string, opts linuxKitBuildOptions) error {
if !php.IsPHPProject(projectDir) {
return fmt.Errorf("not a PHP project (missing composer.json)")
}
fmt.Printf("%s Building LinuxKit image...\n\n", dimStyle.Render("PHP:"))
buildOpts := php.LinuxKitBuildOptions{
ProjectDir: projectDir,
OutputPath: opts.OutputPath,
Format: opts.Format,
Template: opts.Template,
Output: os.Stdout,
}
if buildOpts.Format == "" {
buildOpts.Format = "qcow2"
}
if buildOpts.Template == "" {
buildOpts.Template = "server-php"
}
fmt.Printf("%s %s\n", dimStyle.Render("Template:"), buildOpts.Template)
fmt.Printf("%s %s\n", dimStyle.Render("Format:"), buildOpts.Format)
fmt.Println()
if err := php.BuildLinuxKit(ctx, buildOpts); err != nil {
return fmt.Errorf("build failed: %w", err)
}
fmt.Printf("\n%s LinuxKit image built successfully\n", successStyle.Render("Done:"))
return nil
}
func addPHPServeCommand(parent *clir.Command) {
var (
imageName string
tag string
containerName string
port int
httpsPort int
detach bool
envFile string
)
serveCmd := parent.NewSubCommand("serve", "Run production container")
serveCmd.LongDescription("Run a production PHP container.\n\n" +
"This starts the built Docker image in production mode.\n\n" +
"Examples:\n" +
" core php serve --name myapp # Run container\n" +
" core php serve --name myapp -d # Run detached\n" +
" core php serve --name myapp --port 8080 # Custom port")
serveCmd.StringFlag("name", "Docker image name (required)", &imageName)
serveCmd.StringFlag("tag", "Image tag (default: latest)", &tag)
serveCmd.StringFlag("container", "Container name", &containerName)
serveCmd.IntFlag("port", "HTTP port (default: 80)", &port)
serveCmd.IntFlag("https-port", "HTTPS port (default: 443)", &httpsPort)
serveCmd.BoolFlag("d", "Run in detached mode", &detach)
serveCmd.StringFlag("env-file", "Path to environment file", &envFile)
serveCmd.Action(func() error {
if imageName == "" {
// Try to detect from current directory
cwd, err := os.Getwd()
if err == nil {
imageName = php.GetLaravelAppName(cwd)
if imageName != "" {
imageName = strings.ToLower(strings.ReplaceAll(imageName, " ", "-"))
}
}
if imageName == "" {
return fmt.Errorf("--name is required: specify the Docker image name")
}
}
ctx := context.Background()
opts := php.ServeOptions{
ImageName: imageName,
Tag: tag,
ContainerName: containerName,
Port: port,
HTTPSPort: httpsPort,
Detach: detach,
EnvFile: envFile,
Output: os.Stdout,
}
fmt.Printf("%s Running production container...\n\n", dimStyle.Render("PHP:"))
fmt.Printf("%s %s:%s\n", dimStyle.Render("Image:"), imageName, func() string {
if tag == "" {
return "latest"
}
return tag
}())
effectivePort := port
if effectivePort == 0 {
effectivePort = 80
}
effectiveHTTPSPort := httpsPort
if effectiveHTTPSPort == 0 {
effectiveHTTPSPort = 443
}
fmt.Printf("%s http://localhost:%d, https://localhost:%d\n",
dimStyle.Render("Ports:"), effectivePort, effectiveHTTPSPort)
fmt.Println()
if err := php.ServeProduction(ctx, opts); err != nil {
return fmt.Errorf("failed to start container: %w", err)
}
if !detach {
fmt.Printf("\n%s Container stopped\n", dimStyle.Render("PHP:"))
}
return nil
})
}
func addPHPShellCommand(parent *clir.Command) {
shellCmd := parent.NewSubCommand("shell", "Open shell in running container")
shellCmd.LongDescription("Open an interactive shell in a running PHP container.\n\n" +
"Examples:\n" +
" core php shell abc123 # Shell into container by ID\n" +
" core php shell myapp # Shell into container by name")
shellCmd.Action(func() error {
args := shellCmd.OtherArgs()
if len(args) == 0 {
return fmt.Errorf("container ID or name is required")
}
ctx := context.Background()
fmt.Printf("%s Opening shell in container %s...\n", dimStyle.Render("PHP:"), args[0])
if err := php.Shell(ctx, args[0]); err != nil {
return fmt.Errorf("failed to open shell: %w", err)
}
return nil
})
}

448
pkg/php/container.go Normal file
View file

@ -0,0 +1,448 @@
package php
import (
"context"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
)
// DockerBuildOptions configures Docker image building for PHP projects.
type DockerBuildOptions struct {
// ProjectDir is the path to the PHP/Laravel project.
ProjectDir string
// ImageName is the name for the Docker image.
ImageName string
// Tag is the image tag (default: "latest").
Tag string
// Platform specifies the target platform (e.g., "linux/amd64", "linux/arm64").
Platform string
// Dockerfile is the path to a custom Dockerfile.
// If empty, one will be auto-generated for FrankenPHP.
Dockerfile string
// NoBuildCache disables Docker build cache.
NoBuildCache bool
// BuildArgs are additional build arguments.
BuildArgs map[string]string
// Output is the writer for build output (default: os.Stdout).
Output io.Writer
}
// LinuxKitBuildOptions configures LinuxKit image building for PHP projects.
type LinuxKitBuildOptions struct {
// ProjectDir is the path to the PHP/Laravel project.
ProjectDir string
// OutputPath is the path for the output image.
OutputPath string
// Format is the output format: "iso", "qcow2", "raw", "vmdk".
Format string
// Template is the LinuxKit template name (default: "server-php").
Template string
// Variables are template variables to apply.
Variables map[string]string
// Output is the writer for build output (default: os.Stdout).
Output io.Writer
}
// ServeOptions configures running a production PHP container.
type ServeOptions struct {
// ImageName is the Docker image to run.
ImageName string
// Tag is the image tag (default: "latest").
Tag string
// ContainerName is the name for the container.
ContainerName string
// Port is the host port to bind (default: 80).
Port int
// HTTPSPort is the host HTTPS port to bind (default: 443).
HTTPSPort int
// Detach runs the container in detached mode.
Detach bool
// EnvFile is the path to an environment file.
EnvFile string
// Volumes maps host paths to container paths.
Volumes map[string]string
// Output is the writer for output (default: os.Stdout).
Output io.Writer
}
// BuildDocker builds a Docker image for the PHP project.
func BuildDocker(ctx context.Context, opts DockerBuildOptions) error {
if opts.ProjectDir == "" {
cwd, err := os.Getwd()
if err != nil {
return fmt.Errorf("failed to get working directory: %w", err)
}
opts.ProjectDir = cwd
}
// Validate project directory
if !IsPHPProject(opts.ProjectDir) {
return fmt.Errorf("not a PHP project: %s (missing composer.json)", opts.ProjectDir)
}
// Set defaults
if opts.ImageName == "" {
opts.ImageName = filepath.Base(opts.ProjectDir)
}
if opts.Tag == "" {
opts.Tag = "latest"
}
if opts.Output == nil {
opts.Output = os.Stdout
}
// Determine Dockerfile path
dockerfilePath := opts.Dockerfile
var tempDockerfile string
if dockerfilePath == "" {
// Generate Dockerfile
content, err := GenerateDockerfile(opts.ProjectDir)
if err != nil {
return fmt.Errorf("failed to generate Dockerfile: %w", err)
}
// Write to temporary file
tempDockerfile = filepath.Join(opts.ProjectDir, "Dockerfile.core-generated")
if err := os.WriteFile(tempDockerfile, []byte(content), 0644); err != nil {
return fmt.Errorf("failed to write Dockerfile: %w", err)
}
defer os.Remove(tempDockerfile)
dockerfilePath = tempDockerfile
}
// Build Docker image
imageRef := fmt.Sprintf("%s:%s", opts.ImageName, opts.Tag)
args := []string{"build", "-t", imageRef, "-f", dockerfilePath}
if opts.Platform != "" {
args = append(args, "--platform", opts.Platform)
}
if opts.NoBuildCache {
args = append(args, "--no-cache")
}
for key, value := range opts.BuildArgs {
args = append(args, "--build-arg", fmt.Sprintf("%s=%s", key, value))
}
args = append(args, opts.ProjectDir)
cmd := exec.CommandContext(ctx, "docker", args...)
cmd.Dir = opts.ProjectDir
cmd.Stdout = opts.Output
cmd.Stderr = opts.Output
if err := cmd.Run(); err != nil {
return fmt.Errorf("docker build failed: %w", err)
}
return nil
}
// BuildLinuxKit builds a LinuxKit image for the PHP project.
func BuildLinuxKit(ctx context.Context, opts LinuxKitBuildOptions) error {
if opts.ProjectDir == "" {
cwd, err := os.Getwd()
if err != nil {
return fmt.Errorf("failed to get working directory: %w", err)
}
opts.ProjectDir = cwd
}
// Validate project directory
if !IsPHPProject(opts.ProjectDir) {
return fmt.Errorf("not a PHP project: %s (missing composer.json)", opts.ProjectDir)
}
// Set defaults
if opts.Template == "" {
opts.Template = "server-php"
}
if opts.Format == "" {
opts.Format = "qcow2"
}
if opts.OutputPath == "" {
opts.OutputPath = filepath.Join(opts.ProjectDir, "dist", filepath.Base(opts.ProjectDir))
}
if opts.Output == nil {
opts.Output = os.Stdout
}
// Ensure output directory exists
outputDir := filepath.Dir(opts.OutputPath)
if err := os.MkdirAll(outputDir, 0755); err != nil {
return fmt.Errorf("failed to create output directory: %w", err)
}
// Find linuxkit binary
linuxkitPath, err := lookupLinuxKit()
if err != nil {
return err
}
// Get template content
templateContent, err := getLinuxKitTemplate(opts.Template)
if err != nil {
return fmt.Errorf("failed to get template: %w", err)
}
// Apply variables
if opts.Variables == nil {
opts.Variables = make(map[string]string)
}
// Add project-specific variables
opts.Variables["PROJECT_DIR"] = opts.ProjectDir
opts.Variables["PROJECT_NAME"] = filepath.Base(opts.ProjectDir)
content, err := applyTemplateVariables(templateContent, opts.Variables)
if err != nil {
return fmt.Errorf("failed to apply template variables: %w", err)
}
// Write template to temp file
tempYAML := filepath.Join(opts.ProjectDir, ".core-linuxkit.yml")
if err := os.WriteFile(tempYAML, []byte(content), 0644); err != nil {
return fmt.Errorf("failed to write template: %w", err)
}
defer os.Remove(tempYAML)
// Build LinuxKit image
args := []string{
"build",
"-format", opts.Format,
"-name", opts.OutputPath,
tempYAML,
}
cmd := exec.CommandContext(ctx, linuxkitPath, args...)
cmd.Dir = opts.ProjectDir
cmd.Stdout = opts.Output
cmd.Stderr = opts.Output
if err := cmd.Run(); err != nil {
return fmt.Errorf("linuxkit build failed: %w", err)
}
return nil
}
// ServeProduction runs a production PHP container.
func ServeProduction(ctx context.Context, opts ServeOptions) error {
if opts.ImageName == "" {
return fmt.Errorf("image name is required")
}
// Set defaults
if opts.Tag == "" {
opts.Tag = "latest"
}
if opts.Port == 0 {
opts.Port = 80
}
if opts.HTTPSPort == 0 {
opts.HTTPSPort = 443
}
if opts.Output == nil {
opts.Output = os.Stdout
}
imageRef := fmt.Sprintf("%s:%s", opts.ImageName, opts.Tag)
args := []string{"run"}
if opts.Detach {
args = append(args, "-d")
} else {
args = append(args, "--rm")
}
if opts.ContainerName != "" {
args = append(args, "--name", opts.ContainerName)
}
// Port mappings
args = append(args, "-p", fmt.Sprintf("%d:80", opts.Port))
args = append(args, "-p", fmt.Sprintf("%d:443", opts.HTTPSPort))
// Environment file
if opts.EnvFile != "" {
args = append(args, "--env-file", opts.EnvFile)
}
// Volume mounts
for hostPath, containerPath := range opts.Volumes {
args = append(args, "-v", fmt.Sprintf("%s:%s", hostPath, containerPath))
}
args = append(args, imageRef)
cmd := exec.CommandContext(ctx, "docker", args...)
cmd.Stdout = opts.Output
cmd.Stderr = opts.Output
if opts.Detach {
output, err := cmd.Output()
if err != nil {
return fmt.Errorf("failed to start container: %w", err)
}
containerID := strings.TrimSpace(string(output))
fmt.Fprintf(opts.Output, "Container started: %s\n", containerID[:12])
return nil
}
return cmd.Run()
}
// Shell opens a shell in a running container.
func Shell(ctx context.Context, containerID string) error {
if containerID == "" {
return fmt.Errorf("container ID is required")
}
// Resolve partial container ID
fullID, err := resolveDockerContainerID(ctx, containerID)
if err != nil {
return err
}
cmd := exec.CommandContext(ctx, "docker", "exec", "-it", fullID, "/bin/sh")
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
}
// IsPHPProject checks if the given directory is a PHP project.
func IsPHPProject(dir string) bool {
composerPath := filepath.Join(dir, "composer.json")
_, err := os.Stat(composerPath)
return err == nil
}
// lookupLinuxKit finds the linuxkit binary.
func lookupLinuxKit() (string, error) {
// Check PATH first
if path, err := exec.LookPath("linuxkit"); err == nil {
return path, nil
}
// Check common locations
paths := []string{
"/usr/local/bin/linuxkit",
"/opt/homebrew/bin/linuxkit",
}
for _, p := range paths {
if _, err := os.Stat(p); err == nil {
return p, nil
}
}
return "", fmt.Errorf("linuxkit not found. Install with: brew install linuxkit (macOS) or see https://github.com/linuxkit/linuxkit")
}
// getLinuxKitTemplate retrieves a LinuxKit template by name.
func getLinuxKitTemplate(name string) (string, error) {
// Default server-php template for PHP projects
if name == "server-php" {
return defaultServerPHPTemplate, nil
}
// Try to load from container package templates
// This would integrate with github.com/host-uk/core/pkg/container
return "", fmt.Errorf("template not found: %s", name)
}
// applyTemplateVariables applies variable substitution to template content.
func applyTemplateVariables(content string, vars map[string]string) (string, error) {
result := content
for key, value := range vars {
placeholder := "${" + key + "}"
result = strings.ReplaceAll(result, placeholder, value)
}
return result, nil
}
// resolveDockerContainerID resolves a partial container ID to a full ID.
func resolveDockerContainerID(ctx context.Context, partialID string) (string, error) {
cmd := exec.CommandContext(ctx, "docker", "ps", "-a", "--no-trunc", "--format", "{{.ID}}")
output, err := cmd.Output()
if err != nil {
return "", fmt.Errorf("failed to list containers: %w", err)
}
lines := strings.Split(strings.TrimSpace(string(output)), "\n")
var matches []string
for _, line := range lines {
if strings.HasPrefix(line, partialID) {
matches = append(matches, line)
}
}
switch len(matches) {
case 0:
return "", fmt.Errorf("no container found matching: %s", partialID)
case 1:
return matches[0], nil
default:
return "", fmt.Errorf("multiple containers match '%s', be more specific", partialID)
}
}
// defaultServerPHPTemplate is the default LinuxKit template for PHP servers.
const defaultServerPHPTemplate = `# LinuxKit configuration for PHP/FrankenPHP server
kernel:
image: linuxkit/kernel:6.6.13
cmdline: "console=tty0 console=ttyS0"
init:
- linuxkit/init:v1.0.1
- linuxkit/runc:v1.0.1
- linuxkit/containerd:v1.0.1
onboot:
- name: sysctl
image: linuxkit/sysctl:v1.0.1
- name: dhcpcd
image: linuxkit/dhcpcd:v1.0.1
command: ["/sbin/dhcpcd", "--nobackground", "-f", "/dhcpcd.conf"]
services:
- name: getty
image: linuxkit/getty:v1.0.1
env:
- INSECURE=true
- name: sshd
image: linuxkit/sshd:v1.0.1
files:
- path: etc/ssh/authorized_keys
contents: |
${SSH_KEY:-}
`

396
pkg/php/dockerfile.go Normal file
View file

@ -0,0 +1,396 @@
package php
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
)
// DockerfileConfig holds configuration for generating a Dockerfile.
type DockerfileConfig struct {
// PHPVersion is the PHP version to use (default: "8.3").
PHPVersion string
// BaseImage is the base Docker image (default: "dunglas/frankenphp").
BaseImage string
// PHPExtensions is the list of PHP extensions to install.
PHPExtensions []string
// HasAssets indicates if the project has frontend assets to build.
HasAssets bool
// PackageManager is the Node.js package manager (npm, pnpm, yarn, bun).
PackageManager string
// IsLaravel indicates if this is a Laravel project.
IsLaravel bool
// HasOctane indicates if Laravel Octane is installed.
HasOctane bool
// UseAlpine uses the Alpine-based image (smaller).
UseAlpine bool
}
// GenerateDockerfile generates a Dockerfile for a PHP/Laravel project.
// It auto-detects dependencies from composer.json and project structure.
func GenerateDockerfile(dir string) (string, error) {
config, err := DetectDockerfileConfig(dir)
if err != nil {
return "", err
}
return GenerateDockerfileFromConfig(config), nil
}
// DetectDockerfileConfig detects configuration from project files.
func DetectDockerfileConfig(dir string) (*DockerfileConfig, error) {
config := &DockerfileConfig{
PHPVersion: "8.3",
BaseImage: "dunglas/frankenphp",
UseAlpine: true,
}
// Read composer.json
composerPath := filepath.Join(dir, "composer.json")
composerData, err := os.ReadFile(composerPath)
if err != nil {
return nil, fmt.Errorf("failed to read composer.json: %w", err)
}
var composer ComposerJSON
if err := json.Unmarshal(composerData, &composer); err != nil {
return nil, fmt.Errorf("failed to parse composer.json: %w", err)
}
// Detect PHP version from composer.json
if phpVersion, ok := composer.Require["php"]; ok {
config.PHPVersion = extractPHPVersion(phpVersion)
}
// Detect if Laravel
if _, ok := composer.Require["laravel/framework"]; ok {
config.IsLaravel = true
}
// Detect if Octane
if _, ok := composer.Require["laravel/octane"]; ok {
config.HasOctane = true
}
// Detect required PHP extensions
config.PHPExtensions = detectPHPExtensions(composer)
// Detect frontend assets
config.HasAssets = hasNodeAssets(dir)
if config.HasAssets {
config.PackageManager = DetectPackageManager(dir)
}
return config, nil
}
// GenerateDockerfileFromConfig generates a Dockerfile from the given configuration.
func GenerateDockerfileFromConfig(config *DockerfileConfig) string {
var sb strings.Builder
// Base image
baseTag := fmt.Sprintf("latest-php%s", config.PHPVersion)
if config.UseAlpine {
baseTag += "-alpine"
}
sb.WriteString(fmt.Sprintf("# Auto-generated Dockerfile for FrankenPHP\n"))
sb.WriteString(fmt.Sprintf("# Generated by Core Framework\n\n"))
// Multi-stage build for smaller images
if config.HasAssets {
// Frontend build stage
sb.WriteString("# Stage 1: Build frontend assets\n")
sb.WriteString("FROM node:20-alpine AS frontend\n\n")
sb.WriteString("WORKDIR /app\n\n")
// Copy package files based on package manager
switch config.PackageManager {
case "pnpm":
sb.WriteString("RUN corepack enable && corepack prepare pnpm@latest --activate\n\n")
sb.WriteString("COPY package.json pnpm-lock.yaml ./\n")
sb.WriteString("RUN pnpm install --frozen-lockfile\n\n")
case "yarn":
sb.WriteString("COPY package.json yarn.lock ./\n")
sb.WriteString("RUN yarn install --frozen-lockfile\n\n")
case "bun":
sb.WriteString("RUN npm install -g bun\n\n")
sb.WriteString("COPY package.json bun.lockb ./\n")
sb.WriteString("RUN bun install --frozen-lockfile\n\n")
default: // npm
sb.WriteString("COPY package.json package-lock.json ./\n")
sb.WriteString("RUN npm ci\n\n")
}
sb.WriteString("COPY . .\n\n")
// Build command
switch config.PackageManager {
case "pnpm":
sb.WriteString("RUN pnpm run build\n\n")
case "yarn":
sb.WriteString("RUN yarn build\n\n")
case "bun":
sb.WriteString("RUN bun run build\n\n")
default:
sb.WriteString("RUN npm run build\n\n")
}
}
// PHP build stage
stageNum := 2
if config.HasAssets {
sb.WriteString(fmt.Sprintf("# Stage %d: PHP application\n", stageNum))
}
sb.WriteString(fmt.Sprintf("FROM %s:%s AS app\n\n", config.BaseImage, baseTag))
sb.WriteString("WORKDIR /app\n\n")
// Install PHP extensions if needed
if len(config.PHPExtensions) > 0 {
sb.WriteString("# Install PHP extensions\n")
sb.WriteString(fmt.Sprintf("RUN install-php-extensions %s\n\n", strings.Join(config.PHPExtensions, " ")))
}
// Copy composer files first for better caching
sb.WriteString("# Copy composer files\n")
sb.WriteString("COPY composer.json composer.lock ./\n\n")
// Install composer dependencies
sb.WriteString("# Install PHP dependencies\n")
sb.WriteString("RUN composer install --no-dev --no-scripts --optimize-autoloader --no-interaction\n\n")
// Copy application code
sb.WriteString("# Copy application code\n")
sb.WriteString("COPY . .\n\n")
// Run post-install scripts
sb.WriteString("# Run composer scripts\n")
sb.WriteString("RUN composer dump-autoload --optimize\n\n")
// Copy frontend assets if built
if config.HasAssets {
sb.WriteString("# Copy built frontend assets\n")
sb.WriteString("COPY --from=frontend /app/public/build public/build\n\n")
}
// Laravel-specific setup
if config.IsLaravel {
sb.WriteString("# Laravel setup\n")
sb.WriteString("RUN php artisan config:cache \\\n")
sb.WriteString(" && php artisan route:cache \\\n")
sb.WriteString(" && php artisan view:cache\n\n")
// Set permissions
sb.WriteString("# Set permissions for Laravel\n")
sb.WriteString("RUN chown -R www-data:www-data storage bootstrap/cache \\\n")
sb.WriteString(" && chmod -R 775 storage bootstrap/cache\n\n")
}
// Expose ports
sb.WriteString("# Expose ports\n")
sb.WriteString("EXPOSE 80 443\n\n")
// Health check
sb.WriteString("# Health check\n")
sb.WriteString("HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \\\n")
sb.WriteString(" CMD curl -f http://localhost/up || exit 1\n\n")
// Start command
sb.WriteString("# Start FrankenPHP\n")
if config.HasOctane {
sb.WriteString("CMD [\"php\", \"artisan\", \"octane:start\", \"--server=frankenphp\", \"--host=0.0.0.0\", \"--port=80\"]\n")
} else {
sb.WriteString("CMD [\"frankenphp\", \"run\", \"--config\", \"/etc/caddy/Caddyfile\"]\n")
}
return sb.String()
}
// ComposerJSON represents the structure of composer.json.
type ComposerJSON struct {
Name string `json:"name"`
Require map[string]string `json:"require"`
RequireDev map[string]string `json:"require-dev"`
}
// detectPHPExtensions detects required PHP extensions from composer.json.
func detectPHPExtensions(composer ComposerJSON) []string {
extensionMap := make(map[string]bool)
// Check for common packages and their required extensions
packageExtensions := map[string][]string{
// Database
"doctrine/dbal": {"pdo_mysql", "pdo_pgsql"},
"illuminate/database": {"pdo_mysql"},
"laravel/framework": {"pdo_mysql", "bcmath", "ctype", "fileinfo", "mbstring", "openssl", "tokenizer", "xml"},
"mongodb/mongodb": {"mongodb"},
"predis/predis": {"redis"},
"phpredis/phpredis": {"redis"},
"laravel/horizon": {"redis", "pcntl"},
"aws/aws-sdk-php": {"curl"},
"intervention/image": {"gd"},
"intervention/image-laravel": {"gd"},
"spatie/image": {"gd"},
"league/flysystem-aws-s3-v3": {"curl"},
"guzzlehttp/guzzle": {"curl"},
"nelmio/cors-bundle": {},
// Queues
"laravel/reverb": {"pcntl"},
"php-amqplib/php-amqplib": {"sockets"},
// Misc
"moneyphp/money": {"bcmath", "intl"},
"symfony/intl": {"intl"},
"nesbot/carbon": {"intl"},
"spatie/laravel-medialibrary": {"exif", "gd"},
}
// Check all require and require-dev dependencies
allDeps := make(map[string]string)
for pkg, ver := range composer.Require {
allDeps[pkg] = ver
}
for pkg, ver := range composer.RequireDev {
allDeps[pkg] = ver
}
// Find required extensions
for pkg := range allDeps {
if exts, ok := packageExtensions[pkg]; ok {
for _, ext := range exts {
extensionMap[ext] = true
}
}
// Check for direct ext- requirements
if strings.HasPrefix(pkg, "ext-") {
ext := strings.TrimPrefix(pkg, "ext-")
// Skip extensions that are built into PHP
builtIn := map[string]bool{
"json": true, "ctype": true, "iconv": true,
"session": true, "simplexml": true, "pdo": true,
"xml": true, "tokenizer": true,
}
if !builtIn[ext] {
extensionMap[ext] = true
}
}
}
// Convert to sorted slice
extensions := make([]string, 0, len(extensionMap))
for ext := range extensionMap {
extensions = append(extensions, ext)
}
sort.Strings(extensions)
return extensions
}
// extractPHPVersion extracts a clean PHP version from a composer constraint.
func extractPHPVersion(constraint string) string {
// Handle common formats: ^8.2, >=8.2, 8.2.*, ~8.2
constraint = strings.TrimLeft(constraint, "^>=~")
constraint = strings.TrimRight(constraint, ".*")
// Extract major.minor
parts := strings.Split(constraint, ".")
if len(parts) >= 2 {
return parts[0] + "." + parts[1]
}
if len(parts) == 1 {
return parts[0] + ".0"
}
return "8.3" // default
}
// hasNodeAssets checks if the project has frontend assets.
func hasNodeAssets(dir string) bool {
packageJSON := filepath.Join(dir, "package.json")
if _, err := os.Stat(packageJSON); err != nil {
return false
}
// Check for build script in package.json
data, err := os.ReadFile(packageJSON)
if err != nil {
return false
}
var pkg struct {
Scripts map[string]string `json:"scripts"`
}
if err := json.Unmarshal(data, &pkg); err != nil {
return false
}
// Check if there's a build script
_, hasBuild := pkg.Scripts["build"]
return hasBuild
}
// GenerateDockerignore generates a .dockerignore file content for PHP projects.
func GenerateDockerignore(dir string) string {
var sb strings.Builder
sb.WriteString("# Git\n")
sb.WriteString(".git\n")
sb.WriteString(".gitignore\n")
sb.WriteString(".gitattributes\n\n")
sb.WriteString("# Node\n")
sb.WriteString("node_modules\n\n")
sb.WriteString("# Development\n")
sb.WriteString(".env\n")
sb.WriteString(".env.local\n")
sb.WriteString(".env.*.local\n")
sb.WriteString("*.log\n")
sb.WriteString(".phpunit.result.cache\n")
sb.WriteString("phpunit.xml\n")
sb.WriteString(".php-cs-fixer.cache\n")
sb.WriteString("phpstan.neon\n\n")
sb.WriteString("# IDE\n")
sb.WriteString(".idea\n")
sb.WriteString(".vscode\n")
sb.WriteString("*.swp\n")
sb.WriteString("*.swo\n\n")
sb.WriteString("# Laravel specific\n")
sb.WriteString("storage/app/*\n")
sb.WriteString("storage/logs/*\n")
sb.WriteString("storage/framework/cache/*\n")
sb.WriteString("storage/framework/sessions/*\n")
sb.WriteString("storage/framework/views/*\n")
sb.WriteString("bootstrap/cache/*\n\n")
sb.WriteString("# Build artifacts\n")
sb.WriteString("public/hot\n")
sb.WriteString("public/storage\n")
sb.WriteString("vendor\n\n")
sb.WriteString("# Docker\n")
sb.WriteString("Dockerfile*\n")
sb.WriteString("docker-compose*.yml\n")
sb.WriteString(".dockerignore\n\n")
sb.WriteString("# Documentation\n")
sb.WriteString("README.md\n")
sb.WriteString("CHANGELOG.md\n")
sb.WriteString("docs\n")
return sb.String()
}

614
pkg/php/dockerfile_test.go Normal file
View file

@ -0,0 +1,614 @@
package php
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestGenerateDockerfile_Good(t *testing.T) {
t.Run("basic Laravel project", func(t *testing.T) {
dir := t.TempDir()
// Create composer.json
composerJSON := `{
"name": "test/laravel-project",
"require": {
"php": "^8.2",
"laravel/framework": "^11.0"
}
}`
err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte(composerJSON), 0644)
require.NoError(t, err)
// Create composer.lock
err = os.WriteFile(filepath.Join(dir, "composer.lock"), []byte("{}"), 0644)
require.NoError(t, err)
content, err := GenerateDockerfile(dir)
require.NoError(t, err)
// Check content
assert.Contains(t, content, "FROM dunglas/frankenphp")
assert.Contains(t, content, "php8.2")
assert.Contains(t, content, "COPY composer.json composer.lock")
assert.Contains(t, content, "composer install")
assert.Contains(t, content, "EXPOSE 80 443")
})
t.Run("Laravel project with Octane", func(t *testing.T) {
dir := t.TempDir()
composerJSON := `{
"name": "test/laravel-octane",
"require": {
"php": "^8.3",
"laravel/framework": "^11.0",
"laravel/octane": "^2.0"
}
}`
err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte(composerJSON), 0644)
require.NoError(t, err)
err = os.WriteFile(filepath.Join(dir, "composer.lock"), []byte("{}"), 0644)
require.NoError(t, err)
content, err := GenerateDockerfile(dir)
require.NoError(t, err)
assert.Contains(t, content, "php8.3")
assert.Contains(t, content, "octane:start")
})
t.Run("project with frontend assets", func(t *testing.T) {
dir := t.TempDir()
composerJSON := `{
"name": "test/laravel-vite",
"require": {
"php": "^8.3",
"laravel/framework": "^11.0"
}
}`
err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte(composerJSON), 0644)
require.NoError(t, err)
err = os.WriteFile(filepath.Join(dir, "composer.lock"), []byte("{}"), 0644)
require.NoError(t, err)
packageJSON := `{
"name": "test-app",
"scripts": {
"dev": "vite",
"build": "vite build"
}
}`
err = os.WriteFile(filepath.Join(dir, "package.json"), []byte(packageJSON), 0644)
require.NoError(t, err)
err = os.WriteFile(filepath.Join(dir, "package-lock.json"), []byte("{}"), 0644)
require.NoError(t, err)
content, err := GenerateDockerfile(dir)
require.NoError(t, err)
// Should have multi-stage build
assert.Contains(t, content, "FROM node:20-alpine AS frontend")
assert.Contains(t, content, "npm ci")
assert.Contains(t, content, "npm run build")
assert.Contains(t, content, "COPY --from=frontend")
})
t.Run("project with pnpm", func(t *testing.T) {
dir := t.TempDir()
composerJSON := `{
"name": "test/laravel-pnpm",
"require": {
"php": "^8.3",
"laravel/framework": "^11.0"
}
}`
err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte(composerJSON), 0644)
require.NoError(t, err)
err = os.WriteFile(filepath.Join(dir, "composer.lock"), []byte("{}"), 0644)
require.NoError(t, err)
packageJSON := `{
"name": "test-app",
"scripts": {
"build": "vite build"
}
}`
err = os.WriteFile(filepath.Join(dir, "package.json"), []byte(packageJSON), 0644)
require.NoError(t, err)
// Create pnpm-lock.yaml
err = os.WriteFile(filepath.Join(dir, "pnpm-lock.yaml"), []byte("lockfileVersion: 6.0"), 0644)
require.NoError(t, err)
content, err := GenerateDockerfile(dir)
require.NoError(t, err)
assert.Contains(t, content, "pnpm install")
assert.Contains(t, content, "pnpm run build")
})
t.Run("project with Redis dependency", func(t *testing.T) {
dir := t.TempDir()
composerJSON := `{
"name": "test/laravel-redis",
"require": {
"php": "^8.3",
"laravel/framework": "^11.0",
"predis/predis": "^2.0"
}
}`
err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte(composerJSON), 0644)
require.NoError(t, err)
err = os.WriteFile(filepath.Join(dir, "composer.lock"), []byte("{}"), 0644)
require.NoError(t, err)
content, err := GenerateDockerfile(dir)
require.NoError(t, err)
assert.Contains(t, content, "install-php-extensions")
assert.Contains(t, content, "redis")
})
t.Run("project with explicit ext- requirements", func(t *testing.T) {
dir := t.TempDir()
composerJSON := `{
"name": "test/with-extensions",
"require": {
"php": "^8.3",
"ext-gd": "*",
"ext-imagick": "*",
"ext-intl": "*"
}
}`
err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte(composerJSON), 0644)
require.NoError(t, err)
err = os.WriteFile(filepath.Join(dir, "composer.lock"), []byte("{}"), 0644)
require.NoError(t, err)
content, err := GenerateDockerfile(dir)
require.NoError(t, err)
assert.Contains(t, content, "install-php-extensions")
assert.Contains(t, content, "gd")
assert.Contains(t, content, "imagick")
assert.Contains(t, content, "intl")
})
}
func TestGenerateDockerfile_Bad(t *testing.T) {
t.Run("missing composer.json", func(t *testing.T) {
dir := t.TempDir()
_, err := GenerateDockerfile(dir)
assert.Error(t, err)
assert.Contains(t, err.Error(), "composer.json")
})
t.Run("invalid composer.json", func(t *testing.T) {
dir := t.TempDir()
err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte("not json{"), 0644)
require.NoError(t, err)
_, err = GenerateDockerfile(dir)
assert.Error(t, err)
})
}
func TestDetectDockerfileConfig_Good(t *testing.T) {
t.Run("full Laravel project", func(t *testing.T) {
dir := t.TempDir()
composerJSON := `{
"name": "test/full-laravel",
"require": {
"php": "^8.3",
"laravel/framework": "^11.0",
"laravel/octane": "^2.0",
"predis/predis": "^2.0",
"intervention/image": "^3.0"
}
}`
err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte(composerJSON), 0644)
require.NoError(t, err)
packageJSON := `{"scripts": {"build": "vite build"}}`
err = os.WriteFile(filepath.Join(dir, "package.json"), []byte(packageJSON), 0644)
require.NoError(t, err)
err = os.WriteFile(filepath.Join(dir, "yarn.lock"), []byte(""), 0644)
require.NoError(t, err)
config, err := DetectDockerfileConfig(dir)
require.NoError(t, err)
assert.Equal(t, "8.3", config.PHPVersion)
assert.True(t, config.IsLaravel)
assert.True(t, config.HasOctane)
assert.True(t, config.HasAssets)
assert.Equal(t, "yarn", config.PackageManager)
assert.Contains(t, config.PHPExtensions, "redis")
assert.Contains(t, config.PHPExtensions, "gd")
})
}
func TestDetectDockerfileConfig_Bad(t *testing.T) {
t.Run("non-existent directory", func(t *testing.T) {
_, err := DetectDockerfileConfig("/non/existent/path")
assert.Error(t, err)
})
}
func TestExtractPHPVersion_Good(t *testing.T) {
tests := []struct {
constraint string
expected string
}{
{"^8.2", "8.2"},
{"^8.3", "8.3"},
{">=8.2", "8.2"},
{"~8.2", "8.2"},
{"8.2.*", "8.2"},
{"8.2.0", "8.2"},
{"8", "8.0"},
}
for _, tt := range tests {
t.Run(tt.constraint, func(t *testing.T) {
result := extractPHPVersion(tt.constraint)
assert.Equal(t, tt.expected, result)
})
}
}
func TestDetectPHPExtensions_Good(t *testing.T) {
t.Run("detects Redis from predis", func(t *testing.T) {
composer := ComposerJSON{
Require: map[string]string{
"predis/predis": "^2.0",
},
}
extensions := detectPHPExtensions(composer)
assert.Contains(t, extensions, "redis")
})
t.Run("detects GD from intervention/image", func(t *testing.T) {
composer := ComposerJSON{
Require: map[string]string{
"intervention/image": "^3.0",
},
}
extensions := detectPHPExtensions(composer)
assert.Contains(t, extensions, "gd")
})
t.Run("detects multiple extensions from Laravel", func(t *testing.T) {
composer := ComposerJSON{
Require: map[string]string{
"laravel/framework": "^11.0",
},
}
extensions := detectPHPExtensions(composer)
assert.Contains(t, extensions, "pdo_mysql")
assert.Contains(t, extensions, "bcmath")
})
t.Run("detects explicit ext- requirements", func(t *testing.T) {
composer := ComposerJSON{
Require: map[string]string{
"ext-gd": "*",
"ext-imagick": "*",
},
}
extensions := detectPHPExtensions(composer)
assert.Contains(t, extensions, "gd")
assert.Contains(t, extensions, "imagick")
})
t.Run("skips built-in extensions", func(t *testing.T) {
composer := ComposerJSON{
Require: map[string]string{
"ext-json": "*",
"ext-session": "*",
"ext-pdo": "*",
},
}
extensions := detectPHPExtensions(composer)
assert.NotContains(t, extensions, "json")
assert.NotContains(t, extensions, "session")
assert.NotContains(t, extensions, "pdo")
})
t.Run("sorts extensions alphabetically", func(t *testing.T) {
composer := ComposerJSON{
Require: map[string]string{
"ext-zip": "*",
"ext-gd": "*",
"ext-intl": "*",
},
}
extensions := detectPHPExtensions(composer)
// Check they are sorted
for i := 1; i < len(extensions); i++ {
assert.True(t, extensions[i-1] < extensions[i],
"extensions should be sorted: %v", extensions)
}
})
}
func TestHasNodeAssets_Good(t *testing.T) {
t.Run("with build script", func(t *testing.T) {
dir := t.TempDir()
packageJSON := `{
"name": "test",
"scripts": {
"dev": "vite",
"build": "vite build"
}
}`
err := os.WriteFile(filepath.Join(dir, "package.json"), []byte(packageJSON), 0644)
require.NoError(t, err)
assert.True(t, hasNodeAssets(dir))
})
}
func TestHasNodeAssets_Bad(t *testing.T) {
t.Run("no package.json", func(t *testing.T) {
dir := t.TempDir()
assert.False(t, hasNodeAssets(dir))
})
t.Run("no build script", func(t *testing.T) {
dir := t.TempDir()
packageJSON := `{
"name": "test",
"scripts": {
"dev": "vite"
}
}`
err := os.WriteFile(filepath.Join(dir, "package.json"), []byte(packageJSON), 0644)
require.NoError(t, err)
assert.False(t, hasNodeAssets(dir))
})
t.Run("invalid package.json", func(t *testing.T) {
dir := t.TempDir()
err := os.WriteFile(filepath.Join(dir, "package.json"), []byte("invalid{"), 0644)
require.NoError(t, err)
assert.False(t, hasNodeAssets(dir))
})
}
func TestGenerateDockerignore_Good(t *testing.T) {
t.Run("generates complete dockerignore", func(t *testing.T) {
dir := t.TempDir()
content := GenerateDockerignore(dir)
// Check key entries
assert.Contains(t, content, ".git")
assert.Contains(t, content, "node_modules")
assert.Contains(t, content, ".env")
assert.Contains(t, content, "vendor")
assert.Contains(t, content, "storage/logs/*")
assert.Contains(t, content, ".idea")
assert.Contains(t, content, ".vscode")
})
}
func TestGenerateDockerfileFromConfig_Good(t *testing.T) {
t.Run("minimal config", func(t *testing.T) {
config := &DockerfileConfig{
PHPVersion: "8.3",
BaseImage: "dunglas/frankenphp",
UseAlpine: true,
}
content := GenerateDockerfileFromConfig(config)
assert.Contains(t, content, "FROM dunglas/frankenphp:latest-php8.3-alpine")
assert.Contains(t, content, "WORKDIR /app")
assert.Contains(t, content, "COPY composer.json composer.lock")
assert.Contains(t, content, "EXPOSE 80 443")
})
t.Run("with extensions", func(t *testing.T) {
config := &DockerfileConfig{
PHPVersion: "8.3",
BaseImage: "dunglas/frankenphp",
UseAlpine: true,
PHPExtensions: []string{"redis", "gd", "intl"},
}
content := GenerateDockerfileFromConfig(config)
assert.Contains(t, content, "install-php-extensions redis gd intl")
})
t.Run("Laravel with Octane", func(t *testing.T) {
config := &DockerfileConfig{
PHPVersion: "8.3",
BaseImage: "dunglas/frankenphp",
UseAlpine: true,
IsLaravel: true,
HasOctane: true,
}
content := GenerateDockerfileFromConfig(config)
assert.Contains(t, content, "php artisan config:cache")
assert.Contains(t, content, "php artisan route:cache")
assert.Contains(t, content, "php artisan view:cache")
assert.Contains(t, content, "chown -R www-data:www-data storage")
assert.Contains(t, content, "octane:start")
})
t.Run("with frontend assets", func(t *testing.T) {
config := &DockerfileConfig{
PHPVersion: "8.3",
BaseImage: "dunglas/frankenphp",
UseAlpine: true,
HasAssets: true,
PackageManager: "npm",
}
content := GenerateDockerfileFromConfig(config)
// Multi-stage build
assert.Contains(t, content, "FROM node:20-alpine AS frontend")
assert.Contains(t, content, "COPY package.json package-lock.json")
assert.Contains(t, content, "RUN npm ci")
assert.Contains(t, content, "RUN npm run build")
assert.Contains(t, content, "COPY --from=frontend /app/public/build public/build")
})
t.Run("with yarn", func(t *testing.T) {
config := &DockerfileConfig{
PHPVersion: "8.3",
BaseImage: "dunglas/frankenphp",
UseAlpine: true,
HasAssets: true,
PackageManager: "yarn",
}
content := GenerateDockerfileFromConfig(config)
assert.Contains(t, content, "COPY package.json yarn.lock")
assert.Contains(t, content, "yarn install --frozen-lockfile")
assert.Contains(t, content, "yarn build")
})
t.Run("with bun", func(t *testing.T) {
config := &DockerfileConfig{
PHPVersion: "8.3",
BaseImage: "dunglas/frankenphp",
UseAlpine: true,
HasAssets: true,
PackageManager: "bun",
}
content := GenerateDockerfileFromConfig(config)
assert.Contains(t, content, "npm install -g bun")
assert.Contains(t, content, "COPY package.json bun.lockb")
assert.Contains(t, content, "bun install --frozen-lockfile")
assert.Contains(t, content, "bun run build")
})
t.Run("non-alpine image", func(t *testing.T) {
config := &DockerfileConfig{
PHPVersion: "8.3",
BaseImage: "dunglas/frankenphp",
UseAlpine: false,
}
content := GenerateDockerfileFromConfig(config)
assert.Contains(t, content, "FROM dunglas/frankenphp:latest-php8.3 AS app")
assert.NotContains(t, content, "alpine")
})
}
func TestIsPHPProject_Good(t *testing.T) {
t.Run("project with composer.json", func(t *testing.T) {
dir := t.TempDir()
err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte("{}"), 0644)
require.NoError(t, err)
assert.True(t, IsPHPProject(dir))
})
}
func TestIsPHPProject_Bad(t *testing.T) {
t.Run("project without composer.json", func(t *testing.T) {
dir := t.TempDir()
assert.False(t, IsPHPProject(dir))
})
t.Run("non-existent directory", func(t *testing.T) {
assert.False(t, IsPHPProject("/non/existent/path"))
})
}
func TestDockerfileStructure_Good(t *testing.T) {
t.Run("Dockerfile has proper structure", func(t *testing.T) {
dir := t.TempDir()
composerJSON := `{
"name": "test/app",
"require": {
"php": "^8.3",
"laravel/framework": "^11.0",
"laravel/octane": "^2.0",
"predis/predis": "^2.0"
}
}`
err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte(composerJSON), 0644)
require.NoError(t, err)
err = os.WriteFile(filepath.Join(dir, "composer.lock"), []byte("{}"), 0644)
require.NoError(t, err)
packageJSON := `{"scripts": {"build": "vite build"}}`
err = os.WriteFile(filepath.Join(dir, "package.json"), []byte(packageJSON), 0644)
require.NoError(t, err)
err = os.WriteFile(filepath.Join(dir, "package-lock.json"), []byte("{}"), 0644)
require.NoError(t, err)
content, err := GenerateDockerfile(dir)
require.NoError(t, err)
lines := strings.Split(content, "\n")
var fromCount, workdirCount, copyCount, runCount, exposeCount, cmdCount int
for _, line := range lines {
trimmed := strings.TrimSpace(line)
switch {
case strings.HasPrefix(trimmed, "FROM "):
fromCount++
case strings.HasPrefix(trimmed, "WORKDIR "):
workdirCount++
case strings.HasPrefix(trimmed, "COPY "):
copyCount++
case strings.HasPrefix(trimmed, "RUN "):
runCount++
case strings.HasPrefix(trimmed, "EXPOSE "):
exposeCount++
case strings.HasPrefix(trimmed, "CMD ["):
// Only count actual CMD instructions, not HEALTHCHECK CMD
cmdCount++
}
}
// Multi-stage build should have 2 FROM statements
assert.Equal(t, 2, fromCount, "should have 2 FROM statements for multi-stage build")
// Should have proper structure
assert.GreaterOrEqual(t, workdirCount, 1, "should have WORKDIR")
assert.GreaterOrEqual(t, copyCount, 3, "should have multiple COPY statements")
assert.GreaterOrEqual(t, runCount, 2, "should have multiple RUN statements")
assert.Equal(t, 1, exposeCount, "should have exactly one EXPOSE")
assert.Equal(t, 1, cmdCount, "should have exactly one CMD")
})
}