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:
parent
96d394435a
commit
5568f86768
4 changed files with 1746 additions and 0 deletions
|
|
@ -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
448
pkg/php/container.go
Normal 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
396
pkg/php/dockerfile.go
Normal 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
614
pkg/php/dockerfile_test.go
Normal 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")
|
||||
})
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue