Port all PHP command files from core/cli internal/cmd/php/ into a standalone module. Inlines workspace dependency to avoid cross-module internal imports. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
451 lines
11 KiB
Go
451 lines
11 KiB
Go
package php
|
|
|
|
import (
|
|
"context"
|
|
"io"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"forge.lthn.ai/core/go/pkg/cli"
|
|
)
|
|
|
|
// 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 cli.WrapVerb(err, "get", "working directory")
|
|
}
|
|
opts.ProjectDir = cwd
|
|
}
|
|
|
|
// Validate project directory
|
|
if !IsPHPProject(opts.ProjectDir) {
|
|
return cli.Err("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 cli.WrapVerb(err, "generate", "Dockerfile")
|
|
}
|
|
|
|
// Write to temporary file
|
|
m := getMedium()
|
|
tempDockerfile = filepath.Join(opts.ProjectDir, "Dockerfile.core-generated")
|
|
if err := m.Write(tempDockerfile, content); err != nil {
|
|
return cli.WrapVerb(err, "write", "Dockerfile")
|
|
}
|
|
defer func() { _ = m.Delete(tempDockerfile) }()
|
|
|
|
dockerfilePath = tempDockerfile
|
|
}
|
|
|
|
// Build Docker image
|
|
imageRef := cli.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", cli.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 cli.Wrap(err, "docker build failed")
|
|
}
|
|
|
|
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 cli.WrapVerb(err, "get", "working directory")
|
|
}
|
|
opts.ProjectDir = cwd
|
|
}
|
|
|
|
// Validate project directory
|
|
if !IsPHPProject(opts.ProjectDir) {
|
|
return cli.Err("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
|
|
m := getMedium()
|
|
outputDir := filepath.Dir(opts.OutputPath)
|
|
if err := m.EnsureDir(outputDir); err != nil {
|
|
return cli.WrapVerb(err, "create", "output directory")
|
|
}
|
|
|
|
// Find linuxkit binary
|
|
linuxkitPath, err := lookupLinuxKit()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Get template content
|
|
templateContent, err := getLinuxKitTemplate(opts.Template)
|
|
if err != nil {
|
|
return cli.WrapVerb(err, "get", "template")
|
|
}
|
|
|
|
// 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 cli.WrapVerb(err, "apply", "template variables")
|
|
}
|
|
|
|
// Write template to temp file
|
|
tempYAML := filepath.Join(opts.ProjectDir, ".core-linuxkit.yml")
|
|
if err := m.Write(tempYAML, content); err != nil {
|
|
return cli.WrapVerb(err, "write", "template")
|
|
}
|
|
defer func() { _ = m.Delete(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 cli.Wrap(err, "linuxkit build failed")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// ServeProduction runs a production PHP container.
|
|
func ServeProduction(ctx context.Context, opts ServeOptions) error {
|
|
if opts.ImageName == "" {
|
|
return cli.Err("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 := cli.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", cli.Sprintf("%d:80", opts.Port))
|
|
args = append(args, "-p", cli.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", cli.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 cli.WrapVerb(err, "start", "container")
|
|
}
|
|
containerID := strings.TrimSpace(string(output))
|
|
cli.Print("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 cli.Err("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")
|
|
return getMedium().IsFile(composerPath)
|
|
}
|
|
|
|
// commonLinuxKitPaths defines default search locations for linuxkit.
|
|
var commonLinuxKitPaths = []string{
|
|
"/usr/local/bin/linuxkit",
|
|
"/opt/homebrew/bin/linuxkit",
|
|
}
|
|
|
|
// lookupLinuxKit finds the linuxkit binary.
|
|
func lookupLinuxKit() (string, error) {
|
|
// Check PATH first
|
|
if path, err := exec.LookPath("linuxkit"); err == nil {
|
|
return path, nil
|
|
}
|
|
|
|
m := getMedium()
|
|
for _, p := range commonLinuxKitPaths {
|
|
if m.IsFile(p) {
|
|
return p, nil
|
|
}
|
|
}
|
|
|
|
return "", cli.Err("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 forge.lthn.ai/core/go/pkg/container
|
|
return "", cli.Err("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 "", cli.WrapVerb(err, "list", "containers")
|
|
}
|
|
|
|
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 "", cli.Err("no container found matching: %s", partialID)
|
|
case 1:
|
|
return matches[0], nil
|
|
default:
|
|
return "", cli.Err("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:-}
|
|
`
|