mirror of
https://github.com/dunglas/frankenphp.git
synced 2025-12-24 13:38:11 +08:00
305 lines
7.9 KiB
Go
305 lines
7.9 KiB
Go
package caddy
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/caddyserver/caddy/v2"
|
|
"github.com/caddyserver/caddy/v2/caddyconfig"
|
|
"github.com/caddyserver/caddy/v2/caddyconfig/caddyfile"
|
|
"github.com/caddyserver/caddy/v2/caddyconfig/httpcaddyfile"
|
|
"github.com/caddyserver/caddy/v2/modules/caddyhttp"
|
|
"github.com/dunglas/frankenphp"
|
|
"github.com/dunglas/frankenphp/internal/fastabs"
|
|
)
|
|
|
|
// FrankenPHPApp represents the global "frankenphp" directive in the Caddyfile
|
|
// it's responsible for starting up the global PHP instance and all threads
|
|
//
|
|
// {
|
|
// frankenphp {
|
|
// num_threads 20
|
|
// }
|
|
// }
|
|
type FrankenPHPApp struct {
|
|
// NumThreads sets the number of PHP threads to start. Default: 2x the number of available CPUs.
|
|
NumThreads int `json:"num_threads,omitempty"`
|
|
// MaxThreads limits how many threads can be started at runtime. Default 2x NumThreads
|
|
MaxThreads int `json:"max_threads,omitempty"`
|
|
// Workers configures the worker scripts to start.
|
|
Workers []workerConfig `json:"workers,omitempty"`
|
|
// TaskWorkers configures the task worker scripts to start.
|
|
TaskWorkers []workerConfig `json:"task_workers,omitempty"`
|
|
// Overwrites the default php ini configuration
|
|
PhpIni map[string]string `json:"php_ini,omitempty"`
|
|
// The maximum amount of time a request may be stalled waiting for a thread
|
|
MaxWaitTime time.Duration `json:"max_wait_time,omitempty"`
|
|
|
|
metrics frankenphp.Metrics
|
|
logger *slog.Logger
|
|
}
|
|
|
|
var iniError = errors.New(`"php_ini" must be in the format: php_ini "<key>" "<value>"`)
|
|
|
|
// CaddyModule returns the Caddy module information.
|
|
func (f FrankenPHPApp) CaddyModule() caddy.ModuleInfo {
|
|
return caddy.ModuleInfo{
|
|
ID: "frankenphp",
|
|
New: func() caddy.Module { return &f },
|
|
}
|
|
}
|
|
|
|
// Provision sets up the module.
|
|
func (f *FrankenPHPApp) Provision(ctx caddy.Context) error {
|
|
f.logger = ctx.Slogger()
|
|
|
|
if httpApp, err := ctx.AppIfConfigured("http"); err == nil {
|
|
if httpApp.(*caddyhttp.App).Metrics != nil {
|
|
f.metrics = frankenphp.NewPrometheusMetrics(ctx.GetMetricsRegistry())
|
|
}
|
|
} else {
|
|
// if the http module is not configured (this should never happen) then collect the metrics by default
|
|
if errors.Is(err, caddy.ErrNotConfigured) {
|
|
f.metrics = frankenphp.NewPrometheusMetrics(ctx.GetMetricsRegistry())
|
|
} else {
|
|
// the http module failed to provision due to invalid configuration
|
|
return fmt.Errorf("failed to provision caddy http: %w", err)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (f *FrankenPHPApp) generateUniqueModuleWorkerName(filepath string) string {
|
|
var i uint
|
|
filepath, _ = fastabs.FastAbs(filepath)
|
|
name := "m#" + filepath
|
|
|
|
retry:
|
|
for _, wc := range f.Workers {
|
|
if wc.Name == name {
|
|
name = fmt.Sprintf("m#%s_%d", filepath, i)
|
|
i++
|
|
|
|
goto retry
|
|
}
|
|
}
|
|
|
|
return name
|
|
}
|
|
|
|
func (f *FrankenPHPApp) addModuleWorkers(workers ...workerConfig) ([]workerConfig, error) {
|
|
for i := range workers {
|
|
w := &workers[i]
|
|
if frankenphp.EmbeddedAppPath != "" && filepath.IsLocal(w.FileName) {
|
|
w.FileName = filepath.Join(frankenphp.EmbeddedAppPath, w.FileName)
|
|
}
|
|
if w.Name == "" {
|
|
w.Name = f.generateUniqueModuleWorkerName(w.FileName)
|
|
} else if !strings.HasPrefix(w.Name, "m#") {
|
|
w.Name = "m#" + w.Name
|
|
}
|
|
f.Workers = append(f.Workers, *w)
|
|
}
|
|
return workers, nil
|
|
}
|
|
|
|
func (f *FrankenPHPApp) Start() error {
|
|
repl := caddy.NewReplacer()
|
|
|
|
opts := []frankenphp.Option{
|
|
frankenphp.WithNumThreads(f.NumThreads),
|
|
frankenphp.WithMaxThreads(f.MaxThreads),
|
|
frankenphp.WithLogger(f.logger),
|
|
frankenphp.WithMetrics(f.metrics),
|
|
frankenphp.WithPhpIni(f.PhpIni),
|
|
frankenphp.WithMaxWaitTime(f.MaxWaitTime),
|
|
}
|
|
for _, w := range append(f.Workers) {
|
|
workerOpts := []frankenphp.WorkerOption{
|
|
frankenphp.WithWorkerEnv(w.Env),
|
|
frankenphp.WithWorkerWatchMode(w.Watch),
|
|
frankenphp.WithWorkerMaxFailures(w.MaxConsecutiveFailures),
|
|
}
|
|
|
|
opts = append(opts, frankenphp.WithWorkers(w.Name, repl.ReplaceKnown(w.FileName, ""), w.Num, workerOpts...))
|
|
}
|
|
for _, tw := range f.TaskWorkers {
|
|
workerOpts := []frankenphp.WorkerOption{
|
|
frankenphp.WithWorkerEnv(tw.Env),
|
|
frankenphp.WithWorkerWatchMode(tw.Watch),
|
|
frankenphp.AsTaskWorker(true, 0), // TODO: maxQueueLen configurable here?
|
|
}
|
|
|
|
opts = append(opts, frankenphp.WithWorkers(tw.Name, repl.ReplaceKnown(tw.FileName, ""), tw.Num, workerOpts...))
|
|
}
|
|
|
|
frankenphp.Shutdown()
|
|
if err := frankenphp.Init(opts...); err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (f *FrankenPHPApp) Stop() error {
|
|
f.logger.Info("FrankenPHP stopped 🐘")
|
|
|
|
// attempt a graceful shutdown if caddy is exiting
|
|
// note: Exiting() is currently marked as 'experimental'
|
|
// https://github.com/caddyserver/caddy/blob/e76405d55058b0a3e5ba222b44b5ef00516116aa/caddy.go#L810
|
|
if caddy.Exiting() {
|
|
frankenphp.DrainWorkers()
|
|
}
|
|
|
|
// reset the configuration so it doesn't bleed into later tests
|
|
f.Workers = nil
|
|
f.NumThreads = 0
|
|
f.MaxWaitTime = 0
|
|
|
|
return nil
|
|
}
|
|
|
|
// UnmarshalCaddyfile implements caddyfile.Unmarshaler.
|
|
func (f *FrankenPHPApp) UnmarshalCaddyfile(d *caddyfile.Dispenser) error {
|
|
for d.Next() {
|
|
for d.NextBlock(0) {
|
|
// when adding a new directive, also update the allowedDirectives error message
|
|
switch d.Val() {
|
|
case "num_threads":
|
|
if !d.NextArg() {
|
|
return d.ArgErr()
|
|
}
|
|
|
|
v, err := strconv.ParseUint(d.Val(), 10, 32)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
f.NumThreads = int(v)
|
|
case "max_threads":
|
|
if !d.NextArg() {
|
|
return d.ArgErr()
|
|
}
|
|
|
|
if d.Val() == "auto" {
|
|
f.MaxThreads = -1
|
|
continue
|
|
}
|
|
|
|
v, err := strconv.ParseUint(d.Val(), 10, 32)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
f.MaxThreads = int(v)
|
|
case "max_wait_time":
|
|
if !d.NextArg() {
|
|
return d.ArgErr()
|
|
}
|
|
|
|
v, err := time.ParseDuration(d.Val())
|
|
if err != nil {
|
|
return d.Err("max_wait_time must be a valid duration (example: 10s)")
|
|
}
|
|
|
|
f.MaxWaitTime = v
|
|
case "php_ini":
|
|
parseIniLine := func(d *caddyfile.Dispenser) error {
|
|
key := d.Val()
|
|
if !d.NextArg() {
|
|
return d.WrapErr(iniError)
|
|
}
|
|
if f.PhpIni == nil {
|
|
f.PhpIni = make(map[string]string)
|
|
}
|
|
f.PhpIni[key] = d.Val()
|
|
if d.NextArg() {
|
|
return d.WrapErr(iniError)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
isBlock := false
|
|
for d.NextBlock(1) {
|
|
isBlock = true
|
|
err := parseIniLine(d)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
if !isBlock {
|
|
if !d.NextArg() {
|
|
return d.WrapErr(iniError)
|
|
}
|
|
err := parseIniLine(d)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
case "task_worker":
|
|
twc, err := parseWorkerConfig(d)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
f.TaskWorkers = append(f.TaskWorkers, twc)
|
|
|
|
case "worker":
|
|
wc, err := parseWorkerConfig(d)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if frankenphp.EmbeddedAppPath != "" && filepath.IsLocal(wc.FileName) {
|
|
wc.FileName = filepath.Join(frankenphp.EmbeddedAppPath, wc.FileName)
|
|
}
|
|
if strings.HasPrefix(wc.Name, "m#") {
|
|
return d.Errf(`global worker names must not start with "m#": %q`, wc.Name)
|
|
}
|
|
// check for duplicate workers
|
|
for _, existingWorker := range f.Workers {
|
|
if existingWorker.FileName == wc.FileName {
|
|
return d.Errf("global workers must not have duplicate filenames: %q", wc.FileName)
|
|
}
|
|
}
|
|
|
|
f.Workers = append(f.Workers, wc)
|
|
default:
|
|
allowedDirectives := "num_threads, max_threads, php_ini, worker, max_wait_time"
|
|
return wrongSubDirectiveError("frankenphp", allowedDirectives, d.Val())
|
|
}
|
|
}
|
|
}
|
|
|
|
if f.MaxThreads > 0 && f.NumThreads > 0 && f.MaxThreads < f.NumThreads {
|
|
return d.Err(`"max_threads"" must be greater than or equal to "num_threads"`)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func parseGlobalOption(d *caddyfile.Dispenser, _ any) (any, error) {
|
|
app := &FrankenPHPApp{}
|
|
if err := app.UnmarshalCaddyfile(d); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// tell Caddyfile adapter that this is the JSON for an app
|
|
return httpcaddyfile.App{
|
|
Name: "frankenphp",
|
|
Value: caddyconfig.JSON(app, nil),
|
|
}, nil
|
|
}
|
|
|
|
var (
|
|
_ caddy.App = (*FrankenPHPApp)(nil)
|
|
_ caddy.Provisioner = (*FrankenPHPApp)(nil)
|
|
)
|