mirror of
https://github.com/hanzoai/pack.git
synced 2026-09-30 23:43:13 +00:00
- Migrate module path to github.com/hanzoai/pack across all Go packages - Remove legacy upstream docs and competitor brand references - Modernize LLM.md and mise.toml for pristine Hanzo Pack builder
468 lines
13 KiB
Go
468 lines
13 KiB
Go
package php
|
|
|
|
import (
|
|
"fmt"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
|
|
_ "embed"
|
|
|
|
"github.com/hanzoai/pack/core/generate"
|
|
"github.com/hanzoai/pack/core/plan"
|
|
"github.com/hanzoai/pack/core/providers/node"
|
|
"github.com/stretchr/objx"
|
|
)
|
|
|
|
const (
|
|
DEFAULT_PHP_VERSION = "8.4"
|
|
DefaultCaddyfilePath = "/Caddyfile"
|
|
COMPOSER_CACHE_DIR = "/opt/cache/composer"
|
|
)
|
|
|
|
//go:embed Caddyfile
|
|
var caddyfileTemplate string
|
|
|
|
//go:embed start-container.sh
|
|
var startContainerScript string
|
|
|
|
//go:embed php.ini
|
|
var phpIniTemplate string
|
|
|
|
type PhpProvider struct{}
|
|
|
|
func (p *PhpProvider) Name() string {
|
|
return "php"
|
|
}
|
|
|
|
func (p *PhpProvider) Detect(ctx *generate.GenerateContext) (bool, error) {
|
|
return ctx.App.HasFile("index.php") ||
|
|
ctx.App.HasFile("composer.json"), nil
|
|
}
|
|
|
|
func (p *PhpProvider) Initialize(ctx *generate.GenerateContext) error {
|
|
return nil
|
|
}
|
|
|
|
func (p *PhpProvider) Plan(ctx *generate.GenerateContext) error {
|
|
phpImageStep, err := p.phpImagePackage(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
configFiles, err := p.getConfigFiles(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get config files: %w", err)
|
|
}
|
|
|
|
isLaravel := p.usesLaravel(ctx)
|
|
|
|
prepare := ctx.NewCommandStep("prepare")
|
|
prepare.AddInput(plan.NewStepLayer(phpImageStep.Name()))
|
|
p.Prepare(ctx, prepare, configFiles)
|
|
|
|
extensions := ctx.NewCommandStep("extensions")
|
|
extensions.AddInput(plan.NewStepLayer(prepare.Name()))
|
|
p.InstallExtensions(ctx, extensions)
|
|
|
|
composer := ctx.NewCommandStep("install:composer")
|
|
composer.AddInput(plan.NewStepLayer(extensions.Name()))
|
|
p.InstallCompose(ctx, composer)
|
|
|
|
// Node (if necessary)
|
|
nodeProvider := node.NodeProvider{}
|
|
isNode, err := nodeProvider.Detect(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if isLaravel {
|
|
ctx.Logger.LogInfo("Found Laravel app")
|
|
}
|
|
|
|
ctx.Metadata.SetBool("phpLaravel", isLaravel)
|
|
|
|
if isNode {
|
|
err = p.DeployWithNode(ctx, nodeProvider, composer, isLaravel)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
} else {
|
|
// A manual build command will go here
|
|
build := ctx.NewCommandStep("build")
|
|
build.AddInput(plan.NewStepLayer(composer.Name()))
|
|
build.AddInput(plan.NewLocalLayer())
|
|
ctx.Deploy.Base = plan.NewStepLayer(build.Name())
|
|
p.ConditionallyIncludeMise(ctx)
|
|
}
|
|
|
|
ctx.Deploy.StartCmd = "/start-container.sh"
|
|
|
|
return nil
|
|
}
|
|
|
|
func (p *PhpProvider) Prepare(ctx *generate.GenerateContext, prepare *generate.CommandStepBuilder, configFiles *ConfigFiles) {
|
|
if configFiles.Caddyfile.Filename != "" {
|
|
ctx.Logger.LogInfo("Using custom Caddyfile: %s", configFiles.Caddyfile.Filename)
|
|
}
|
|
|
|
if configFiles.PhpIni.Filename != "" {
|
|
ctx.Logger.LogInfo("Using custom php.ini: %s", configFiles.PhpIni.Filename)
|
|
}
|
|
|
|
if configFiles.StartContainerScript.Filename != "" {
|
|
ctx.Logger.LogInfo("Using custom start-container.sh: %s", configFiles.StartContainerScript.Filename)
|
|
}
|
|
|
|
prepare.Assets["Caddyfile"] = configFiles.Caddyfile.Contents
|
|
prepare.Assets["php.ini"] = configFiles.PhpIni.Contents
|
|
prepare.Assets["start-container.sh"] = configFiles.StartContainerScript.Contents
|
|
|
|
prepare.AddEnvVars(map[string]string{
|
|
"APP_ENV": "production",
|
|
"APP_DEBUG": "false",
|
|
"APP_LOCALE": "en",
|
|
"LOG_CHANNEL": "stderr",
|
|
"LOG_LEVEL": "debug",
|
|
"SERVER_NAME": ":80",
|
|
"PHP_INI_DIR": "/usr/local/etc/php",
|
|
"OCTANE_SERVER": "frankenphp",
|
|
"IS_LARAVEL": strconv.FormatBool(p.usesLaravel(ctx)),
|
|
})
|
|
prepare.AddCommands([]plan.Command{
|
|
plan.NewExecCommand("mkdir -p /usr/local/etc/php/conf.d"),
|
|
plan.NewExecCommand("mkdir -p /conf.d/"),
|
|
plan.NewFileCommand("/usr/local/etc/php/conf.d/php.ini", "php.ini"),
|
|
plan.NewFileCommand(DefaultCaddyfilePath, "Caddyfile"),
|
|
plan.NewFileCommand("/start-container.sh", "start-container.sh", plan.FileOptions{
|
|
CustomName: "create start container script",
|
|
Mode: 0755,
|
|
}),
|
|
})
|
|
prepare.Secrets = []string{}
|
|
}
|
|
|
|
func (p *PhpProvider) InstallExtensions(ctx *generate.GenerateContext, extensions *generate.CommandStepBuilder) {
|
|
phpExtensions := p.getPhpExtensions(ctx)
|
|
|
|
if len(phpExtensions) > 0 {
|
|
extensions.AddCommands([]plan.Command{
|
|
plan.NewExecCommand(fmt.Sprintf("install-php-extensions %s", strings.Join(phpExtensions, " "))),
|
|
})
|
|
extensions.Caches = append(extensions.Caches, ctx.Caches.GetAptCaches()...)
|
|
}
|
|
extensions.Secrets = []string{}
|
|
}
|
|
|
|
func (p *PhpProvider) InstallCompose(ctx *generate.GenerateContext, composer *generate.CommandStepBuilder) {
|
|
composer.Secrets = []string{}
|
|
composer.UseSecretsWithPrefixes([]string{"COMPOSER", "PHP"})
|
|
composer.AddVariables(map[string]string{
|
|
"COMPOSER_FUND": "0",
|
|
"COMPOSER_CACHE_DIR": COMPOSER_CACHE_DIR,
|
|
})
|
|
if _, err := p.readComposerJson(ctx); err == nil {
|
|
composer.AddCache(ctx.Caches.AddCache("composer", COMPOSER_CACHE_DIR))
|
|
composerFiles := p.ComposerSupportingFiles(ctx)
|
|
|
|
// Copy composer from the composer image
|
|
composer.AddCommand(plan.CopyCommand{Image: "composer:latest", Src: "/usr/bin/composer", Dest: "/usr/bin/composer"})
|
|
|
|
for _, file := range composerFiles {
|
|
composer.AddCommand(plan.NewCopyCommand(file))
|
|
}
|
|
|
|
composer.AddCommands([]plan.Command{
|
|
plan.NewExecCommand("composer install --optimize-autoloader --no-scripts --no-interaction"),
|
|
})
|
|
}
|
|
}
|
|
|
|
func (p *PhpProvider) DeployWithNode(ctx *generate.GenerateContext, nodeProvider node.NodeProvider, composer *generate.CommandStepBuilder, isLaravel bool) error {
|
|
err := nodeProvider.Initialize(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
ctx.Logger.LogInfo("Installing Node")
|
|
|
|
miseStep := ctx.GetMiseStepBuilder()
|
|
nodeProvider.InstallMisePackages(ctx, miseStep)
|
|
|
|
install := ctx.NewCommandStep("install:node")
|
|
install.AddInput(plan.NewStepLayer(miseStep.Name()))
|
|
nodeProvider.InstallNodeDeps(ctx, install)
|
|
|
|
prune := ctx.NewCommandStep("prune:node")
|
|
prune.AddInput(plan.NewStepLayer(install.Name()))
|
|
nodeProvider.PruneNodeDeps(ctx, prune)
|
|
|
|
build := ctx.NewCommandStep("build")
|
|
build.Inputs = []plan.Layer{
|
|
plan.NewStepLayer(composer.Name()),
|
|
plan.NewStepLayer(install.Name(), plan.Filter{
|
|
Include: append([]string{"."}, miseStep.GetOutputPaths()...),
|
|
}),
|
|
}
|
|
nodeProvider.Build(ctx, build)
|
|
|
|
if isLaravel {
|
|
build.AddCommands([]plan.Command{
|
|
plan.NewExecShellCommand("mkdir -p storage/framework/{sessions,views,cache,testing} storage/logs bootstrap/cache && chmod -R a+rw storage"),
|
|
// config values like APP_KEY are resolved and baked into bootstrap/cache/config.php. Runtime env vars are ignored
|
|
// for cached configs, leading to MissingAppKeyException if APP_KEY was unset at cache time.
|
|
plan.NewExecCommand("php artisan config:cache"),
|
|
plan.NewExecCommand("php artisan event:cache"),
|
|
plan.NewExecCommand("php artisan route:cache"),
|
|
plan.NewExecCommand("php artisan view:cache"),
|
|
})
|
|
}
|
|
|
|
ctx.Deploy.Base = plan.NewStepLayer(composer.Name())
|
|
|
|
p.ConditionallyIncludeMise(ctx)
|
|
ctx.Deploy.AddInputs([]plan.Layer{
|
|
plan.NewStepLayer(prune.Name(), plan.Filter{
|
|
Include: []string{"/app/node_modules"},
|
|
}),
|
|
plan.NewStepLayer(build.Name(), plan.Filter{
|
|
Include: []string{"."},
|
|
Exclude: []string{"node_modules"},
|
|
}),
|
|
})
|
|
|
|
return nil
|
|
}
|
|
|
|
// Include mise and packages in the final image if the user has specified any additional packages
|
|
func (p *PhpProvider) ConditionallyIncludeMise(ctx *generate.GenerateContext) {
|
|
if len(ctx.GetMiseStepBuilder().MisePackages) >= 1 {
|
|
ctx.Deploy.AddInputs([]plan.Layer{
|
|
ctx.GetMiseStepBuilder().GetLayer(),
|
|
})
|
|
}
|
|
}
|
|
|
|
func (p *PhpProvider) ComposerSupportingFiles(ctx *generate.GenerateContext) []string {
|
|
patterns := []string{
|
|
"**/composer.json",
|
|
"**/composer.lock",
|
|
"artisan",
|
|
}
|
|
|
|
var allFiles []string
|
|
for _, pattern := range patterns {
|
|
files, err := ctx.App.FindFiles(pattern)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
allFiles = append(allFiles, files...)
|
|
|
|
dirs, err := ctx.App.FindDirectories(pattern)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
allFiles = append(allFiles, dirs...)
|
|
}
|
|
|
|
return allFiles
|
|
}
|
|
|
|
func (p *PhpProvider) getPhpExtensions(ctx *generate.GenerateContext) []string {
|
|
extensions := []string{}
|
|
|
|
composerJson, err := p.readComposerJson(ctx)
|
|
if err != nil {
|
|
return extensions
|
|
}
|
|
|
|
if require, ok := composerJson["require"].(map[string]any); ok {
|
|
for ext := range require {
|
|
if after, ok0 := strings.CutPrefix(ext, "ext-"); ok0 {
|
|
extensions = append(extensions, after)
|
|
}
|
|
}
|
|
}
|
|
|
|
if extensionsVar, _ := ctx.Env.GetConfigVariable("PHP_EXTENSIONS"); extensionsVar != "" {
|
|
extensions = append(extensions, strings.FieldsFunc(extensionsVar, func(r rune) bool {
|
|
return r == ',' || r == ' '
|
|
})...)
|
|
}
|
|
|
|
if p.usesLaravel(ctx) {
|
|
// https://laravel.com/docs/12.x/deployment#server-requirements
|
|
extensions = append(extensions,
|
|
"ctype",
|
|
"curl",
|
|
"dom",
|
|
"fileinfo",
|
|
"filter",
|
|
"hash",
|
|
"mbstring",
|
|
"openssl",
|
|
"pcre",
|
|
"pdo",
|
|
"session",
|
|
"tokenizer",
|
|
"xml")
|
|
}
|
|
|
|
if dbConnection := ctx.Env.GetVariable("DB_CONNECTION"); dbConnection != "" {
|
|
switch dbConnection {
|
|
case "mysql":
|
|
extensions = append(extensions, "pdo_mysql")
|
|
case "pgsql":
|
|
extensions = append(extensions, "pdo_pgsql")
|
|
}
|
|
}
|
|
|
|
if p.needsRedisExtension(ctx, composerJson) {
|
|
extensions = append(extensions, "redis")
|
|
}
|
|
|
|
return extensions
|
|
}
|
|
|
|
func (p *PhpProvider) needsRedisExtension(ctx *generate.GenerateContext, composerJson map[string]any) bool {
|
|
// Check if Redis is explicitly mentioned in environment variables
|
|
redisHost := ctx.Env.GetVariable("REDIS_HOST")
|
|
redisUrl := ctx.Env.GetVariable("REDIS_URL")
|
|
cacheDriver := ctx.Env.GetVariable("CACHE_DRIVER")
|
|
sessionDriver := ctx.Env.GetVariable("SESSION_DRIVER")
|
|
queueConnection := ctx.Env.GetVariable("QUEUE_CONNECTION")
|
|
|
|
if redisHost != "" || redisUrl != "" ||
|
|
cacheDriver == "redis" || sessionDriver == "redis" || queueConnection == "redis" {
|
|
return true
|
|
}
|
|
|
|
// Check for Redis packages in composer.json
|
|
if require, ok := composerJson["require"].(map[string]any); ok {
|
|
for pkg := range require {
|
|
if strings.Contains(pkg, "redis") ||
|
|
strings.Contains(pkg, "predis") {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
func (p *PhpProvider) usesLaravel(ctx *generate.GenerateContext) bool {
|
|
return ctx.App.HasFile("artisan")
|
|
}
|
|
|
|
type ConfigFiles struct {
|
|
Caddyfile *generate.TemplateFileResult
|
|
StartContainerScript *generate.TemplateFileResult
|
|
PhpIni *generate.TemplateFileResult
|
|
}
|
|
|
|
func (p *PhpProvider) getConfigFiles(ctx *generate.GenerateContext) (*ConfigFiles, error) {
|
|
phpRootDir := "/app"
|
|
if variable := ctx.Env.GetVariable("RAILPACK_PHP_ROOT_DIR"); variable != "" {
|
|
phpRootDir = variable
|
|
} else if p.usesLaravel(ctx) {
|
|
phpRootDir = "/app/public"
|
|
}
|
|
|
|
data := map[string]any{
|
|
"RAILPACK_PHP_ROOT_DIR": phpRootDir,
|
|
"IS_LARAVEL": p.usesLaravel(ctx),
|
|
}
|
|
|
|
caddyfile, err := ctx.TemplateFiles([]string{"Caddyfile"}, caddyfileTemplate, data)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
startContainerScript, err := ctx.TemplateFiles([]string{"start-container.sh"}, startContainerScript, data)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
phpIni, err := ctx.TemplateFiles([]string{"php.ini"}, phpIniTemplate, data)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &ConfigFiles{
|
|
Caddyfile: caddyfile,
|
|
StartContainerScript: startContainerScript,
|
|
PhpIni: phpIni,
|
|
}, nil
|
|
}
|
|
|
|
func (p *PhpProvider) phpImagePackage(ctx *generate.GenerateContext) (*generate.ImageStepBuilder, error) {
|
|
imageStep := ctx.NewImageStep("packages:image", func(options *generate.BuildStepOptions) string {
|
|
if phpVersion, ok := options.ResolvedPackages["php"]; ok && phpVersion.ResolvedVersion != nil {
|
|
return getPhpImage(*phpVersion.ResolvedVersion)
|
|
}
|
|
|
|
// Return the default if we were not able to resolve the version
|
|
return getPhpImage(DEFAULT_PHP_VERSION)
|
|
})
|
|
|
|
imageStep.AptPackages = append(imageStep.AptPackages, "git", "zip", "unzip", "ca-certificates")
|
|
|
|
// Include both build and runtime apt packages since we don't have a separate runtime image
|
|
imageStep.AptPackages = append(imageStep.AptPackages, ctx.Config.BuildAptPackages...)
|
|
imageStep.AptPackages = append(imageStep.AptPackages, ctx.Config.Deploy.AptPackages...)
|
|
|
|
php := imageStep.Default("php", DEFAULT_PHP_VERSION)
|
|
|
|
// Read composer.json to get the PHP version
|
|
if composerJson, err := p.readComposerJson(ctx); err == nil {
|
|
phpVersion := objx.New(composerJson).Get("require.php")
|
|
if phpVersion.IsStr() {
|
|
if after, ok := strings.CutPrefix(phpVersion.Str(), "^"); ok {
|
|
imageStep.Version(php, after, "composer.json > require > php")
|
|
} else {
|
|
imageStep.Version(php, phpVersion.Str(), "composer.json > require > php")
|
|
}
|
|
}
|
|
}
|
|
|
|
// Ensure that the version is available on Docker Hub
|
|
imageStep.SetVersionAvailable(php, func(version string) bool {
|
|
image := getPhpImage(version)
|
|
|
|
// dunglas/frankenphp:php8.4.3-bookworm -> [dunglas, frankenphp, php8.4.3-bookworm]
|
|
parts := strings.Split(image, ":")
|
|
repository := parts[0] // dunglas/frankenphp
|
|
tag := parts[1] // php8.4.3-bookworm
|
|
|
|
url := fmt.Sprintf("https://registry.hub.docker.com/v2/repositories/%s/tags/%s", repository, tag)
|
|
resp, err := http.Get(url)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
return resp.StatusCode == http.StatusOK
|
|
})
|
|
|
|
return imageStep, nil
|
|
}
|
|
|
|
func getPhpImage(phpVersion string) string {
|
|
return fmt.Sprintf("dunglas/frankenphp:php%s-bookworm", phpVersion)
|
|
}
|
|
|
|
func (p *PhpProvider) readComposerJson(ctx *generate.GenerateContext) (map[string]any, error) {
|
|
var composerJson map[string]any
|
|
err := ctx.App.ReadJSON("composer.json", &composerJson)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return composerJson, nil
|
|
}
|
|
|
|
func (p *PhpProvider) CleansePlan(buildPlan *plan.BuildPlan) {}
|
|
|
|
func (p *PhpProvider) StartCommandHelp() string {
|
|
return ""
|
|
}
|