diff --git a/cmd/core/cmd/php.go b/cmd/core/cmd/php.go index b0eb085..522a8c1 100644 --- a/cmd/core/cmd/php.go +++ b/cmd/core/cmd/php.go @@ -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 + }) +} diff --git a/pkg/php/container.go b/pkg/php/container.go new file mode 100644 index 0000000..5f37ae6 --- /dev/null +++ b/pkg/php/container.go @@ -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:-} +` diff --git a/pkg/php/dockerfile.go b/pkg/php/dockerfile.go new file mode 100644 index 0000000..eff1110 --- /dev/null +++ b/pkg/php/dockerfile.go @@ -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() +} diff --git a/pkg/php/dockerfile_test.go b/pkg/php/dockerfile_test.go new file mode 100644 index 0000000..3bb186a --- /dev/null +++ b/pkg/php/dockerfile_test.go @@ -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") + }) +}