From 95665df4aed5a419102966f739a61062901c6479 Mon Sep 17 00:00:00 2001 From: asp24 <488588+asp24@users.noreply.github.com> Date: Wed, 21 Oct 2026 21:18:59 +0200 Subject: [PATCH 01/18] Document the configuration options in README --- README.md | 66 +++++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 64 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 6f8ea0f..27d68ee 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,65 @@ -# Docker PHP-Fpm Wrapper +# Docker PHP-FPM Wrapper -Solution for php app logging running under php-fpm in container + prometheus monitoring + gracefull shutdown in k8s +A solution to improve PHP application logging when running under php-fpm in a containerized environment. It also provides Prometheus monitoring and a graceful shutdown mechanism tailored for Kubernetes deployments. + +## Overview + +This project wraps around PHP-FPM, intercepting its logs and exposing Prometheus metrics. It is especially useful for containerized PHP applications running in Kubernetes, providing: + +- **Enhanced Logging:** Routes php-fpm logs through a customizable logging proxy. +- **Slowlog Proxy:** Parse and proxy PHP-FPM slowlog output. +- **Logging Proxy:** Configure Unix pipe or socket for app log forwarding. +- **Prometheus Monitoring:** Exposes application metrics to help monitor application performance. +- **Graceful Shutdown:** Implements a delay mechanism to ensure smooth shutdowns in Kubernetes environments. + +## Available Configuration Options + +The following options can be configured via command-line flags or environment variables: + +- **--log-level** + - *Description:* Sets the internal log level. Use `-1` for debug-level logging. + - *Default:* `-1` + +- **--log-encoder** + - *Description:* Chooses the internal logging encoder. + - *Default:* `auto` + +- **--fpm, -f** + - *Description:* Path to the PHP-FPM executable. + - *Default:* _(No default, must be provided if not in PATH)_ + +- **--fpm-config, -c** + - *Description:* Path to the PHP-FPM configuration file. + - *Default:* `/etc/php/php-fpm.conf` + +- **--fpm-no-errlog** + - *Description:* Disable php-fpm error log parsing and proxy. + - *Default:* `false` + +- **--fpm-no-slowlog** + - *Description:* Disable php-fpm slow log parsing and proxy. + - *Default:* `false` + +- **--wrapper-pipe, -p** + - *Description:* Path to the logging pipe. Set to an empty string (`""`) to disable. + - *Default:* `/tmp/fpm-wrapper-pipe` + +- **--wrapper-socket, -s** + - *Description:* Path to the logging socket. Set to `null` to disable. + - *Default:* `/tmp/fpm-wrapper.sock` + +- **--line-buffer-size** + - *Description:* Specifies the maximum log line size in bytes. + - *Default:* `16384` (16*1024 bytes) + +- **--listen** + - *Description:* The address for Prometheus to scrape metrics. + - *Default:* `:8080` + +- **--metrics-path** + - *Description:* The path to expose Prometheus metrics. + - *Default:* `/metrics` + +- **--shutdown-delay** + - *Description:* Delay before the process shutdown to allow graceful termination. + - *Default:* `500ms` From 663e42c26f0a7f392cb506bc078106dcdafbfd47 Mon Sep 17 00:00:00 2001 From: asp24 <488588+asp24@users.noreply.github.com> Date: Wed, 21 Oct 2026 21:19:09 +0200 Subject: [PATCH 02/18] Update Go to 1.27 and the dependencies --- go.mod | 36 ++++++++++++------------- go.sum | 85 +++++++++++++++++++++++++++++++++------------------------- 2 files changed, 67 insertions(+), 54 deletions(-) diff --git a/go.mod b/go.mod index eb35eb0..9bcde4e 100644 --- a/go.mod +++ b/go.mod @@ -1,39 +1,39 @@ module github.com/code-tool/docker-fpm-wrapper -go 1.23 +go 1.27 require ( github.com/FZambia/viper-lite v0.0.0-20220110144934-1899f66c7d0e github.com/joho/godotenv v1.5.1 - github.com/mattn/go-isatty v0.0.20 + github.com/mattn/go-isatty v0.0.24 github.com/otiai10/copy v1.14.1 - github.com/prometheus/client_golang v1.20.5 - github.com/prometheus/procfs v0.15.1 - github.com/spf13/pflag v1.0.5 - github.com/stretchr/testify v1.10.0 + github.com/prometheus/client_golang v1.25.0 + github.com/prometheus/procfs v0.22.0 + github.com/spf13/pflag v1.0.10 + github.com/stretchr/testify v1.12.1 github.com/tomasen/fcgi_client v0.0.0-20180423082037-2bb3d819fd19 go.uber.org/multierr v1.11.0 - go.uber.org/zap v1.27.0 - golang.org/x/sys v0.29.0 - gopkg.in/ini.v1 v1.67.0 + go.uber.org/zap v1.28.0 + golang.org/x/sys v0.49.0 + gopkg.in/ini.v1 v1.67.3 ) require ( github.com/beorn7/perks v1.0.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect - github.com/davecgh/go-spew v1.1.1 // indirect - github.com/klauspost/compress v1.17.9 // indirect + github.com/klauspost/compress v1.20.3 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/otiai10/mint v1.6.3 // indirect github.com/pelletier/go-toml v1.9.5 // indirect - github.com/pmezard/go-difflib v1.0.0 // indirect - github.com/prometheus/client_model v0.6.1 // indirect - github.com/prometheus/common v0.55.0 // indirect - github.com/spf13/cast v1.7.1 // indirect + github.com/prometheus/client_model v0.6.3 // indirect + github.com/prometheus/common v0.72.1 // indirect + github.com/rogpeppe/go-internal v1.14.1 // indirect + github.com/spf13/cast v1.10.0 // indirect github.com/spf13/jwalterweatherman v1.1.0 // indirect - golang.org/x/sync v0.10.0 // indirect - google.golang.org/protobuf v1.34.2 // indirect + go.yaml.in/yaml/v3 v3.0.5 // indirect + golang.org/x/sync v0.22.0 // indirect + google.golang.org/protobuf v1.36.12 // indirect + gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect gopkg.in/yaml.v2 v2.4.0 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 0888d72..ac012b1 100644 --- a/go.sum +++ b/go.sum @@ -4,24 +4,27 @@ github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= -github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= -github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= -github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA= -github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= +github.com/klauspost/compress v1.20.3 h1:zWNoFqq/lSuYQRim3dUXlXeA0IqwuZGicdG5ZTfY5j4= +github.com/klauspost/compress v1.20.3/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= -github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= -github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= +github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= @@ -32,48 +35,58 @@ github.com/otiai10/mint v1.6.3 h1:87qsV/aw1F5as1eH1zS/yqHY85ANKVMgkDrf9rcxbQs= github.com/otiai10/mint v1.6.3/go.mod h1:MJm72SBthJjz8qhefc4z1PYEieWmy8Bku7CjcAqyUSM= github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8= github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/prometheus/client_golang v1.20.5 h1:cxppBPuYhUnsO6yo/aoRol4L7q7UFfdm+bR9r+8l63Y= -github.com/prometheus/client_golang v1.20.5/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE= -github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= -github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= -github.com/prometheus/common v0.55.0 h1:KEi6DK7lXW/m7Ig5i47x0vRzuBsHuvJdi5ee6Y3G1dc= -github.com/prometheus/common v0.55.0/go.mod h1:2SECS4xJG1kd8XF9IcM1gMX6510RAEL65zxzNImwdc8= -github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= -github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= -github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= -github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= -github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y= -github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= +github.com/prometheus/client_golang v1.25.0 h1:cDtW1TcFsMi1RJc8khwCKB4VhzwV04aKqbYtNOXuiK0= +github.com/prometheus/client_golang v1.25.0/go.mod h1:j2X/4/kaQHLPnmyEU8zjmkndo1T317VpCIQEml8LTKk= +github.com/prometheus/client_model v0.6.3 h1:O0jaTVAYNxTHYInEPFJt5I3+sN8zqBtVMPTB1qyxiEo= +github.com/prometheus/client_model v0.6.3/go.mod h1:gpN5P9S7Rr6Yr92PiQ+Ixvhf6JZEkF1dnxsYL2aPBEM= +github.com/prometheus/common v0.72.1 h1:ZAiQed/nk8IIcsdzb2TlJu23NEUDyDLQ9TXTN2WRK6Y= +github.com/prometheus/common v0.72.1/go.mod h1:1IoBY4IRaJnhs2ESjNzjilP1cJ/P8pimGoomq7zRXrA= +github.com/prometheus/procfs v0.22.0 h1:6q9+/JL9IKAPbCmBrv9n5O5Ty3NKnciV5X7YGw0oics= +github.com/prometheus/procfs v0.22.0/go.mod h1:CvmFr/GVhIjIvWJZW3tgkODBQMRIf0EyWMQLHCHab58= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= +github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= github.com/spf13/jwalterweatherman v1.1.0 h1:ue6voC5bR5F8YxI5S67j9i582FU4Qvo2bmqnqMYADFk= github.com/spf13/jwalterweatherman v1.1.0/go.mod h1:aNWZUN0dPAAO/Ljvb5BEdw96iTZ0EXowPYD95IqWIGo= -github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= -github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= -github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= -github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= +github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= github.com/tomasen/fcgi_client v0.0.0-20180423082037-2bb3d819fd19 h1:ZCmSnT6CLGhfoQ2lPEhL4nsJstKDCw1F1RfN8/smTCU= github.com/tomasen/fcgi_client v0.0.0-20180423082037-2bb3d819fd19/go.mod h1:SXTY+QvI+KTTKXQdg0zZ7nx0u94QWh8ZAwBQYsW9cqk= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= -go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= -go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= -golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= -golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= -golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg= -google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw= +go.uber.org/zap v1.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo= +go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q= +go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= +go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= +go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= +go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA= +golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= +google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc= +google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= -gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= -gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/ini.v1 v1.67.3 h1:iM9Lhz5MRSGhHVGGwCuzG9KO8PoirCXj/m/qTmOJJQw= +gopkg.in/ini.v1 v1.67.3/go.mod h1:x/cyOwCgZqOkJoDIJ3c1KNHMo10+nLGAhh+kn3Zizss= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= -gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= From c63c927964105f254bb2e1abe6c0e11ca8f8121f Mon Sep 17 00:00:00 2001 From: asp24 <488588+asp24@users.noreply.github.com> Date: Wed, 21 Oct 2026 21:19:24 +0200 Subject: [PATCH 03/18] Fix the linter findings and handle the logger creation error --- cmd/docker-fpm-wrapper/main.go | 8 ++++++-- internal/applog/data_listener.go | 3 +-- internal/session/cleaner.go | 12 ++++++++---- pkg/phpfpm/slowlog_parser_test.go | 2 +- 4 files changed, 16 insertions(+), 9 deletions(-) diff --git a/cmd/docker-fpm-wrapper/main.go b/cmd/docker-fpm-wrapper/main.go index 91267dc..7e7989e 100644 --- a/cmd/docker-fpm-wrapper/main.go +++ b/cmd/docker-fpm-wrapper/main.go @@ -58,6 +58,10 @@ func main() { } log, err := createLogger(cfg.LogEncoder, logLever, syncStderr) + if err != nil { + fmt.Printf("Can't create logger: %v\n", err) + os.Exit(1) + } if cfg.FpmPath == "" { log.Error("php-fpm path not set") @@ -99,14 +103,14 @@ func main() { os.Exit(1) } - if false == cfg.FpmNoErrlogProxy && fpmConfig.ErrorLog != "syslog" { + if !cfg.FpmNoErrlogProxy && fpmConfig.ErrorLog != "syslog" { if err := startErrLogProxy(ctx, log.Named("php-fpm"), fpmConfig.ErrorLog); err != nil { log.Error("can't start err_log proxy", zap.String("path", fpmConfig.ErrorLog), zap.Error(err)) os.Exit(1) } } - if false == cfg.FpmNoSlowlogProxy { + if !cfg.FpmNoSlowlogProxy { if err = startSlowlogProxies(ctx, log.Named("php-fpm"), fpmConfig.Pools); err != nil { log.Error("Can't start slowlog proxies", zap.Error(err)) os.Exit(1) diff --git a/internal/applog/data_listener.go b/internal/applog/data_listener.go index c0ae34f..45c07bd 100644 --- a/internal/applog/data_listener.go +++ b/internal/applog/data_listener.go @@ -1,7 +1,6 @@ package applog import ( - "errors" "fmt" "io" "net" @@ -58,7 +57,7 @@ func (l *SockDataListener) initSocket() error { if err == nil { _ = c.Close() // socket exists and listening - return errors.New(fmt.Sprintf("Socket %s already exists and listening", l.socketPath)) + return fmt.Errorf("socket %s already exists and listening", l.socketPath) } err = os.Remove(l.socketPath) diff --git a/internal/session/cleaner.go b/internal/session/cleaner.go index 1913000..1481a58 100644 --- a/internal/session/cleaner.go +++ b/internal/session/cleaner.go @@ -4,7 +4,6 @@ import ( "bytes" "context" "fmt" - "io/ioutil" "os" "os/exec" "strings" @@ -137,13 +136,18 @@ func (c *Cleaner) touchOpenFiles(procName string, savePath string) error { } func (c *Cleaner) removeSessionFiles(savePath string, cutoff time.Duration) error { - fileInfo, err := ioutil.ReadDir(savePath) + entries, err := os.ReadDir(savePath) if err != nil { return err } now := time.Now() - for _, info := range fileInfo { - if info.IsDir() || !strings.HasPrefix(info.Name(), "sess_") { + for _, entry := range entries { + if entry.IsDir() || !strings.HasPrefix(entry.Name(), "sess_") { + continue + } + + info, err := entry.Info() + if err != nil { continue } diff --git a/pkg/phpfpm/slowlog_parser_test.go b/pkg/phpfpm/slowlog_parser_test.go index 1bd7d03..c39bcd7 100644 --- a/pkg/phpfpm/slowlog_parser_test.go +++ b/pkg/phpfpm/slowlog_parser_test.go @@ -19,7 +19,7 @@ func TestSlowlogParser(t *testing.T) { t.Fail() return } - defer f.Close() + defer func() { _ = f.Close() }() slp := NewSlowlogParser(0) out := make(chan SlowlogEntry) From 4bf37fe73181ab05599d3b5a53cba2f545f20ceb Mon Sep 17 00:00:00 2001 From: asp24 <488588+asp24@users.noreply.github.com> Date: Wed, 21 Oct 2026 21:19:41 +0200 Subject: [PATCH 04/18] Replace the manual e2e test with testcontainers e2e tests --- e2e/applog_test.go | 41 ++++++ e2e/errlog_test.go | 35 ++++++ e2e/logcollector_test.go | 49 ++++++++ e2e/logentry_test.go | 44 +++++++ e2e/main_test.go | 76 ++++++++++++ e2e/metrics_test.go | 27 ++++ e2e/shutdown_test.go | 56 +++++++++ e2e/slowlog_test.go | 36 ++++++ e2e/startfailure_test.go | 86 +++++++++++++ e2e/testdata/invalid-php-fpm.conf | 10 ++ e2e/testdata/php-fpm.conf | 22 ++++ e2e/testdata/www/applog.php | 37 ++++++ e2e/testdata/www/index.php | 5 + e2e/testdata/www/sleep.php | 7 ++ e2e/testdata/www/slow.php | 19 +++ e2e/testdata/www/stderr.php | 11 ++ e2e/wrapper_test.go | 199 ++++++++++++++++++++++++++++++ go.mod | 48 ++++++- go.sum | 113 ++++++++++++++++- 19 files changed, 915 insertions(+), 6 deletions(-) create mode 100644 e2e/applog_test.go create mode 100644 e2e/errlog_test.go create mode 100644 e2e/logcollector_test.go create mode 100644 e2e/logentry_test.go create mode 100644 e2e/main_test.go create mode 100644 e2e/metrics_test.go create mode 100644 e2e/shutdown_test.go create mode 100644 e2e/slowlog_test.go create mode 100644 e2e/startfailure_test.go create mode 100644 e2e/testdata/invalid-php-fpm.conf create mode 100644 e2e/testdata/php-fpm.conf create mode 100644 e2e/testdata/www/applog.php create mode 100644 e2e/testdata/www/index.php create mode 100644 e2e/testdata/www/sleep.php create mode 100644 e2e/testdata/www/slow.php create mode 100644 e2e/testdata/www/stderr.php create mode 100644 e2e/wrapper_test.go diff --git a/e2e/applog_test.go b/e2e/applog_test.go new file mode 100644 index 0000000..277a966 --- /dev/null +++ b/e2e/applog_test.go @@ -0,0 +1,41 @@ +//go:build e2e + +package e2e + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +// TestAppLogProxy examines the lines that PHP writes to FPM_WRAPPER_SOCK and FPM_WRAPPER_PIPE. +func TestAppLogProxy(t *testing.T) { + t.Parallel() + + w := startWrapper(t) + + assert.Equal(t, "ok", w.mustRequest(t, "applog.php", "")) + + tests := []struct { + name string + line string + }{ + {name: "socket line", line: "socket line"}, + {name: "socket line with crlf has lf only", line: "socket crlf line"}, + {name: "socket line without newline gets newline", line: "socket line without newline"}, + {name: "socket line after a dropped long line", line: "socket line after long line"}, + {name: "pipe line", line: "pipe line"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + w.waitForLine(t, tt.line) + }) + } + + t.Run("long line is dropped", func(t *testing.T) { + for _, line := range w.logs.Lines() { + assert.NotContains(t, line, strings.Repeat("x", 1024)) + } + }) +} diff --git a/e2e/errlog_test.go b/e2e/errlog_test.go new file mode 100644 index 0000000..2f279a5 --- /dev/null +++ b/e2e/errlog_test.go @@ -0,0 +1,35 @@ +//go:build e2e + +package e2e + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +// TestErrorLogProxy examines the entries of the php-fpm error log that the wrapper writes as JSON. +func TestErrorLogProxy(t *testing.T) { + t.Parallel() + + w := startWrapper(t) + + assert.Equal(t, "ok", w.mustRequest(t, "stderr.php", "")) + + tests := []struct { + name string + level string + inMessage string + }{ + {name: "notice of php-fpm is info", level: "info", inMessage: "ready to handle connections"}, + {name: "stderr of a worker is warn", level: "warn", inMessage: "worker stderr line"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + w.waitForEntry(t, func(e logEntry) bool { + return e.Channel == "php-fpm" && e.Level == tt.level && strings.Contains(e.Message, tt.inMessage) + }) + }) + } +} diff --git a/e2e/logcollector_test.go b/e2e/logcollector_test.go new file mode 100644 index 0000000..869ef98 --- /dev/null +++ b/e2e/logcollector_test.go @@ -0,0 +1,49 @@ +//go:build e2e + +package e2e + +import ( + "slices" + "strings" + "sync" + + "github.com/testcontainers/testcontainers-go" +) + +// logCollector keeps the output of a container as lines. +type logCollector struct { + mu sync.Mutex + lines []string + partial map[string]string // the incomplete last line of each stream +} + +func newLogCollector() *logCollector { + return &logCollector{partial: make(map[string]string)} +} + +// Accept implements testcontainers.LogConsumer. +func (c *logCollector) Accept(l testcontainers.Log) { + c.mu.Lock() + defer c.mu.Unlock() + + data := c.partial[l.LogType] + string(l.Content) + for { + line, rest, found := strings.Cut(data, "\n") + if !found { + break + } + + c.lines = append(c.lines, line) + data = rest + } + + c.partial[l.LogType] = data +} + +// Lines returns a copy of the complete lines. +func (c *logCollector) Lines() []string { + c.mu.Lock() + defer c.mu.Unlock() + + return slices.Clone(c.lines) +} diff --git a/e2e/logentry_test.go b/e2e/logentry_test.go new file mode 100644 index 0000000..8c97303 --- /dev/null +++ b/e2e/logentry_test.go @@ -0,0 +1,44 @@ +//go:build e2e + +package e2e + +import ( + "encoding/json/v2" + "strings" +) + +// logEntry is a JSON log entry of the wrapper. +type logEntry struct { + Level string `json:"level"` + Channel string `json:"channel"` + Message string `json:"message"` + Filename string `json:"filename"` + Trace []traceFrame `json:"trace"` +} + +// traceFrame is one frame of the trace in a slowlog entry. +type traceFrame struct { + Path string `json:"path"` + Func string `json:"func"` + Line int `json:"line"` +} + +// parseEntries returns the JSON log entries. It ignores the lines that are not JSON objects, +// for example the lines from the log socket and the log pipe. +func parseEntries(lines []string) []logEntry { + var entries []logEntry + for _, line := range lines { + if !strings.HasPrefix(line, "{") { + continue + } + + var entry logEntry + if err := json.Unmarshal([]byte(line), &entry); err != nil { + continue + } + + entries = append(entries, entry) + } + + return entries +} diff --git a/e2e/main_test.go b/e2e/main_test.go new file mode 100644 index 0000000..fe83733 --- /dev/null +++ b/e2e/main_test.go @@ -0,0 +1,76 @@ +//go:build e2e + +// Package e2e runs docker-fpm-wrapper with php-fpm in a container and examines what the wrapper +// writes to its output. +package e2e + +import ( + "cmp" + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "testing" + "time" +) + +const ( + // defaultPHPImage is the image for the tests. Set E2E_PHP_IMAGE to use a different image. + defaultPHPImage = "php:8.5-fpm-alpine" + + wrapperPath = "/usr/local/bin/docker-fpm-wrapper" + fpmPath = "/usr/local/sbin/php-fpm" + fpmConfigPath = "/usr/local/etc/php-fpm-e2e.conf" + invalidFpmConfigPath = "/usr/local/etc/php-fpm-e2e-invalid.conf" + scriptDir = "/var/www/html" + + fcgiPort = "9000/tcp" + metricsPort = "8080/tcp" + + eventuallyTimeout = 10 * time.Second + eventuallyTick = 50 * time.Millisecond +) + +// wrapperBinary is the path of the wrapper binary on the host. TestMain builds the binary once +// for all tests, and the go tool gives no other way to share it with the tests. +var wrapperBinary string //nolint:gochecknoglobals // TestMain sets it before the tests start. + +func TestMain(m *testing.M) { + os.Exit(run(m)) +} + +func run(m *testing.M) int { + dir, err := os.MkdirTemp("", "fpm-wrapper-e2e") + if err != nil { + _, _ = fmt.Fprintf(os.Stderr, "create a temp dir: %v\n", err) + return 1 + } + defer func() { _ = os.RemoveAll(dir) }() + + wrapperBinary = filepath.Join(dir, "docker-fpm-wrapper") + if err := buildWrapper(wrapperBinary); err != nil { + _, _ = fmt.Fprintln(os.Stderr, err) + return 1 + } + + return m.Run() +} + +// buildWrapper builds a static Linux binary for the architecture of the host. +func buildWrapper(output string) error { + cmd := exec.Command("go", "build", "-o", output, "../cmd/docker-fpm-wrapper") + cmd.Env = append(os.Environ(), "CGO_ENABLED=0", "GOOS=linux", "GOARCH="+runtime.GOARCH) + cmd.Stdout = os.Stderr + cmd.Stderr = os.Stderr + + if err := cmd.Run(); err != nil { + return fmt.Errorf("build the wrapper: %w", err) + } + + return nil +} + +func phpImage() string { + return cmp.Or(os.Getenv("E2E_PHP_IMAGE"), defaultPHPImage) +} diff --git a/e2e/metrics_test.go b/e2e/metrics_test.go new file mode 100644 index 0000000..e1ac38c --- /dev/null +++ b/e2e/metrics_test.go @@ -0,0 +1,27 @@ +//go:build e2e + +package e2e + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +// TestMetrics examines the metrics that the wrapper reads from the status page of php-fpm. +func TestMetrics(t *testing.T) { + t.Parallel() + + w := startWrapper(t) + + const requests = 3 + for range requests { + assert.Equal(t, "ok", w.mustRequest(t, "index.php", "")) + } + + // The status requests of the wrapper also increase the counter. + assert.GreaterOrEqual(t, w.poolGauge(t.Context(), t, "phpfpm_accepted_conn", "www"), float64(requests)) + // The config has "pm = static" and "pm.max_children = 2". + assert.InDelta(t, 2, w.poolGauge(t.Context(), t, "phpfpm_total_processes", "www"), 0) + assert.InDelta(t, 0, w.poolGauge(t.Context(), t, "phpfpm_slow_requests", "www"), 0) +} diff --git a/e2e/shutdown_test.go b/e2e/shutdown_test.go new file mode 100644 index 0000000..ceb79a3 --- /dev/null +++ b/e2e/shutdown_test.go @@ -0,0 +1,56 @@ +//go:build e2e + +package e2e + +import ( + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestGracefulShutdown sends SIGTERM to the wrapper while php-fpm serves a request. +// The wrapper must keep php-fpm running for --shutdown-delay, then stop it gracefully. +func TestGracefulShutdown(t *testing.T) { + t.Parallel() + + const shutdownDelay = time.Second + w := startWrapper(t, "--shutdown-delay", shutdownDelay.String()) + + var wg sync.WaitGroup + + var slowBody string + var slowErr error + wg.Go(func() { + slowBody, slowErr = w.request("sleep.php", "seconds=3") + }) + + // The status request itself is one active process. + require.EventuallyWithT(t, func(c *assert.CollectT) { + assert.GreaterOrEqual(c, w.poolGauge(t.Context(), c, "phpfpm_active_processes", "www"), float64(2)) + }, eventuallyTimeout, eventuallyTick) + + // Do not use "docker stop": the php-fpm images have "STOPSIGNAL SIGQUIT". + stopStart := time.Now() + exitCode, _, err := w.ctr.Exec(t.Context(), []string{"sh", "-c", "kill -TERM 1"}) + require.NoError(t, err) + require.Equal(t, 0, exitCode) + + // php-fpm accepts new requests during the shutdown delay. + time.Sleep(shutdownDelay / 3) + assert.Equal(t, "ok", w.mustRequest(t, "index.php", "")) + + wg.Wait() + require.NoError(t, slowErr) + assert.Equal(t, "done", slowBody) + + require.EventuallyWithT(t, func(c *assert.CollectT) { + state, err := w.ctr.State(t.Context()) + require.NoError(c, err) + require.False(c, state.Running) + assert.Equal(c, 0, state.ExitCode) + }, eventuallyTimeout, eventuallyTick) + assert.GreaterOrEqual(t, time.Since(stopStart), shutdownDelay) +} diff --git a/e2e/slowlog_test.go b/e2e/slowlog_test.go new file mode 100644 index 0000000..2ad00a5 --- /dev/null +++ b/e2e/slowlog_test.go @@ -0,0 +1,36 @@ +//go:build e2e + +package e2e + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +// TestSlowlogProxy examines the slowlog entry that the wrapper writes as JSON. +func TestSlowlogProxy(t *testing.T) { + t.Parallel() + + w := startWrapper(t) + + assert.Equal(t, "ok", w.mustRequest(t, "slow.php", "")) + + entry := w.waitForEntry(t, func(e logEntry) bool { + return e.Message == "slowlog" + }) + + assert.Equal(t, "php-fpm", entry.Channel) + assert.Equal(t, "warn", entry.Level) + // The wrapper removes the common directory of the script and the trace from all paths. + assert.Equal(t, "/slow.php", entry.Filename) + + funcs := make([]string, 0, len(entry.Trace)) + for _, frame := range entry.Trace { + assert.Equal(t, "/slow.php", frame.Path) + funcs = append(funcs, frame.Func) + } + assert.Equal(t, []string{"sleep()", "slowInner()", "slowOuter()"}, funcs) + + assert.InDelta(t, 1, w.poolGauge(t.Context(), t, "phpfpm_slow_requests", "www"), 0) +} diff --git a/e2e/startfailure_test.go b/e2e/startfailure_test.go new file mode 100644 index 0000000..ac7384a --- /dev/null +++ b/e2e/startfailure_test.go @@ -0,0 +1,86 @@ +//go:build e2e + +package e2e + +import ( + "io" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/testcontainers/testcontainers-go" + "github.com/testcontainers/testcontainers-go/wait" +) + +// readLogs reads all output of a container that exited. +func readLogs(t *testing.T, ctr *testcontainers.DockerContainer) []string { + t.Helper() + + reader, err := ctr.Logs(t.Context()) + require.NoError(t, err) + defer func() { _ = reader.Close() }() + + data, err := io.ReadAll(reader) + require.NoError(t, err) + + return strings.Split(string(data), "\n") +} + +// TestStartFailure examines the exit code and the log of the wrapper when it cannot start php-fpm. +func TestStartFailure(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + args []string + wantExitCode int + wantMessage string + }{ + { + name: "fpm path is not set", + args: []string{"--fpm-config", fpmConfigPath}, + wantExitCode: 1, + wantMessage: "php-fpm path not set", + }, + { + name: "fpm config does not exist", + args: []string{"--fpm", fpmPath, "--fpm-config", "/nonexistent/php-fpm.conf"}, + wantExitCode: 1, + wantMessage: "Can't parse fpm config", + }, + { + name: "fpm binary does not exist", + args: []string{"--fpm", "/nonexistent/php-fpm", "--fpm-config", fpmConfigPath}, + wantExitCode: 1, + wantMessage: "Can't start php-fpm", + }, + { + // The wrapper exits with the exit code of php-fpm: 78 is EX_CONFIG. + name: "php-fpm rejects the config", + args: []string{"--fpm", fpmPath, "--fpm-config", invalidFpmConfigPath}, + wantExitCode: 78, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + ctr := runContainer(t, wait.ForExit(), newLogCollector(), tt.args...) + + state, err := ctr.State(t.Context()) + require.NoError(t, err) + assert.Equal(t, tt.wantExitCode, state.ExitCode) + + if tt.wantMessage == "" { + return + } + + messages := make([]string, 0) + for _, entry := range parseEntries(readLogs(t, ctr)) { + messages = append(messages, entry.Message) + } + assert.Contains(t, messages, tt.wantMessage) + }) + } +} diff --git a/e2e/testdata/invalid-php-fpm.conf b/e2e/testdata/invalid-php-fpm.conf new file mode 100644 index 0000000..419ef82 --- /dev/null +++ b/e2e/testdata/invalid-php-fpm.conf @@ -0,0 +1,10 @@ +; The wrapper can parse this config, but php-fpm rejects it: "pm" has an unknown value. + +[global] +error_log = /tmp/php-fpm.log + +[www] +listen = 9000 +pm = unknown +pm.max_children = 2 +pm.status_path = /status diff --git a/e2e/testdata/php-fpm.conf b/e2e/testdata/php-fpm.conf new file mode 100644 index 0000000..41a8d19 --- /dev/null +++ b/e2e/testdata/php-fpm.conf @@ -0,0 +1,22 @@ +; The php-fpm config for the e2e tests. +; The wrapper replaces error_log and slowlog with FIFOs and parses them. + +[global] +error_log = /tmp/php-fpm.log +log_level = notice +; Let the children finish the current request after SIGQUIT. +process_control_timeout = 10s + +[www] +listen = 9000 +pm = static +pm.max_children = 2 +pm.status_path = /status + +; The wrapper must replace $pool with the name of the pool. +slowlog = /tmp/php-fpm-$pool.slow.log +request_slowlog_timeout = 1 + +; Keep the environment of the wrapper: it contains FPM_WRAPPER_SOCK and FPM_WRAPPER_PIPE. +clear_env = no +catch_workers_output = yes diff --git a/e2e/testdata/www/applog.php b/e2e/testdata/www/applog.php new file mode 100644 index 0000000..d75b2af --- /dev/null +++ b/e2e/testdata/www/applog.php @@ -0,0 +1,37 @@ + Date: Wed, 21 Oct 2026 21:19:47 +0200 Subject: [PATCH 05/18] Replace Travis CI with GitHub Actions and add a Makefile --- .github/workflows/release.yaml | 24 +++++++++++---- .github/workflows/test.yaml | 55 ++++++++++++++++++++++++++++++++++ .travis.yml | 30 ------------------- CHANGELOG.md | 13 ++++++++ Makefile | 11 +++++++ README.md | 15 ++++++++++ 6 files changed, 113 insertions(+), 35 deletions(-) create mode 100644 .github/workflows/test.yaml delete mode 100644 .travis.yml create mode 100644 Makefile diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 902216c..ac55a0f 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -1,26 +1,40 @@ +name: Release + on: release: types: [created] permissions: - contents: write - packages: write + contents: read jobs: + test: + name: Test + uses: ./.github/workflows/test.yaml + releases-matrix: name: Release Go Binary + needs: test runs-on: ubuntu-latest + permissions: + contents: write strategy: matrix: goos: [linux, darwin] goarch: [amd64, arm64] + env: + # A static binary runs on both glibc (Debian) and musl (Alpine) images. + CGO_ENABLED: "0" steps: - - uses: actions/checkout@v3 - - uses: wangyoucao577/go-release-action@v1 + - uses: actions/checkout@v7 + - uses: wangyoucao577/go-release-action@v1.55 with: github_token: ${{ secrets.GITHUB_TOKEN }} goos: ${{ matrix.goos }} goarch: ${{ matrix.goarch }} + goversion: go.mod project_path: "./cmd/docker-fpm-wrapper" binary_name: "docker-fpm-wrapper" - extra_files: LICENSE README.md + ldflags: "-s -w" + sha256sum: true + extra_files: LICENSE README.md CHANGELOG.md diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml new file mode 100644 index 0000000..25d3a7b --- /dev/null +++ b/.github/workflows/test.yaml @@ -0,0 +1,55 @@ +name: Test + +on: + push: + branches: [master] + pull_request: + workflow_call: + +permissions: + contents: read + +jobs: + lint: + name: Lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-go@v7 + with: + go-version-file: go.mod + - uses: golangci/golangci-lint-action@v9 + with: + version: v2.15.0 + args: --build-tags e2e + + unit: + name: Unit tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-go@v7 + with: + go-version-file: go.mod + - run: go test -race ./... + + e2e: + name: E2E tests (${{ matrix.image }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + image: + - php:8.2-fpm-alpine + - php:8.3-fpm-alpine + - php:8.4-fpm-alpine + - php:8.5-fpm-alpine + - php:8.5-fpm + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-go@v7 + with: + go-version-file: go.mod + - run: go test -race -tags e2e ./e2e/... + env: + E2E_PHP_IMAGE: ${{ matrix.image }} diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index b0222d0..0000000 --- a/.travis.yml +++ /dev/null @@ -1,30 +0,0 @@ -language: go - -sudo: required - -go: -- "1.10" - -before_install: - - sudo apt-get -qq update - -install: - - wget -O glide.tar.gz "https://github.com/Masterminds/glide/releases/download/v0.13.1/glide-v0.13.1-linux-amd64.tar.gz" - - mkdir -p $HOME/bin - - tar -vxz -C $HOME/bin --strip=1 -f glide.tar.gz - - export PATH="$HOME/bin:$PATH" - - glide i - - go build - -script: - - go test $(glide novendor) - -deploy: - provider: releases - api_key: - secure: ahyGkxfVZOu6B6Xlqy3lVpGiqSg1UpZz6y3oTStL3neZZHaoZtZFBZzaPW+ksSQDjfMiKIFEw3oo7db4DxruCBsDaZAntcFajSMjnEVAWfauNvZKzOTEk5CMDhz9KRl6HMqhDj1EojiiHxhgGHZykqEI8zrK9ZXj816haL4NnO4ETskP0MlebvLoTV1JpsugCr8UzZwsBAchFRck0wCJnkSQTI1Nu7mE8uE12zd8xf4Tvcr072GYqYAvwKtduX+j3N/wK1iec/FSZBYEVb3Vc+43pe8sInXG9Jy3XkBTcvTvQ6NvOpkNYEfJc7mcRprp29fT13y0zddIzs/M+Zc9e2Zw2xIgt7Aehpeea5y2uy8J/SkMdtR4HP4rfRjMkWI41NfQqaXaI2XeRPjW5gcewvHcBDq90gxIMpzWasN8BjAK46HwSnjJt6wITemu5y2BpyM03MvvWvVCpeAHArqBbLSu7jotBhGsG4cCKikUFhNyL9hn2CTAVS4rAFk0z4XYwtVslkla+2n3enrOifiQ5z3YuhMb8XjyroCfwdz7A3xCdu/FEVInTT8NWWEsO4I0UQvcqIJzSnaSaX/ApHUv0P5u1/h1yKrygu/IT1VDBvu0Qjiz/PyZZUNhZTh6AbjJu5mdS6NJg/hvKSbkHYheCXPu+9r7mZAto1oDVWmyTJo= - file: docker-fpm-wrapper - skip_cleanup: true - on: - repo: code-tool/docker-fpm-wrapper - tags: true diff --git a/CHANGELOG.md b/CHANGELOG.md index 013dca5..b4325ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](http://semver.org). --- +## [Unreleased] + +### Changed + +- The minimum Go version is 1.27. Update all dependencies. +- The release binaries are static (`CGO_ENABLED=0`). The `linux/amd64` binary of 1.0.2 needed glibc and did not start on Alpine images. +- Replace the manual e2e test with automated e2e tests on testcontainers-go. +- Replace Travis CI with GitHub Actions: lint, unit tests, and e2e tests on PHP 8.2–8.5. + +### Fixed + +- The wrapper exits with an error when `--log-encoder` has an unknown value. Before, it panicked. + ## [1.0.1] - 2025-01-10 ### Fixed diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..3dc9ff8 --- /dev/null +++ b/Makefile @@ -0,0 +1,11 @@ +.PHONY: lint test e2e + +lint: + golangci-lint run --build-tags e2e ./... + +test: + go test -race ./... + +# The e2e tests need Docker. Set E2E_PHP_IMAGE to use a different php-fpm image. +e2e: + go test -race -tags e2e ./e2e/... diff --git a/README.md b/README.md index 27d68ee..f5b2a40 100644 --- a/README.md +++ b/README.md @@ -63,3 +63,18 @@ The following options can be configured via command-line flags or environment va - **--shutdown-delay** - *Description:* Delay before the process shutdown to allow graceful termination. - *Default:* `500ms` + +## Development + +Run the checks before you push: + +- `make lint` runs `golangci-lint`. +- `make test` runs the unit tests. +- `make e2e` runs the end-to-end tests. These tests need Docker. + +The end-to-end tests start the wrapper with php-fpm in a container (testcontainers-go). +The default image is `php:8.5-fpm-alpine`. To use a different image, set `E2E_PHP_IMAGE`: + +```sh +E2E_PHP_IMAGE=php:8.2-fpm make e2e +``` From ed82a4b1adc1742c28f334d81945637f55620d61 Mon Sep 17 00:00:00 2001 From: asp24 <488588+asp24@users.noreply.github.com> Date: Wed, 21 Oct 2026 21:20:48 +0200 Subject: [PATCH 06/18] Apply the shutdown delay to SIGQUIT --- CHANGELOG.md | 1 + README.md | 2 +- e2e/shutdown_test.go | 89 +++++++++++++++++++++++++------------------ pkg/phpfpm/process.go | 5 ++- 4 files changed, 56 insertions(+), 41 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b4325ad..d291df1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ and this project adheres to [Semantic Versioning](http://semver.org). ### Fixed +- `--shutdown-delay` also applies to SIGQUIT. The php-fpm images have `STOPSIGNAL SIGQUIT`, so Docker and Kubernetes stopped php-fpm without the delay. - The wrapper exits with an error when `--log-encoder` has an unknown value. Before, it panicked. ## [1.0.1] - 2025-01-10 diff --git a/README.md b/README.md index f5b2a40..0872333 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,7 @@ The following options can be configured via command-line flags or environment va - *Default:* `/metrics` - **--shutdown-delay** - - *Description:* Delay before the process shutdown to allow graceful termination. + - *Description:* Delay before the process shutdown to allow graceful termination. The wrapper waits on SIGTERM and SIGQUIT, then sends SIGQUIT to php-fpm. - *Default:* `500ms` ## Development diff --git a/e2e/shutdown_test.go b/e2e/shutdown_test.go index ceb79a3..5ba565e 100644 --- a/e2e/shutdown_test.go +++ b/e2e/shutdown_test.go @@ -11,46 +11,59 @@ import ( "github.com/stretchr/testify/require" ) -// TestGracefulShutdown sends SIGTERM to the wrapper while php-fpm serves a request. +// TestGracefulShutdown sends a stop signal to the wrapper while php-fpm serves a request. // The wrapper must keep php-fpm running for --shutdown-delay, then stop it gracefully. func TestGracefulShutdown(t *testing.T) { t.Parallel() - const shutdownDelay = time.Second - w := startWrapper(t, "--shutdown-delay", shutdownDelay.String()) - - var wg sync.WaitGroup - - var slowBody string - var slowErr error - wg.Go(func() { - slowBody, slowErr = w.request("sleep.php", "seconds=3") - }) - - // The status request itself is one active process. - require.EventuallyWithT(t, func(c *assert.CollectT) { - assert.GreaterOrEqual(c, w.poolGauge(t.Context(), c, "phpfpm_active_processes", "www"), float64(2)) - }, eventuallyTimeout, eventuallyTick) - - // Do not use "docker stop": the php-fpm images have "STOPSIGNAL SIGQUIT". - stopStart := time.Now() - exitCode, _, err := w.ctr.Exec(t.Context(), []string{"sh", "-c", "kill -TERM 1"}) - require.NoError(t, err) - require.Equal(t, 0, exitCode) - - // php-fpm accepts new requests during the shutdown delay. - time.Sleep(shutdownDelay / 3) - assert.Equal(t, "ok", w.mustRequest(t, "index.php", "")) - - wg.Wait() - require.NoError(t, slowErr) - assert.Equal(t, "done", slowBody) - - require.EventuallyWithT(t, func(c *assert.CollectT) { - state, err := w.ctr.State(t.Context()) - require.NoError(c, err) - require.False(c, state.Running) - assert.Equal(c, 0, state.ExitCode) - }, eventuallyTimeout, eventuallyTick) - assert.GreaterOrEqual(t, time.Since(stopStart), shutdownDelay) + tests := []struct { + name string + signal string + }{ + {name: "SIGTERM", signal: "TERM"}, + // Docker and Kubernetes send the STOPSIGNAL of the image. The php-fpm images use SIGQUIT. + {name: "SIGQUIT", signal: "QUIT"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + const shutdownDelay = time.Second + w := startWrapper(t, "--shutdown-delay", shutdownDelay.String()) + + var wg sync.WaitGroup + + var slowBody string + var slowErr error + wg.Go(func() { + slowBody, slowErr = w.request("sleep.php", "seconds=3") + }) + + // The status request itself is one active process. + require.EventuallyWithT(t, func(c *assert.CollectT) { + assert.GreaterOrEqual(c, w.poolGauge(t.Context(), c, "phpfpm_active_processes", "www"), float64(2)) + }, eventuallyTimeout, eventuallyTick) + + stopStart := time.Now() + exitCode, _, err := w.ctr.Exec(t.Context(), []string{"sh", "-c", "kill -" + tt.signal + " 1"}) + require.NoError(t, err) + require.Equal(t, 0, exitCode) + + // php-fpm accepts new requests during the shutdown delay. + time.Sleep(shutdownDelay / 3) + assert.Equal(t, "ok", w.mustRequest(t, "index.php", "")) + + wg.Wait() + require.NoError(t, slowErr) + assert.Equal(t, "done", slowBody) + + require.EventuallyWithT(t, func(c *assert.CollectT) { + state, err := w.ctr.State(t.Context()) + require.NoError(c, err) + require.False(c, state.Running) + assert.Equal(c, 0, state.ExitCode) + }, eventuallyTimeout, eventuallyTick) + assert.GreaterOrEqual(t, time.Since(stopStart), shutdownDelay) + }) + } } diff --git a/pkg/phpfpm/process.go b/pkg/phpfpm/process.go index aa5bfed..9ccc16d 100644 --- a/pkg/phpfpm/process.go +++ b/pkg/phpfpm/process.go @@ -46,8 +46,9 @@ func (p *Process) HandleSignal(signalCh chan os.Signal) { for { sig := <-signalCh - // k8s graceful shutdown impl - if sig == syscall.SIGTERM { + // k8s graceful shutdown impl. + // Docker and Kubernetes send the STOPSIGNAL of the image. The php-fpm images use SIGQUIT. + if sig == syscall.SIGTERM || sig == syscall.SIGQUIT { if p.shutdownDelay > 0 { <-time.After(p.shutdownDelay) } From 692c622899d4cccbf3c5d97748f48cefdcabd66e Mon Sep 17 00:00:00 2001 From: asp24 <488588+asp24@users.noreply.github.com> Date: Wed, 21 Oct 2026 21:28:12 +0200 Subject: [PATCH 07/18] Pass unparsed error log lines through and keep parsing --- CHANGELOG.md | 1 + cmd/docker-fpm-wrapper/errlog.go | 4 +- cmd/docker-fpm-wrapper/main.go | 2 +- e2e/applog_test.go | 2 +- e2e/errlog_test.go | 7 ++- e2e/main_test.go | 4 +- e2e/metrics_test.go | 2 +- e2e/shutdown_test.go | 4 +- e2e/slowlog_test.go | 2 +- e2e/testdata/php-fpm.conf | 10 +++++ e2e/testdata/www/stderr.php | 2 +- e2e/wrapper_test.go | 29 +++++++----- pkg/phpfpm/errlog_parser.go | 36 +++++++++++---- pkg/phpfpm/errlog_parser_test.go | 77 ++++++++++++++++++++++++++++++++ 14 files changed, 151 insertions(+), 31 deletions(-) create mode 100644 pkg/phpfpm/errlog_parser_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index d291df1..7a58eca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ and this project adheres to [Semantic Versioning](http://semver.org). ### Fixed - `--shutdown-delay` also applies to SIGQUIT. The php-fpm images have `STOPSIGNAL SIGQUIT`, so Docker and Kubernetes stopped php-fpm without the delay. +- The error log proxy writes a line in an unknown format to stderr without a change and continues. Before, it stopped at the first such line, and all next entries of the error log were lost. Such lines come, for example, from a pool with `decorate_workers_output = no`. - The wrapper exits with an error when `--log-encoder` has an unknown value. Before, it panicked. ## [1.0.1] - 2025-01-10 diff --git a/cmd/docker-fpm-wrapper/errlog.go b/cmd/docker-fpm-wrapper/errlog.go index 2fca375..b59ab4d 100644 --- a/cmd/docker-fpm-wrapper/errlog.go +++ b/cmd/docker-fpm-wrapper/errlog.go @@ -10,7 +10,7 @@ import ( "github.com/code-tool/docker-fpm-wrapper/pkg/phpfpm" ) -func startErrLogProxy(ctx context.Context, log *zap.Logger, fPath string) error { +func startErrLogProxy(ctx context.Context, log *zap.Logger, unparsedOut io.Writer, fPath string) error { if fPath == "" { return nil } @@ -35,7 +35,7 @@ func startErrLogProxy(ctx context.Context, log *zap.Logger, fPath string) error } }() - logParser := phpfpm.NewErrLogParser() + logParser := phpfpm.NewErrLogParser(unparsedOut) go func() { if err := logParser.Parse(ctx, f, entryCh); err != nil { log.Error("can't parse php-fpm errorlog entry", zap.Error(err)) diff --git a/cmd/docker-fpm-wrapper/main.go b/cmd/docker-fpm-wrapper/main.go index 7e7989e..8ffa489 100644 --- a/cmd/docker-fpm-wrapper/main.go +++ b/cmd/docker-fpm-wrapper/main.go @@ -104,7 +104,7 @@ func main() { } if !cfg.FpmNoErrlogProxy && fpmConfig.ErrorLog != "syslog" { - if err := startErrLogProxy(ctx, log.Named("php-fpm"), fpmConfig.ErrorLog); err != nil { + if err := startErrLogProxy(ctx, log.Named("php-fpm"), syncStderr, fpmConfig.ErrorLog); err != nil { log.Error("can't start err_log proxy", zap.String("path", fpmConfig.ErrorLog), zap.Error(err)) os.Exit(1) } diff --git a/e2e/applog_test.go b/e2e/applog_test.go index 277a966..5f48b19 100644 --- a/e2e/applog_test.go +++ b/e2e/applog_test.go @@ -15,7 +15,7 @@ func TestAppLogProxy(t *testing.T) { w := startWrapper(t) - assert.Equal(t, "ok", w.mustRequest(t, "applog.php", "")) + assert.Equal(t, "ok", w.mustRequest(t, wwwPort, "applog.php", "")) tests := []struct { name string diff --git a/e2e/errlog_test.go b/e2e/errlog_test.go index 2f279a5..8cc8820 100644 --- a/e2e/errlog_test.go +++ b/e2e/errlog_test.go @@ -15,7 +15,12 @@ func TestErrorLogProxy(t *testing.T) { w := startWrapper(t) - assert.Equal(t, "ok", w.mustRequest(t, "stderr.php", "")) + // The "raw" pool writes a line in an unknown format to the error log before the "www" pool + // writes its line. The wrapper must write the raw line without a change and keep parsing. + assert.Equal(t, "ok", w.mustRequest(t, rawPort, "stderr.php", "message=raw+stderr+line")) + w.waitForLine(t, "raw stderr line") + + assert.Equal(t, "ok", w.mustRequest(t, wwwPort, "stderr.php", "")) tests := []struct { name string diff --git a/e2e/main_test.go b/e2e/main_test.go index fe83733..e3b6db1 100644 --- a/e2e/main_test.go +++ b/e2e/main_test.go @@ -25,7 +25,9 @@ const ( invalidFpmConfigPath = "/usr/local/etc/php-fpm-e2e-invalid.conf" scriptDir = "/var/www/html" - fcgiPort = "9000/tcp" + // wwwPort and rawPort are the FastCGI ports of the pools "www" and "raw". + wwwPort = "9000/tcp" + rawPort = "9001/tcp" metricsPort = "8080/tcp" eventuallyTimeout = 10 * time.Second diff --git a/e2e/metrics_test.go b/e2e/metrics_test.go index e1ac38c..fa27d96 100644 --- a/e2e/metrics_test.go +++ b/e2e/metrics_test.go @@ -16,7 +16,7 @@ func TestMetrics(t *testing.T) { const requests = 3 for range requests { - assert.Equal(t, "ok", w.mustRequest(t, "index.php", "")) + assert.Equal(t, "ok", w.mustRequest(t, wwwPort, "index.php", "")) } // The status requests of the wrapper also increase the counter. diff --git a/e2e/shutdown_test.go b/e2e/shutdown_test.go index 5ba565e..9d116f2 100644 --- a/e2e/shutdown_test.go +++ b/e2e/shutdown_test.go @@ -36,7 +36,7 @@ func TestGracefulShutdown(t *testing.T) { var slowBody string var slowErr error wg.Go(func() { - slowBody, slowErr = w.request("sleep.php", "seconds=3") + slowBody, slowErr = w.request(wwwPort, "sleep.php", "seconds=3") }) // The status request itself is one active process. @@ -51,7 +51,7 @@ func TestGracefulShutdown(t *testing.T) { // php-fpm accepts new requests during the shutdown delay. time.Sleep(shutdownDelay / 3) - assert.Equal(t, "ok", w.mustRequest(t, "index.php", "")) + assert.Equal(t, "ok", w.mustRequest(t, wwwPort, "index.php", "")) wg.Wait() require.NoError(t, slowErr) diff --git a/e2e/slowlog_test.go b/e2e/slowlog_test.go index 2ad00a5..cc99a7e 100644 --- a/e2e/slowlog_test.go +++ b/e2e/slowlog_test.go @@ -14,7 +14,7 @@ func TestSlowlogProxy(t *testing.T) { w := startWrapper(t) - assert.Equal(t, "ok", w.mustRequest(t, "slow.php", "")) + assert.Equal(t, "ok", w.mustRequest(t, wwwPort, "slow.php", "")) entry := w.waitForEntry(t, func(e logEntry) bool { return e.Message == "slowlog" diff --git a/e2e/testdata/php-fpm.conf b/e2e/testdata/php-fpm.conf index 41a8d19..2cbeea7 100644 --- a/e2e/testdata/php-fpm.conf +++ b/e2e/testdata/php-fpm.conf @@ -20,3 +20,13 @@ request_slowlog_timeout = 1 ; Keep the environment of the wrapper: it contains FPM_WRAPPER_SOCK and FPM_WRAPPER_PIPE. clear_env = no catch_workers_output = yes + +; A pool that writes the stderr of the workers to the error log without a change. +[raw] +listen = 9001 +pm = static +pm.max_children = 1 +pm.status_path = /status +clear_env = no +catch_workers_output = yes +decorate_workers_output = no diff --git a/e2e/testdata/www/stderr.php b/e2e/testdata/www/stderr.php index 2ade869..c59c754 100644 --- a/e2e/testdata/www/stderr.php +++ b/e2e/testdata/www/stderr.php @@ -5,7 +5,7 @@ // php-fpm catches the stderr of the worker and writes it to the error log. $stderr = fopen('php://stderr', 'wb'); -fwrite($stderr, "worker stderr line\n"); +fwrite($stderr, ($_GET['message'] ?? 'worker stderr line') . "\n"); fclose($stderr); echo 'ok'; diff --git a/e2e/wrapper_test.go b/e2e/wrapper_test.go index 39a16ef..7737a03 100644 --- a/e2e/wrapper_test.go +++ b/e2e/wrapper_test.go @@ -57,7 +57,7 @@ func runContainer(t *testing.T, strategy wait.Strategy, logs testcontainers.LogC testcontainers.WithFiles(containerFiles(t)...), testcontainers.WithEntrypoint(wrapperPath), testcontainers.WithCmd(args...), - testcontainers.WithExposedPorts(fcgiPort, metricsPort), + testcontainers.WithExposedPorts(wwwPort, rawPort, metricsPort), testcontainers.WithConfigModifier(func(config *container.Config) { config.User = "www-data" }), @@ -75,7 +75,7 @@ type wrapper struct { ctr *testcontainers.DockerContainer logs *logCollector - fcgiAddr string + fcgiAddrs map[string]string // the host address of each FastCGI port metricsURL string } @@ -86,25 +86,32 @@ func startWrapper(t *testing.T, extraArgs ...string) *wrapper { args := append([]string{"--fpm", fpmPath, "--fpm-config", fpmConfigPath, "--listen", ":8080"}, extraArgs...) strategy := wait.ForAll( - wait.ForListeningPort(fcgiPort), + wait.ForListeningPort(wwwPort), + wait.ForListeningPort(rawPort), wait.ForHTTP("/metrics").WithPort(metricsPort), ) logs := newLogCollector() ctr := runContainer(t, strategy, logs, args...) - fcgiAddr, err := ctr.PortEndpoint(t.Context(), fcgiPort, "") - require.NoError(t, err) + fcgiAddrs := make(map[string]string) + for _, port := range []string{wwwPort, rawPort} { + addr, err := ctr.PortEndpoint(t.Context(), port, "") + require.NoError(t, err) + + fcgiAddrs[port] = addr + } metricsEndpoint, err := ctr.PortEndpoint(t.Context(), metricsPort, "http") require.NoError(t, err) - return &wrapper{ctr: ctr, logs: logs, fcgiAddr: fcgiAddr, metricsURL: metricsEndpoint + "/metrics"} + return &wrapper{ctr: ctr, logs: logs, fcgiAddrs: fcgiAddrs, metricsURL: metricsEndpoint + "/metrics"} } -// request sends a FastCGI GET request for a script in scriptDir and returns the body of the response. -func (w *wrapper) request(script, query string) (string, error) { - client, err := fcgiclient.DialTimeout("tcp", w.fcgiAddr, time.Second) +// request sends a FastCGI GET request for a script in scriptDir to the pool on port and returns +// the body of the response. +func (w *wrapper) request(port, script, query string) (string, error) { + client, err := fcgiclient.DialTimeout("tcp", w.fcgiAddrs[port], time.Second) if err != nil { return "", fmt.Errorf("dial php-fpm: %w", err) } @@ -128,10 +135,10 @@ func (w *wrapper) request(script, query string) (string, error) { return string(body), nil } -func (w *wrapper) mustRequest(t *testing.T, script, query string) string { +func (w *wrapper) mustRequest(t *testing.T, port, script, query string) string { t.Helper() - body, err := w.request(script, query) + body, err := w.request(port, script, query) require.NoError(t, err) return body diff --git a/pkg/phpfpm/errlog_parser.go b/pkg/phpfpm/errlog_parser.go index 9ff3ff2..52c4e9f 100644 --- a/pkg/phpfpm/errlog_parser.go +++ b/pkg/phpfpm/errlog_parser.go @@ -19,21 +19,21 @@ type ErrLogEntry struct { } type ErrLogParser struct { - // + unparsedOut io.Writer } -func NewErrLogParser() *ErrLogParser { - return &ErrLogParser{} +// NewErrLogParser returns a parser of the php-fpm error log. The parser writes the lines that +// are not in the php-fpm format to unparsedOut without a change. Such lines come, for example, +// from a pool with "decorate_workers_output = no". +func NewErrLogParser(unparsedOut io.Writer) *ErrLogParser { + return &ErrLogParser{unparsedOut: unparsedOut} } var errLogEntryRegexp = regexp.MustCompile(`^\[([^]]+)]\s+(ALERT|ERROR|WARNING|NOTICE|DEBUG):\s+([^\n]+)\n$`) -func (p *ErrLogParser) ParseOne(r *bufio.Reader) (ErrLogEntry, error) { +func (p *ErrLogParser) parseLine(buf []byte) (ErrLogEntry, error) { + var err error result := ErrLogEntry{} - buf, err := line.ReadOne(r, false) - if err != nil { - return result, err - } matches := errLogEntryRegexp.FindSubmatchIndex(buf) if len(matches) == 0 { @@ -51,15 +51,33 @@ func (p *ErrLogParser) ParseOne(r *bufio.Reader) (ErrLogEntry, error) { return result, nil } +func (p *ErrLogParser) ParseOne(r *bufio.Reader) (ErrLogEntry, error) { + buf, err := line.ReadOne(r, false) + if err != nil { + return ErrLogEntry{}, err + } + + return p.parseLine(buf) +} + func (p *ErrLogParser) Parse(ctx context.Context, r io.Reader, out chan ErrLogEntry) error { bufioReader := bufio.NewReader(r) for { - entry, err := p.ParseOne(bufioReader) + buf, err := line.ReadOne(bufioReader, false) if err != nil { return err } + entry, err := p.parseLine(buf) + if err != nil { + if _, err := p.unparsedOut.Write(buf); err != nil { + return fmt.Errorf("write unparsed line: %w", err) + } + + continue + } + select { case <-ctx.Done(): return nil diff --git a/pkg/phpfpm/errlog_parser_test.go b/pkg/phpfpm/errlog_parser_test.go new file mode 100644 index 0000000..c83bb7c --- /dev/null +++ b/pkg/phpfpm/errlog_parser_test.go @@ -0,0 +1,77 @@ +package phpfpm + +import ( + "bytes" + "io" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestErrLogParser_Parse(t *testing.T) { + ready := ErrLogEntry{ + CreatedAt: time.Date(2026, time.October, 21, 19, 21, 13, 0, time.UTC), + Level: LogLevelNotice, + Message: "ready to handle connections", + } + childSaid := ErrLogEntry{ + CreatedAt: time.Date(2026, time.October, 21, 19, 21, 15, 0, time.UTC), + Level: LogLevelWarning, + Message: `[pool www] child 17 said into stderr: "worker stderr line"`, + } + + tests := []struct { + name string + input string + wantEntries []ErrLogEntry + wantUnparsed string + }{ + { + name: "php-fpm lines", + input: "[21-Oct-2026 19:21:13] NOTICE: ready to handle connections\n" + + "[21-Oct-2026 19:21:15] WARNING: [pool www] child 17 said into stderr: \"worker stderr line\"\n", + wantEntries: []ErrLogEntry{ready, childSaid}, + }, + { + name: "raw worker line between php-fpm lines", + input: "[21-Oct-2026 19:21:13] NOTICE: ready to handle connections\n" + + "{\"message\":\"raw worker line\"}\n" + + "[21-Oct-2026 19:21:15] WARNING: [pool www] child 17 said into stderr: \"worker stderr line\"\n", + wantEntries: []ErrLogEntry{ready, childSaid}, + wantUnparsed: "{\"message\":\"raw worker line\"}\n", + }, + { + name: "line with invalid timestamp", + input: "[not a time] NOTICE: ready to handle connections\n" + + "[21-Oct-2026 19:21:13] NOTICE: ready to handle connections\n", + wantEntries: []ErrLogEntry{ready}, + wantUnparsed: "[not a time] NOTICE: ready to handle connections\n", + }, + { + name: "line with unknown level", + input: "[21-Oct-2026 19:21:13] TRACE: ready to handle connections\n", + wantUnparsed: "[21-Oct-2026 19:21:13] TRACE: ready to handle connections\n", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var unparsed bytes.Buffer + parser := NewErrLogParser(&unparsed) + + out := make(chan ErrLogEntry, 10) + err := parser.Parse(t.Context(), strings.NewReader(tt.input), out) + close(out) + + assert.ErrorIs(t, err, io.EOF) + + var entries []ErrLogEntry + for entry := range out { + entries = append(entries, entry) + } + assert.Equal(t, tt.wantEntries, entries) + assert.Equal(t, tt.wantUnparsed, unparsed.String()) + }) + } +} From 30e382eb2698cdd132f603cc2a8b976a3dabf9a4 Mon Sep 17 00:00:00 2001 From: asp24 <488588+asp24@users.noreply.github.com> Date: Wed, 21 Oct 2026 21:29:50 +0200 Subject: [PATCH 08/18] Set explicit FIFO modes so that workers can write to the wrapper pipe --- CHANGELOG.md | 1 + cmd/docker-fpm-wrapper/errlog.go | 2 +- cmd/docker-fpm-wrapper/main.go | 2 +- cmd/docker-fpm-wrapper/pipe.go | 20 ++++++++-- cmd/docker-fpm-wrapper/pipe_test.go | 57 +++++++++++++++++++++++++++++ cmd/docker-fpm-wrapper/slowlog.go | 2 +- e2e/applog_test.go | 54 +++++++++++++++++---------- e2e/startfailure_test.go | 2 +- e2e/testdata/php-fpm.conf | 5 +++ e2e/wrapper_test.go | 22 +++++++---- 10 files changed, 132 insertions(+), 35 deletions(-) create mode 100644 cmd/docker-fpm-wrapper/pipe_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a58eca..b3671c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ and this project adheres to [Semantic Versioning](http://semver.org). - `--shutdown-delay` also applies to SIGQUIT. The php-fpm images have `STOPSIGNAL SIGQUIT`, so Docker and Kubernetes stopped php-fpm without the delay. - The error log proxy writes a line in an unknown format to stderr without a change and continues. Before, it stopped at the first such line, and all next entries of the error log were lost. Such lines come, for example, from a pool with `decorate_workers_output = no`. +- The wrapper pipe (`FPM_WRAPPER_PIPE`) has mode `0666` for all umask values. Before, the umask gave `0644`, and workers that run as a different user than the master could not write to the pipe. - The wrapper exits with an error when `--log-encoder` has an unknown value. Before, it panicked. ## [1.0.1] - 2025-01-10 diff --git a/cmd/docker-fpm-wrapper/errlog.go b/cmd/docker-fpm-wrapper/errlog.go index b59ab4d..12985a1 100644 --- a/cmd/docker-fpm-wrapper/errlog.go +++ b/cmd/docker-fpm-wrapper/errlog.go @@ -15,7 +15,7 @@ func startErrLogProxy(ctx context.Context, log *zap.Logger, unparsedOut io.Write return nil } - f, err := createFIFOByPathCtx(ctx, fPath) + f, err := createFIFOByPathCtx(ctx, fPath, fpmLogPipePerm) if err != nil { return err } diff --git a/cmd/docker-fpm-wrapper/main.go b/cmd/docker-fpm-wrapper/main.go index 8ffa489..5c342bb 100644 --- a/cmd/docker-fpm-wrapper/main.go +++ b/cmd/docker-fpm-wrapper/main.go @@ -88,7 +88,7 @@ func main() { if cfg.WrapperPipe != "" { env = append(env, fmt.Sprintf("FPM_WRAPPER_PIPE=%s", cfg.WrapperPipe)) - wrapperPipe, err := createFIFOByPathCtx(ctx, cfg.WrapperPipe) + wrapperPipe, err := createFIFOByPathCtx(ctx, cfg.WrapperPipe, wrapperPipePerm) if err != nil { log.Error("can't create pipe", zap.Error(err), zap.String("path", cfg.WrapperPipe)) os.Exit(1) diff --git a/cmd/docker-fpm-wrapper/pipe.go b/cmd/docker-fpm-wrapper/pipe.go index 47ccbd7..5c43929 100644 --- a/cmd/docker-fpm-wrapper/pipe.go +++ b/cmd/docker-fpm-wrapper/pipe.go @@ -8,15 +8,27 @@ import ( "golang.org/x/sys/unix" ) -func createFIFOByPath(fPath string) (*os.File, error) { +const ( + // The php-fpm workers write to the wrapper pipe. They can run as a different user than the wrapper. + wrapperPipePerm os.FileMode = 0o666 + // Only the php-fpm master writes to the error log and the slowlog. + fpmLogPipePerm os.FileMode = 0o644 +) + +// createFIFOByPath creates a FIFO with perm. The umask of the process does not change perm. +func createFIFOByPath(fPath string, perm os.FileMode) (*os.File, error) { if err := os.Remove(fPath); err != nil && !os.IsNotExist(err) { return nil, fmt.Errorf("can't remove file: %w", err) } - if err := unix.Mkfifo(fPath, 0666); err != nil { + if err := unix.Mkfifo(fPath, uint32(perm)); err != nil { return nil, fmt.Errorf("can't create linux pipe: %w", err) } + if err := os.Chmod(fPath, perm); err != nil { + return nil, fmt.Errorf("can't change pipe mode: %w", err) + } + fifoF, err := os.OpenFile(fPath, os.O_RDWR, os.ModeNamedPipe) if err != nil { return nil, fmt.Errorf("can't open pipe: %w", err) @@ -25,8 +37,8 @@ func createFIFOByPath(fPath string) (*os.File, error) { return fifoF, nil } -func createFIFOByPathCtx(ctx context.Context, fPath string) (*os.File, error) { - f, err := createFIFOByPath(fPath) +func createFIFOByPathCtx(ctx context.Context, fPath string, perm os.FileMode) (*os.File, error) { + f, err := createFIFOByPath(fPath, perm) if err != nil { return nil, err } diff --git a/cmd/docker-fpm-wrapper/pipe_test.go b/cmd/docker-fpm-wrapper/pipe_test.go new file mode 100644 index 0000000..d0b3647 --- /dev/null +++ b/cmd/docker-fpm-wrapper/pipe_test.go @@ -0,0 +1,57 @@ +package main + +import ( + "os" + "path/filepath" + "syscall" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestCreateFIFOByPath changes the umask of the process, so it does not run in parallel. +func TestCreateFIFOByPath(t *testing.T) { + tests := []struct { + name string + umask int + perm os.FileMode + }{ + {name: "wrapper pipe with default umask", umask: 0o022, perm: wrapperPipePerm}, + {name: "wrapper pipe with strict umask", umask: 0o077, perm: wrapperPipePerm}, + {name: "fpm log pipe with strict umask", umask: 0o077, perm: fpmLogPipePerm}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + oldUmask := syscall.Umask(tt.umask) + t.Cleanup(func() { syscall.Umask(oldUmask) }) + + fPath := filepath.Join(t.TempDir(), "pipe") + f, err := createFIFOByPath(fPath, tt.perm) + require.NoError(t, err) + t.Cleanup(func() { _ = f.Close() }) + + info, err := os.Stat(fPath) + require.NoError(t, err) + assert.Equal(t, os.ModeNamedPipe|tt.perm, info.Mode()) + }) + } + + t.Run("existing file is replaced", func(t *testing.T) { + fPath := filepath.Join(t.TempDir(), "pipe") + require.NoError(t, os.WriteFile(fPath, []byte("data"), 0o600)) + + f, err := createFIFOByPath(fPath, wrapperPipePerm) + require.NoError(t, err) + t.Cleanup(func() { _ = f.Close() }) + + info, err := os.Stat(fPath) + require.NoError(t, err) + assert.Equal(t, os.ModeNamedPipe|wrapperPipePerm, info.Mode()) + }) + + t.Run("missing directory is an error", func(t *testing.T) { + _, err := createFIFOByPath(filepath.Join(t.TempDir(), "missing", "pipe"), wrapperPipePerm) + assert.ErrorContains(t, err, "can't create linux pipe") + }) +} diff --git a/cmd/docker-fpm-wrapper/slowlog.go b/cmd/docker-fpm-wrapper/slowlog.go index 03605d5..015812e 100644 --- a/cmd/docker-fpm-wrapper/slowlog.go +++ b/cmd/docker-fpm-wrapper/slowlog.go @@ -11,7 +11,7 @@ import ( ) func startSlowlogProxyForPool(ctx context.Context, log *zap.Logger, pool phpfpm.Pool, out chan phpfpm.SlowlogEntry) error { - fifoF, err := createFIFOByPathCtx(ctx, pool.SlowlogPath) + fifoF, err := createFIFOByPathCtx(ctx, pool.SlowlogPath, fpmLogPipePerm) if err != nil { return err } diff --git a/e2e/applog_test.go b/e2e/applog_test.go index 5f48b19..22729fd 100644 --- a/e2e/applog_test.go +++ b/e2e/applog_test.go @@ -13,29 +13,43 @@ import ( func TestAppLogProxy(t *testing.T) { t.Parallel() - w := startWrapper(t) - - assert.Equal(t, "ok", w.mustRequest(t, wwwPort, "applog.php", "")) - - tests := []struct { + users := []struct { name string - line string + user string }{ - {name: "socket line", line: "socket line"}, - {name: "socket line with crlf has lf only", line: "socket crlf line"}, - {name: "socket line without newline gets newline", line: "socket line without newline"}, - {name: "socket line after a dropped long line", line: "socket line after long line"}, - {name: "pipe line", line: "pipe line"}, + {name: "all processes run as www-data", user: "www-data"}, + // The workers must be able to write to the socket and the pipe that root created. + {name: "master runs as root, workers run as www-data", user: "root"}, } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - w.waitForLine(t, tt.line) + for _, u := range users { + t.Run(u.name, func(t *testing.T) { + t.Parallel() + + w := startWrapperAs(t, u.user) + + assert.Equal(t, "ok", w.mustRequest(t, wwwPort, "applog.php", "")) + + tests := []struct { + name string + line string + }{ + {name: "socket line", line: "socket line"}, + {name: "socket line with crlf has lf only", line: "socket crlf line"}, + {name: "socket line without newline gets newline", line: "socket line without newline"}, + {name: "socket line after a dropped long line", line: "socket line after long line"}, + {name: "pipe line", line: "pipe line"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + w.waitForLine(t, tt.line) + }) + } + + t.Run("long line is dropped", func(t *testing.T) { + for _, line := range w.logs.Lines() { + assert.NotContains(t, line, strings.Repeat("x", 1024)) + } + }) }) } - - t.Run("long line is dropped", func(t *testing.T) { - for _, line := range w.logs.Lines() { - assert.NotContains(t, line, strings.Repeat("x", 1024)) - } - }) } diff --git a/e2e/startfailure_test.go b/e2e/startfailure_test.go index ac7384a..73ba000 100644 --- a/e2e/startfailure_test.go +++ b/e2e/startfailure_test.go @@ -66,7 +66,7 @@ func TestStartFailure(t *testing.T) { t.Run(tt.name, func(t *testing.T) { t.Parallel() - ctr := runContainer(t, wait.ForExit(), newLogCollector(), tt.args...) + ctr := runContainer(t, "www-data", wait.ForExit(), newLogCollector(), tt.args...) state, err := ctr.State(t.Context()) require.NoError(t, err) diff --git a/e2e/testdata/php-fpm.conf b/e2e/testdata/php-fpm.conf index 2cbeea7..30adc98 100644 --- a/e2e/testdata/php-fpm.conf +++ b/e2e/testdata/php-fpm.conf @@ -8,6 +8,9 @@ log_level = notice process_control_timeout = 10s [www] +; php-fpm ignores user and group when the master does not run as root. +user = www-data +group = www-data listen = 9000 pm = static pm.max_children = 2 @@ -23,6 +26,8 @@ catch_workers_output = yes ; A pool that writes the stderr of the workers to the error log without a change. [raw] +user = www-data +group = www-data listen = 9001 pm = static pm.max_children = 1 diff --git a/e2e/wrapper_test.go b/e2e/wrapper_test.go index 7737a03..e32b72c 100644 --- a/e2e/wrapper_test.go +++ b/e2e/wrapper_test.go @@ -48,9 +48,10 @@ func containerFiles(t *testing.T) []testcontainers.ContainerFile { return files } -// runContainer starts the wrapper as the entrypoint of a php-fpm container. The container runs -// as www-data, as a container in Kubernetes usually does. -func runContainer(t *testing.T, strategy wait.Strategy, logs testcontainers.LogConsumer, args ...string) *testcontainers.DockerContainer { +// runContainer starts the wrapper as the entrypoint of a php-fpm container that runs as user. +func runContainer( + t *testing.T, user string, strategy wait.Strategy, logs testcontainers.LogConsumer, args ...string, +) *testcontainers.DockerContainer { t.Helper() ctr, err := testcontainers.Run(t.Context(), phpImage(), @@ -59,7 +60,7 @@ func runContainer(t *testing.T, strategy wait.Strategy, logs testcontainers.LogC testcontainers.WithCmd(args...), testcontainers.WithExposedPorts(wwwPort, rawPort, metricsPort), testcontainers.WithConfigModifier(func(config *container.Config) { - config.User = "www-data" + config.User = user }), testcontainers.WithLogConsumers(logs), testcontainers.WithWaitStrategy(strategy), @@ -79,11 +80,18 @@ type wrapper struct { metricsURL string } -// startWrapper starts the wrapper with the e2e php-fpm config and waits until php-fpm and the -// metrics endpoint are ready. The extra arguments go after the default arguments. +// startWrapper starts the wrapper as www-data, as a container in Kubernetes usually runs. func startWrapper(t *testing.T, extraArgs ...string) *wrapper { t.Helper() + return startWrapperAs(t, "www-data", extraArgs...) +} + +// startWrapperAs starts the wrapper as user with the e2e php-fpm config and waits until php-fpm +// and the metrics endpoint are ready. The extra arguments go after the default arguments. +func startWrapperAs(t *testing.T, user string, extraArgs ...string) *wrapper { + t.Helper() + args := append([]string{"--fpm", fpmPath, "--fpm-config", fpmConfigPath, "--listen", ":8080"}, extraArgs...) strategy := wait.ForAll( wait.ForListeningPort(wwwPort), @@ -92,7 +100,7 @@ func startWrapper(t *testing.T, extraArgs ...string) *wrapper { ) logs := newLogCollector() - ctr := runContainer(t, strategy, logs, args...) + ctr := runContainer(t, user, strategy, logs, args...) fcgiAddrs := make(map[string]string) for _, port := range []string{wwwPort, rawPort} { From 8d8a4ac6d9091c3daea4e12c5551c13be0d4665c Mon Sep 17 00:00:00 2001 From: asp24 <488588+asp24@users.noreply.github.com> Date: Wed, 21 Oct 2026 21:39:14 +0200 Subject: [PATCH 09/18] Read the PHP directives of the pools --- pkg/phpfpm/config.go | 33 +++++++++++++++++++++++++- pkg/phpfpm/config_test.go | 5 ++++ pkg/phpfpm/testdata/php-fpm.d/www.conf | 4 ++++ 3 files changed, 41 insertions(+), 1 deletion(-) diff --git a/pkg/phpfpm/config.go b/pkg/phpfpm/config.go index 51bbdfd..c848047 100644 --- a/pkg/phpfpm/config.go +++ b/pkg/phpfpm/config.go @@ -25,6 +25,34 @@ type Pool struct { SlowlogPath string RequestSlowlogTimeout int RequestSlowlogTraceDepth int + // PhpValues holds the php_value, php_flag, php_admin_value, and php_admin_flag directives. + PhpValues map[string]string +} + +var phpValueKeyRegexp = regexp.MustCompile(`^php(_admin)?_(?:value|flag)\[(.+)]$`) + +// parsePhpValues returns the PHP directives of the pool section. An admin directive takes +// precedence over a directive with the same name, as in php-fpm. +func parsePhpValues(section *ini.Section) map[string]string { + values := make(map[string]string) + isAdmin := make(map[string]bool) + + for _, key := range section.Keys() { + matches := phpValueKeyRegexp.FindStringSubmatch(key.Name()) + if matches == nil { + continue + } + + name, admin := matches[2], matches[1] != "" + if isAdmin[name] && !admin { + continue + } + + values[name] = key.String() + isAdmin[name] = admin + } + + return values } func fillPull(config *Config, iniConfig *ini.File, poolName string) error { @@ -68,13 +96,16 @@ func fillPull(config *Config, iniConfig *ini.File, poolName string) error { pool.RequestSlowlogTraceDepth, _ = strconv.Atoi(key.String()) } + pool.PhpValues = parsePhpValues(section) + config.Pools = append(config.Pools, pool) return nil } func ParseConfig(fpmConfigPath string) (Config, error) { c := Config{} - cfg := ini.Empty() + // A ";" after a space starts a comment. A ";" in a value stays, for example "2;/var/lib/php/sessions". + cfg := ini.Empty(ini.LoadOptions{SpaceBeforeInlineComment: true}) err := cfg.Append(fpmConfigPath) if err != nil { return c, err diff --git a/pkg/phpfpm/config_test.go b/pkg/phpfpm/config_test.go index fc26c2d..e5caad4 100644 --- a/pkg/phpfpm/config_test.go +++ b/pkg/phpfpm/config_test.go @@ -24,4 +24,9 @@ func TestParse(t *testing.T) { assert.Equal(t, "/run/php-fpm/php-fpm.sock", c.Pools[0].Listen) assert.Equal(t, "/status", c.Pools[0].StatusPath) assert.Equal(t, "log/www.log.slow", c.Pools[0].SlowlogPath) + assert.Equal(t, map[string]string{ + "session.save_path": "2;/var/lib/php/sessions", + "session.gc_maxlifetime": "3600", + "log_errors": "on", + }, c.Pools[0].PhpValues) } diff --git a/pkg/phpfpm/testdata/php-fpm.d/www.conf b/pkg/phpfpm/testdata/php-fpm.d/www.conf index f0dbf74..590ca4d 100644 --- a/pkg/phpfpm/testdata/php-fpm.d/www.conf +++ b/pkg/phpfpm/testdata/php-fpm.d/www.conf @@ -411,3 +411,7 @@ request_slowlog_timeout = 1 ;php_admin_value[error_log] = /var/log/fpm-php.www.log ;php_admin_flag[log_errors] = on ;php_admin_value[memory_limit] = 32M +php_value[session.save_path] = "2;/var/lib/php/sessions" +php_value[session.gc_maxlifetime] = 60 +php_admin_value[session.gc_maxlifetime] = 3600 +php_admin_flag[log_errors] = on ; comment From ce4143f78460f9b50e9e03dcd2a527b911d686b2 Mon Sep 17 00:00:00 2001 From: asp24 <488588+asp24@users.noreply.github.com> Date: Wed, 21 Oct 2026 21:39:16 +0200 Subject: [PATCH 10/18] Rewrite the session cleaner and enable it with --session-cleanup-interval --- CHANGELOG.md | 4 + README.md | 18 +++ cmd/docker-fpm-wrapper/config.go | 4 + cmd/docker-fpm-wrapper/main.go | 22 ++- cmd/docker-fpm-wrapper/session.go | 45 ++++++ e2e/session_test.go | 55 +++++++ e2e/testdata/php-fpm.conf | 6 + e2e/testdata/www/session.php | 17 ++ e2e/wrapper_test.go | 13 ++ internal/session/cleaner.go | 253 ++++++++++++------------------ internal/session/cleaner_test.go | 159 +++++++++++++++++++ internal/session/config.go | 84 +++++----- internal/session/config_test.go | 81 ++++++++++ internal/session/phpinfo.go | 57 +++++++ internal/session/phpinfo_test.go | 96 ++++++++++++ internal/session/pidof.go | 35 ----- internal/session/sapi.go | 22 --- internal/session/target.go | 49 ++++++ internal/session/target_test.go | 76 +++++++++ pkg/phpfpm/process.go | 5 + 20 files changed, 839 insertions(+), 262 deletions(-) create mode 100644 cmd/docker-fpm-wrapper/session.go create mode 100644 e2e/session_test.go create mode 100644 e2e/testdata/www/session.php create mode 100644 internal/session/cleaner_test.go create mode 100644 internal/session/config_test.go create mode 100644 internal/session/phpinfo.go create mode 100644 internal/session/phpinfo_test.go delete mode 100644 internal/session/pidof.go delete mode 100644 internal/session/sapi.go create mode 100644 internal/session/target.go create mode 100644 internal/session/target_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index b3671c4..922e1da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,10 @@ and this project adheres to [Semantic Versioning](http://semver.org). ## [Unreleased] +### Added + +- `--session-cleanup-interval` removes the expired PHP session files, as the `sessionclean` cron job of Debian does. The cleanup is off by default. + ### Changed - The minimum Go version is 1.27. Update all dependencies. diff --git a/README.md b/README.md index 0872333..40fa231 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ This project wraps around PHP-FPM, intercepting its logs and exposing Prometheus - **Logging Proxy:** Configure Unix pipe or socket for app log forwarding. - **Prometheus Monitoring:** Exposes application metrics to help monitor application performance. - **Graceful Shutdown:** Implements a delay mechanism to ensure smooth shutdowns in Kubernetes environments. +- **Session Cleanup:** Removes expired PHP session files, as the `sessionclean` cron job of Debian does. ## Available Configuration Options @@ -64,6 +65,23 @@ The following options can be configured via command-line flags or environment va - *Description:* Delay before the process shutdown to allow graceful termination. The wrapper waits on SIGTERM and SIGQUIT, then sends SIGQUIT to php-fpm. - *Default:* `500ms` +- **--session-cleanup-interval** + - *Description:* Interval of the removal of expired session files. Set to `0` to disable. + - *Default:* `0` + +## Session Cleanup + +Debian packages of PHP set `session.gc_probability = 0` and remove the expired session files with a cron job. +A container usually has no cron. Set `--session-cleanup-interval` (for example, `30m`) to let the wrapper do this work: + +1. The wrapper reads the session directives from `php-fpm -i` and from the `php_value` and `php_admin_value` directives of each pool. +2. It cleans up the `session.save_path` directories of the pools with `session.save_handler = files`, also the subdirectories of `N;/path`. +3. It removes the `sess_*` files that are older than `session.gc_maxlifetime`. When two or more pools use one directory, the longest lifetime applies. +4. It does not remove the session files that php-fpm has open. It updates their modification time. + +Do not enable the cleanup when the application sets `session.gc_maxlifetime` with `ini_set()` at run time. +The wrapper cannot see this value and can remove live sessions. + ## Development Run the checks before you push: diff --git a/cmd/docker-fpm-wrapper/config.go b/cmd/docker-fpm-wrapper/config.go index 2de1935..d9abaf7 100644 --- a/cmd/docker-fpm-wrapper/config.go +++ b/cmd/docker-fpm-wrapper/config.go @@ -29,6 +29,8 @@ type Config struct { MetricsPath string `mapstructure:"metrics-path"` ShutdownDelay time.Duration `mapstructure:"shutdown-delay"` + + SessionCleanupInterval time.Duration `mapstructure:"session-cleanup-interval"` } func parseCommandLineFlags() { @@ -52,6 +54,8 @@ func parseCommandLineFlags() { pflag.Duration("shutdown-delay", 500*time.Millisecond, "Delay before process shutdown") + pflag.Duration("session-cleanup-interval", 0, "Interval of the removal of expired session files, 0 to disable") + pflag.Parse() } diff --git a/cmd/docker-fpm-wrapper/main.go b/cmd/docker-fpm-wrapper/main.go index 5c342bb..297b88f 100644 --- a/cmd/docker-fpm-wrapper/main.go +++ b/cmd/docker-fpm-wrapper/main.go @@ -16,6 +16,7 @@ import ( "github.com/code-tool/docker-fpm-wrapper/internal/applog" "github.com/code-tool/docker-fpm-wrapper/internal/breader" + "github.com/code-tool/docker-fpm-wrapper/internal/session" "github.com/code-tool/docker-fpm-wrapper/pkg/phpfpm" ) @@ -117,14 +118,33 @@ func main() { } } + fpmArgs := findFpmArgs() fpmProcess := phpfpm. - NewProcess(log, cfg.FpmPath, cfg.FpmConfigPath, os.Stdout, syncStderr, cfg.ShutdownDelay, env, findFpmArgs()...) + NewProcess(log, cfg.FpmPath, cfg.FpmConfigPath, os.Stdout, syncStderr, cfg.ShutdownDelay, env, fpmArgs...) + + var sessionCleaner *session.Cleaner + if cfg.SessionCleanupInterval > 0 { + sessionCleaner, err = createSessionCleaner(ctx, log.Named("session-cleaner"), fpmProcess, + cfg.FpmPath, fpmArgs, fpmConfig.Pools, cfg.SessionCleanupInterval) + if err != nil { + log.Error("Can't create session cleaner", zap.Error(err)) + os.Exit(1) + } + } if err = fpmProcess.Start(); err != nil { log.Fatal("Can't start php-fpm", zap.Error(err)) os.Exit(1) } + if sessionCleaner != nil { + go func() { + if err := sessionCleaner.Run(ctx); err != nil { + errCh <- err + } + }() + } + prometheus.MustRegister( phpfpm.NewPromCollector(log.Named("prom-collector"), phpfpm.NewPromMetrics(), fpmConfig.Pools), ) diff --git a/cmd/docker-fpm-wrapper/session.go b/cmd/docker-fpm-wrapper/session.go new file mode 100644 index 0000000..31f509c --- /dev/null +++ b/cmd/docker-fpm-wrapper/session.go @@ -0,0 +1,45 @@ +package main + +import ( + "context" + "fmt" + "time" + + "go.uber.org/zap" + + "github.com/code-tool/docker-fpm-wrapper/internal/session" + "github.com/code-tool/docker-fpm-wrapper/pkg/phpfpm" +) + +// createSessionCleaner reads the session config of php-fpm and returns a cleaner for its +// session directories. It returns nil when no pool keeps the sessions as files. +func createSessionCleaner( + ctx context.Context, + log *zap.Logger, + fpmProcess *phpfpm.Process, + fpmPath string, fpmArgs []string, + pools []phpfpm.Pool, + interval time.Duration, +) (*session.Cleaner, error) { + directives, err := session.ReadFpmDirectives(ctx, fpmPath, fpmArgs) + if err != nil { + return nil, fmt.Errorf("read php-fpm directives: %w", err) + } + + targets, err := session.NewTargets(directives, pools) + if err != nil { + return nil, fmt.Errorf("find session directories: %w", err) + } + + if len(targets) == 0 { + log.Warn("no session directories to clean up") + return nil, nil + } + + for _, target := range targets { + log.Info("session cleanup enabled", + zap.String("dir", target.Dir), zap.Duration("max_lifetime", target.MaxLifetime), zap.Duration("interval", interval)) + } + + return session.NewCleaner(log, fpmProcess, targets, interval), nil +} diff --git a/e2e/session_test.go b/e2e/session_test.go new file mode 100644 index 0000000..f2da522 --- /dev/null +++ b/e2e/session_test.go @@ -0,0 +1,55 @@ +//go:build e2e + +package e2e + +import ( + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestSessionCleanup examines the removal of expired session files. The "www" pool has +// session.gc_maxlifetime = 2 and session.save_path = /tmp/sessions. +func TestSessionCleanup(t *testing.T) { + t.Parallel() + + const ( + closedSession = "/tmp/sessions/sess_closed" + openSession = "/tmp/sessions/sess_open" + ) + + w := startWrapper(t, "--session-cleanup-interval", "500ms") + + assert.Equal(t, "ok", w.mustRequest(t, wwwPort, "session.php", "id=closed")) + + // This request keeps its session file open for 6 s, longer than the lifetime. + var wg sync.WaitGroup + var openBody string + var openErr error + wg.Go(func() { + openBody, openErr = w.request(wwwPort, "session.php", "id=open&seconds=6") + }) + + require.EventuallyWithT(t, func(c *assert.CollectT) { + assert.True(c, w.fileExists(t.Context(), c, openSession)) + }, eventuallyTimeout, eventuallyTick) + + require.EventuallyWithT(t, func(c *assert.CollectT) { + assert.False(c, w.fileExists(t.Context(), c, closedSession), "the expired session file must be removed") + }, eventuallyTimeout, eventuallyTick) + + // Three more cleanups run, and the open session file is older than the lifetime. + time.Sleep(1500 * time.Millisecond) + assert.True(t, w.fileExists(t.Context(), t, openSession), "the open session file must stay") + + wg.Wait() + require.NoError(t, openErr) + assert.Equal(t, "ok", openBody) + + require.EventuallyWithT(t, func(c *assert.CollectT) { + assert.False(c, w.fileExists(t.Context(), c, openSession), "the session file must be removed after it expires") + }, eventuallyTimeout, eventuallyTick) +} diff --git a/e2e/testdata/php-fpm.conf b/e2e/testdata/php-fpm.conf index 30adc98..920695c 100644 --- a/e2e/testdata/php-fpm.conf +++ b/e2e/testdata/php-fpm.conf @@ -24,6 +24,12 @@ request_slowlog_timeout = 1 clear_env = no catch_workers_output = yes +; The session cleanup of the wrapper must read these values. The PHP garbage collector is off, +; so that only the wrapper removes the session files. +php_value[session.save_path] = /tmp/sessions +php_value[session.gc_maxlifetime] = 2 +php_admin_value[session.gc_probability] = 0 + ; A pool that writes the stderr of the workers to the error log without a change. [raw] user = www-data diff --git a/e2e/testdata/www/session.php b/e2e/testdata/www/session.php new file mode 100644 index 0000000..0125877 --- /dev/null +++ b/e2e/testdata/www/session.php @@ -0,0 +1,17 @@ + $v) echo "$k=".$v["local_value"]."\n";') - save_handler=$(echo "$session_config" | sed -ne 's/^session\.save_handler=\(.*\)$/\1/p') - save_path=$(echo "$session_config" | sed -ne 's/^session\.save_path=\(.*;\)\?\(.*\)$/\2/p') - gc_maxlifetime=$(($(echo "$session_config" | sed -ne 's/^session\.gc_maxlifetime=\(.*\)$/\1/p')/60)) - - if [ "$save_handler" = "files" ] && [ -d "$save_path" ]; then - proc_names="$proc_names $(echo "$proc_name" | sed -e "s,@VERSION@,$version,")"; - printf "%s:%s\n" "$save_path" "$gc_maxlifetime" - fi - fi - done -done - -# first find all open session files and touch them (hope it's not massive amount of files) -for pid in $(pidof $proc_names); do - find "/proc/$pid/fd" -ignore_readdir_race -lname "$save_path/sess_*" -exec touch -c {} \; 2>/dev/null -done ) | \ - sort -rn -t: -k2,2 | \ - sort -u -t: -k 1,1 | \ - while IFS=: read -r save_path gc_maxlifetime; do - # find all files older then maxlifetime and delete them - find -O3 "$save_path/" -ignore_readdir_race -depth -mindepth 1 -name 'sess_*' -type f -cmin "+$gc_maxlifetime" -delete - done - -exit 0 -*/ - -type Cleaner struct { -} - -func NewCleaner() *Cleaner { - return &Cleaner{} -} - -func (c *Cleaner) getPHPVersions() ([]string, error) { - // /usr/sbin/phpquery -V - cmd := exec.Command("/usr/sbin/phpquery") - - var stdoutBuff, stderrBuf bytes.Buffer - cmd.Stdout = &stdoutBuff - cmd.Stderr = &stderrBuf - - cmd.Args = append(cmd.Args, "-V") - - if err := cmd.Run(); err != nil { - return nil, err - } - - result := strings.Split(stdoutBuff.String(), "\n") - return result, nil +// MasterProcess is the php-fpm master process. +type MasterProcess interface { + Pid() int } -func (c *Cleaner) getPhpIniPath(phpVersion string, info sapiInfo) string { - return fmt.Sprintf("/etc/php/%s/%s/php.ini", phpVersion, info.ConfDir) -} +// Cleaner removes the expired session files, as the sessionclean cron job of Debian does. +// It does not remove the files that the php-fpm master or its workers have open: it updates +// their modification time instead. +type Cleaner struct { + log *zap.Logger + master MasterProcess -func (c *Cleaner) getPhpPath(phpVersion string) string { - return fmt.Sprintf("/usr/bin/php%s", phpVersion) + targets []Target + interval time.Duration } -func (c *Cleaner) isPhpCliExists(phpVersion string, info sapiInfo) bool { - _, err := os.Stat(c.getPhpIniPath(phpVersion, info)) - if err != nil { - return false - } - - _, err = os.Stat(c.getPhpPath(phpVersion)) - - return err == nil +func NewCleaner(log *zap.Logger, master MasterProcess, targets []Target, interval time.Duration) *Cleaner { + return &Cleaner{log: log, master: master, targets: targets, interval: interval} } -func (c *Cleaner) touchOpenFiles(procName string, savePath string) error { - pids, err := pidOf(procName) +// openFiles returns the files that the php-fpm master and its workers have open. +func (c *Cleaner) openFiles() (map[string]bool, error) { + procs, err := procfs.AllProcs() if err != nil { - return err + return nil, fmt.Errorf("list processes: %w", err) } - prefix := savePath + "/sess_" - currentTime := time.Now().Local() - - for _, pid := range pids { - proc, err := procfs.NewProc(pid) - if err != nil { - return err + masterPid := c.master.Pid() + files := make(map[string]bool) + for _, proc := range procs { + if proc.PID != masterPid { + stat, err := proc.Stat() + if err != nil || stat.PPID != masterPid { + continue + } } + // The process can exit before the read. targets, err := proc.FileDescriptorTargets() if err != nil { - return nil + continue } for _, target := range targets { - if !strings.HasPrefix(target, prefix) { - continue - } - - stat, err := os.Stat(target) - if err != nil || stat.IsDir() { - continue - } - - if err := os.Chtimes(target, currentTime, currentTime); err != nil { - return err - } + files[target] = true } } - return nil + return files, nil } -func (c *Cleaner) removeSessionFiles(savePath string, cutoff time.Duration) error { - entries, err := os.ReadDir(savePath) +// cleanTarget removes the expired session files in the directory of target and in its +// subdirectories. It returns the number of removed files. +func (c *Cleaner) cleanTarget(ctx context.Context, target Target, openFiles map[string]bool, now time.Time) (int, error) { + // The kernel gives the paths of the open files without symlinks. + root, err := filepath.EvalSymlinks(target.Dir) + if errors.Is(err, fs.ErrNotExist) { + return 0, nil + } if err != nil { - return err + return 0, fmt.Errorf("resolve %s: %w", target.Dir, err) } - now := time.Now() - for _, entry := range entries { - if entry.IsDir() || !strings.HasPrefix(entry.Name(), "sess_") { - continue - } - info, err := entry.Info() + removed := 0 + var errs []error + walkErr := filepath.WalkDir(root, func(path string, entry fs.DirEntry, err error) error { if err != nil { - continue + errs = append(errs, err) + return nil } - if diff := now.Sub(info.ModTime()); diff > cutoff { - continue + if ctxErr := ctx.Err(); ctxErr != nil { + return ctxErr } - if err := os.Remove(info.Name()); err != nil { - return err + if !entry.Type().IsRegular() || !strings.HasPrefix(entry.Name(), "sess_") { + return nil } - } - return nil -} + if openFiles[path] { + if err := os.Chtimes(path, now, now); err != nil && !errors.Is(err, fs.ErrNotExist) { + errs = append(errs, fmt.Errorf("touch open session file: %w", err)) + } -func (c *Cleaner) cleanupForPhpAnsSapi(phpVersion string, sapi sapiInfo) error { - if !c.isPhpCliExists(phpVersion, sapi) { - return nil - } + return nil + } - sessConfig, err := NewConfig(phpVersion, sapi.ConfDir) - if err != nil { - return err - } + info, err := entry.Info() + if err != nil { + // PHP removed the file after the read of the directory. + return nil + } - if !sessConfig.IfSaveHandlerFilesAndPathNonEmpty() { - return nil - } + if now.Sub(info.ModTime()) <= target.MaxLifetime { + return nil + } - var result error - if err := c.touchOpenFiles(sapi.GetProcName(phpVersion), sessConfig.SavePath); err != nil { - result = multierr.Append(result, err) - } + if err := os.Remove(path); err != nil && !errors.Is(err, fs.ErrNotExist) { + errs = append(errs, fmt.Errorf("remove session file: %w", err)) + return nil + } + removed++ - if err := c.removeSessionFiles(sessConfig.SavePath, sessConfig.GcMaxLifetime); err != nil { - result = multierr.Append(result, err) - } + return nil + }) - return result + return removed, errors.Join(walkErr, errors.Join(errs...)) } -func (c *Cleaner) Cleanup() error { - phpVersions, err := c.getPHPVersions() +// Cleanup removes the session files that are older than the lifetime of their directory at now. +func (c *Cleaner) Cleanup(ctx context.Context, now time.Time) error { + openFiles, err := c.openFiles() if err != nil { return err } - var result error - for _, phpVersion := range phpVersions { - for _, sapi := range sapis { - if err := c.cleanupForPhpAnsSapi(phpVersion, sapi); err != nil { - result = multierr.Append(result, err) - } + var errs []error + for _, target := range c.targets { + removed, err := c.cleanTarget(ctx, target, openFiles, now) + if err != nil { + errs = append(errs, fmt.Errorf("clean %s: %w", target.Dir, err)) } + + c.log.Debug("session files removed", zap.String("dir", target.Dir), zap.Int("count", removed)) } - return result + return errors.Join(errs...) } -func (c *Cleaner) CleanupInfinity(ctx context.Context) { - ticker := time.NewTicker(30 * time.Minute) +// Run calls Cleanup at start and then after each interval. It blocks until ctx is done. +func (c *Cleaner) Run(ctx context.Context) error { + ticker := time.NewTicker(c.interval) defer ticker.Stop() - select { - case <-ctx.Done(): - return - case <-ticker.C: - _ = c.Cleanup() + now := time.Now() + for { + if err := c.Cleanup(ctx, now); err != nil && ctx.Err() == nil { + c.log.Error("can't clean up session files", zap.Error(err)) + } + + select { + case <-ctx.Done(): + return nil + case now = <-ticker.C: + } } } diff --git a/internal/session/cleaner_test.go b/internal/session/cleaner_test.go new file mode 100644 index 0000000..eb4e3ce --- /dev/null +++ b/internal/session/cleaner_test.go @@ -0,0 +1,159 @@ +package session + +import ( + "context" + "os" + "path/filepath" + "testing" + "testing/synctest" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/zap" +) + +// testMaster is the php-fpm master for the tests: the test process itself. Then the files +// that the test opens are open files of the master. +type testMaster struct{} + +func (testMaster) Pid() int { + return os.Getpid() +} + +func writeSessionFile(t *testing.T, path string, modTime time.Time) { + t.Helper() + + require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o700)) + require.NoError(t, os.WriteFile(path, []byte("data"), 0o600)) + require.NoError(t, os.Chtimes(path, modTime, modTime)) +} + +func TestCleaner_Cleanup(t *testing.T) { + const lifetime = time.Hour + now := time.Now() + expired := now.Add(-2 * lifetime) + + tests := []struct { + name string + file string + modTime time.Time + open bool + wantExists bool + }{ + {name: "expired session is removed", file: "sess_expired", modTime: expired}, + {name: "live session stays", file: "sess_live", modTime: now.Add(-time.Minute), wantExists: true}, + {name: "expired session in a subdirectory is removed", file: "a/b/sess_nested", modTime: expired}, + {name: "expired file without the sess_ prefix stays", file: "other", modTime: expired, wantExists: true}, + {name: "expired open session stays", file: "sess_open", modTime: expired, open: true, wantExists: true}, + } + + dir := t.TempDir() + for _, tt := range tests { + path := filepath.Join(dir, tt.file) + writeSessionFile(t, path, tt.modTime) + + if tt.open { + f, err := os.Open(path) + require.NoError(t, err) + t.Cleanup(func() { _ = f.Close() }) + } + } + + cleaner := NewCleaner(zap.NewNop(), testMaster{}, []Target{{Dir: dir, MaxLifetime: lifetime}}, time.Hour) + require.NoError(t, cleaner.Cleanup(t.Context(), now)) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + path := filepath.Join(dir, tt.file) + if !tt.wantExists { + assert.NoFileExists(t, path) + return + } + + info, err := os.Stat(path) + require.NoError(t, err) + + wantModTime := tt.modTime + if tt.open { + wantModTime = now + } + assert.WithinDuration(t, wantModTime, info.ModTime(), time.Millisecond) + }) + } +} + +func TestCleaner_Cleanup_Dirs(t *testing.T) { + now := time.Now() + + t.Run("missing directory is not an error", func(t *testing.T) { + target := Target{Dir: filepath.Join(t.TempDir(), "missing"), MaxLifetime: time.Hour} + cleaner := NewCleaner(zap.NewNop(), testMaster{}, []Target{target}, time.Hour) + + assert.NoError(t, cleaner.Cleanup(t.Context(), now)) + }) + + t.Run("open session in a symlinked directory stays", func(t *testing.T) { + realDir := t.TempDir() + link := filepath.Join(t.TempDir(), "sessions") + require.NoError(t, os.Symlink(realDir, link)) + + path := filepath.Join(realDir, "sess_open") + writeSessionFile(t, path, now.Add(-2*time.Hour)) + f, err := os.Open(path) + require.NoError(t, err) + t.Cleanup(func() { _ = f.Close() }) + + cleaner := NewCleaner(zap.NewNop(), testMaster{}, []Target{{Dir: link, MaxLifetime: time.Hour}}, time.Hour) + require.NoError(t, cleaner.Cleanup(t.Context(), now)) + + assert.FileExists(t, path) + }) + + t.Run("canceled context stops the cleanup", func(t *testing.T) { + dir := t.TempDir() + writeSessionFile(t, filepath.Join(dir, "sess_expired"), now.Add(-2*time.Hour)) + + ctx, cancel := context.WithCancel(t.Context()) + cancel() + + cleaner := NewCleaner(zap.NewNop(), testMaster{}, []Target{{Dir: dir, MaxLifetime: time.Hour}}, time.Hour) + assert.ErrorIs(t, cleaner.Cleanup(ctx, now), context.Canceled) + assert.FileExists(t, filepath.Join(dir, "sess_expired")) + }) +} + +func TestCleaner_Run(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + const ( + lifetime = time.Minute + interval = time.Hour + ) + dir := t.TempDir() + cleaner := NewCleaner(zap.NewNop(), testMaster{}, []Target{{Dir: dir, MaxLifetime: lifetime}}, interval) + + atStart := filepath.Join(dir, "sess_at_start") + writeSessionFile(t, atStart, time.Now().Add(-2*lifetime)) + + ctx, cancel := context.WithCancel(t.Context()) + done := make(chan error, 1) + go func() { done <- cleaner.Run(ctx) }() + + synctest.Wait() + assert.NoFileExists(t, atStart, "Run cleans up at start") + + afterStart := filepath.Join(dir, "sess_after_start") + writeSessionFile(t, afterStart, time.Now()) + + time.Sleep(interval - time.Second) + synctest.Wait() + assert.FileExists(t, afterStart, "Run waits for the interval") + + time.Sleep(time.Second) + synctest.Wait() + assert.NoFileExists(t, afterStart, "Run cleans up after the interval") + + cancel() + assert.NoError(t, <-done) + }) +} diff --git a/internal/session/config.go b/internal/session/config.go index 25f5d58..3be2291 100644 --- a/internal/session/config.go +++ b/internal/session/config.go @@ -1,76 +1,62 @@ package session import ( - "bufio" - "bytes" "fmt" - "os" - "os/exec" "strconv" "strings" "time" ) +// defaultGcMaxLifetime is the default of session.gc_maxlifetime in PHP. +const defaultGcMaxLifetime = 1440 * time.Second + +// Config is the session config of one pool. type Config struct { - SavePath string SaveHandler string + SavePath string GcMaxLifetime time.Duration } -func NewConfig(phpVersion, confDir string) (*Config, error) { - //session_config=$( - // PHP_INI_SCAN_DIR=/etc/php/${version}/${conf_dir}/conf.d/ - // "/usr/bin/php${version}" - // -c "/etc/php/${version}/${conf_dir}/php.ini" - // -d "error_reporting='~E_ALL'" - // -r 'foreach(ini_get_all("session") as $k => $v) echo "$k=".$v["local_value"]."\n";' - //) - cmd := exec.Command(fmt.Sprintf("/usr/bin/php%s", phpVersion)) - - var stdoutBuff, stderrBuf bytes.Buffer - cmd.Stdout = &stdoutBuff - cmd.Stderr = &stderrBuf +// NewConfig reads the session directives. The PHP directives of the pool take precedence over +// the directives of php.ini. +func NewConfig(directives, poolValues map[string]string) (Config, error) { + get := func(name string) string { + if value, ok := poolValues[name]; ok { + return value + } - cmd.Env = os.Environ() - cmd.Env = append(cmd.Env, fmt.Sprintf("PHP_INI_SCAN_DIR=/etc/php/%s/%s/conf.d/", phpVersion, confDir)) - cmd.Args = append(cmd.Args, "-c", fmt.Sprintf("/etc/php/%s/%s/php.ini", phpVersion, confDir)) - cmd.Args = append(cmd.Args, "-d", "error_reporting='~E_ALL'") - cmd.Args = append(cmd.Args, "-r", `foreach(ini_get_all('session') as $k => $v) echo $k, '=', $v['local_value'], PHP_EOL;`) + return directives[name] + } - if err := cmd.Run(); err != nil { - return nil, err + config := Config{ + SaveHandler: get("session.save_handler"), + SavePath: get("session.save_path"), + GcMaxLifetime: defaultGcMaxLifetime, } - result := &Config{} - scanner := bufio.NewScanner(&stdoutBuff) - for scanner.Scan() { - parts := strings.SplitN(scanner.Text(), "=", 2) - if len(parts) != 2 { - continue + if lifetime := get("session.gc_maxlifetime"); lifetime != "" { + seconds, err := strconv.Atoi(lifetime) + if err != nil { + return Config{}, fmt.Errorf("parse session.gc_maxlifetime: %w", err) } - switch parts[0] { - case "session.save_path": - result.SavePath = parts[1] - case "session.save_handler": - result.SaveHandler = parts[1] - case "session.gc_maxlifetime": - if len(parts[1]) == 0 { - continue - } + config.GcMaxLifetime = time.Duration(seconds) * time.Second + } - gcMaxLifetimeSeconds, err := strconv.Atoi(parts[1]) - if err != nil { - return nil, err - } + return config, nil +} - result.GcMaxLifetime = time.Second * time.Duration(gcMaxLifetimeSeconds) - } +// Dir returns the directory of the session files. session.save_path can have the form +// "N;/path" or "N;MODE;/path", where N is the number of subdirectory levels. +func (c Config) Dir() string { + if _, dir, found := strings.CutLast(c.SavePath, ";"); found { + return dir } - return result, scanner.Err() + return c.SavePath } -func (c *Config) IfSaveHandlerFilesAndPathNonEmpty() bool { - return c.SaveHandler == "files" && len(c.SavePath) > 0 +// HasFiles tells if PHP keeps the sessions as files in a known directory. +func (c Config) HasFiles() bool { + return c.SaveHandler == "files" && c.Dir() != "" } diff --git a/internal/session/config_test.go b/internal/session/config_test.go new file mode 100644 index 0000000..652903f --- /dev/null +++ b/internal/session/config_test.go @@ -0,0 +1,81 @@ +package session + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewConfig(t *testing.T) { + directives := map[string]string{ + "session.save_handler": "files", + "session.save_path": "/var/lib/php/sessions", + "session.gc_maxlifetime": "1440", + } + + tests := []struct { + name string + directives map[string]string + poolValues map[string]string + want Config + wantErr string + }{ + { + name: "directives of php.ini", + directives: directives, + want: Config{SaveHandler: "files", SavePath: "/var/lib/php/sessions", GcMaxLifetime: 1440 * time.Second}, + }, + { + name: "pool values take precedence", + directives: directives, + poolValues: map[string]string{"session.save_path": "/srv/sessions", "session.gc_maxlifetime": "60"}, + want: Config{SaveHandler: "files", SavePath: "/srv/sessions", GcMaxLifetime: time.Minute}, + }, + { + name: "missing lifetime has the PHP default", + directives: map[string]string{"session.save_handler": "files"}, + want: Config{SaveHandler: "files", GcMaxLifetime: defaultGcMaxLifetime}, + }, + { + name: "invalid lifetime", + directives: directives, + poolValues: map[string]string{"session.gc_maxlifetime": "1h"}, + wantErr: "parse session.gc_maxlifetime", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := NewConfig(tt.directives, tt.poolValues) + if tt.wantErr != "" { + assert.ErrorContains(t, err, tt.wantErr) + return + } + + require.NoError(t, err) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestConfig_Dir(t *testing.T) { + tests := []struct { + name string + config Config + wantDir string + hasFiles bool + }{ + {name: "path", config: Config{SaveHandler: "files", SavePath: "/var/lib/php/sessions"}, wantDir: "/var/lib/php/sessions", hasFiles: true}, + {name: "levels and path", config: Config{SaveHandler: "files", SavePath: "2;/var/lib/php/sessions"}, wantDir: "/var/lib/php/sessions", hasFiles: true}, + {name: "levels, mode, and path", config: Config{SaveHandler: "files", SavePath: "2;600;/var/lib/php/sessions"}, wantDir: "/var/lib/php/sessions", hasFiles: true}, + {name: "empty path", config: Config{SaveHandler: "files"}, wantDir: "", hasFiles: false}, + {name: "other handler", config: Config{SaveHandler: "redis", SavePath: "tcp://redis:6379"}, wantDir: "tcp://redis:6379", hasFiles: false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.wantDir, tt.config.Dir()) + assert.Equal(t, tt.hasFiles, tt.config.HasFiles()) + }) + } +} diff --git a/internal/session/phpinfo.go b/internal/session/phpinfo.go new file mode 100644 index 0000000..b34d410 --- /dev/null +++ b/internal/session/phpinfo.go @@ -0,0 +1,57 @@ +package session + +import ( + "bufio" + "bytes" + "context" + "fmt" + "io" + "os/exec" + "slices" + "strings" +) + +// parsePhpinfo returns the local values of the directives in the text output of phpinfo(). +// A directive line has the form "name => local value => master value". +func parsePhpinfo(r io.Reader) (map[string]string, error) { + directives := make(map[string]string) + + scanner := bufio.NewScanner(r) + scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) + for scanner.Scan() { + parts := strings.Split(scanner.Text(), " => ") + if len(parts) != 3 { + continue + } + + value := parts[1] + if value == "no value" { + value = "" + } + + directives[parts[0]] = value + } + + if err := scanner.Err(); err != nil { + return nil, fmt.Errorf("read phpinfo: %w", err) + } + + return directives, nil +} + +// ReadFpmDirectives runs "php-fpm -i" with the extra arguments of php-fpm and returns the local +// values of the PHP directives. The values come from the php.ini of the FPM SAPI and from the +// "-d" arguments. They do not contain the php_value directives of the pools. +func ReadFpmDirectives(ctx context.Context, fpmPath string, fpmArgs []string) (map[string]string, error) { + cmd := exec.CommandContext(ctx, fpmPath, append(slices.Clone(fpmArgs), "-i")...) + + var stderr bytes.Buffer + cmd.Stderr = &stderr + + out, err := cmd.Output() + if err != nil { + return nil, fmt.Errorf("run %s -i: %w: %s", fpmPath, err, bytes.TrimSpace(stderr.Bytes())) + } + + return parsePhpinfo(bytes.NewReader(out)) +} diff --git a/internal/session/phpinfo_test.go b/internal/session/phpinfo_test.go new file mode 100644 index 0000000..1b5fc9c --- /dev/null +++ b/internal/session/phpinfo_test.go @@ -0,0 +1,96 @@ +package session + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParsePhpinfo(t *testing.T) { + tests := []struct { + name string + input string + want map[string]string + }{ + { + name: "directives", + input: "session\n\n" + + "Directive => Local Value => Master Value\n" + + "session.gc_maxlifetime => 60 => 1440\n" + + "session.save_handler => files => files\n", + want: map[string]string{ + "Directive": "Local Value", + "session.gc_maxlifetime": "60", + "session.save_handler": "files", + }, + }, + { + name: "no value is empty", + input: "session.save_path => no value => no value\n", + want: map[string]string{"session.save_path": ""}, + }, + { + name: "rows with two columns are skipped", + input: "Server API => FPM/FastCGI\nRegistered save handlers => files user\n", + want: map[string]string{}, + }, + { + name: "empty output", + input: "", + want: map[string]string{}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := parsePhpinfo(strings.NewReader(tt.input)) + require.NoError(t, err) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestReadFpmDirectives(t *testing.T) { + tests := []struct { + name string + script string + args []string + want map[string]string + wantErr string + }{ + { + name: "arguments go before -i", + script: "#!/bin/sh\necho \"args => $* => master\"\n", + args: []string{"-d", "session.gc_maxlifetime=60"}, + want: map[string]string{"args": "-d session.gc_maxlifetime=60 -i"}, + }, + { + name: "php-fpm fails", + script: "#!/bin/sh\necho 'unknown option' >&2\nexit 64\n", + wantErr: "exit status 64: unknown option", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fpmPath := filepath.Join(t.TempDir(), "php-fpm") + require.NoError(t, os.WriteFile(fpmPath, []byte(tt.script), 0o755)) + + got, err := ReadFpmDirectives(t.Context(), fpmPath, tt.args) + if tt.wantErr != "" { + assert.ErrorContains(t, err, tt.wantErr) + return + } + + require.NoError(t, err) + assert.Equal(t, tt.want, got) + }) + } + + t.Run("php-fpm does not exist", func(t *testing.T) { + _, err := ReadFpmDirectives(t.Context(), filepath.Join(t.TempDir(), "php-fpm"), nil) + assert.ErrorContains(t, err, "no such file or directory") + }) +} diff --git a/internal/session/pidof.go b/internal/session/pidof.go deleted file mode 100644 index 9beccd5..0000000 --- a/internal/session/pidof.go +++ /dev/null @@ -1,35 +0,0 @@ -package session - -import ( - "regexp" - - "github.com/prometheus/procfs" -) - -func pidOf(name string) ([]int, error) { - re, err := regexp.Compile("(^|/)" + name + "$") - if err != nil { - return nil, err - } - - procs, err := procfs.AllProcs() - if err != nil { - return nil, err - } - - var result []int - for _, proc := range procs { - executable, err := proc.Executable() - if err != nil { - continue - } - - if !re.MatchString(executable) { - continue - } - - result = append(result, proc.PID) - } - - return result, err -} diff --git a/internal/session/sapi.go b/internal/session/sapi.go deleted file mode 100644 index 87d7dbe..0000000 --- a/internal/session/sapi.go +++ /dev/null @@ -1,22 +0,0 @@ -package session - -import ( - "strings" -) - -type sapiInfo struct { - ConfDir string - procName string -} - -var sapis = []sapiInfo{ - {ConfDir: "apache2", procName: "apache2"}, - {ConfDir: "apache2filter", procName: "apache2"}, - {ConfDir: "cgi", procName: "php@VERSION@"}, - {ConfDir: "fpm", procName: "php-fpm@VERSION@"}, - {ConfDir: "cli", procName: "php@VERSION@"}, -} - -func (si sapiInfo) GetProcName(phpVersion string) string { - return strings.Replace(si.procName, "@VERSION@", phpVersion, 1) -} diff --git a/internal/session/target.go b/internal/session/target.go new file mode 100644 index 0000000..b5a25f0 --- /dev/null +++ b/internal/session/target.go @@ -0,0 +1,49 @@ +package session + +import ( + "cmp" + "fmt" + "path/filepath" + "slices" + "time" + + "github.com/code-tool/docker-fpm-wrapper/pkg/phpfpm" +) + +// Target is a directory with session files and the lifetime of the files in it. +type Target struct { + Dir string + MaxLifetime time.Duration +} + +// NewTargets returns the session directories of the pools, sorted by directory. It skips the +// pools that do not keep the sessions as files. When two or more pools use one directory, the +// longest lifetime applies. Then the cleaner does not remove a live session of a pool. +func NewTargets(directives map[string]string, pools []phpfpm.Pool) ([]Target, error) { + lifetimes := make(map[string]time.Duration) + + for _, pool := range pools { + config, err := NewConfig(directives, pool.PhpValues) + if err != nil { + return nil, fmt.Errorf("pool %s: %w", pool.Name, err) + } + + if !config.HasFiles() { + continue + } + + dir := filepath.Clean(config.Dir()) + lifetimes[dir] = max(lifetimes[dir], config.GcMaxLifetime) + } + + targets := make([]Target, 0, len(lifetimes)) + for dir, lifetime := range lifetimes { + targets = append(targets, Target{Dir: dir, MaxLifetime: lifetime}) + } + + slices.SortFunc(targets, func(a, b Target) int { + return cmp.Compare(a.Dir, b.Dir) + }) + + return targets, nil +} diff --git a/internal/session/target_test.go b/internal/session/target_test.go new file mode 100644 index 0000000..8eb2cec --- /dev/null +++ b/internal/session/target_test.go @@ -0,0 +1,76 @@ +package session + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/code-tool/docker-fpm-wrapper/pkg/phpfpm" +) + +func TestNewTargets(t *testing.T) { + directives := map[string]string{ + "session.save_handler": "files", + "session.save_path": "/var/lib/php/sessions", + "session.gc_maxlifetime": "1440", + } + + tests := []struct { + name string + pools []phpfpm.Pool + want []Target + wantErr string + }{ + { + name: "pool with php.ini directives", + pools: []phpfpm.Pool{{Name: "www"}}, + want: []Target{{Dir: "/var/lib/php/sessions", MaxLifetime: 1440 * time.Second}}, + }, + { + name: "pools in one directory get the longest lifetime", + pools: []phpfpm.Pool{ + {Name: "short", PhpValues: map[string]string{"session.gc_maxlifetime": "60"}}, + {Name: "long", PhpValues: map[string]string{"session.save_path": "2;/var/lib/php/sessions/", "session.gc_maxlifetime": "3600"}}, + }, + want: []Target{{Dir: "/var/lib/php/sessions", MaxLifetime: time.Hour}}, + }, + { + name: "pools in different directories are sorted", + pools: []phpfpm.Pool{ + {Name: "www"}, + {Name: "admin", PhpValues: map[string]string{"session.save_path": "/srv/admin-sessions"}}, + }, + want: []Target{ + {Dir: "/srv/admin-sessions", MaxLifetime: 1440 * time.Second}, + {Dir: "/var/lib/php/sessions", MaxLifetime: 1440 * time.Second}, + }, + }, + { + name: "pools without session files are skipped", + pools: []phpfpm.Pool{ + {Name: "redis", PhpValues: map[string]string{"session.save_handler": "redis"}}, + {Name: "tmp", PhpValues: map[string]string{"session.save_path": ""}}, + }, + want: []Target{}, + }, + { + name: "invalid lifetime", + pools: []phpfpm.Pool{{Name: "www", PhpValues: map[string]string{"session.gc_maxlifetime": "x"}}}, + wantErr: "pool www: parse session.gc_maxlifetime", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := NewTargets(directives, tt.pools) + if tt.wantErr != "" { + assert.ErrorContains(t, err, tt.wantErr) + return + } + + require.NoError(t, err) + assert.Equal(t, tt.want, got) + }) + } +} diff --git a/pkg/phpfpm/process.go b/pkg/phpfpm/process.go index 9ccc16d..c169d1c 100644 --- a/pkg/phpfpm/process.go +++ b/pkg/phpfpm/process.go @@ -42,6 +42,11 @@ func (p *Process) Start() error { return p.cmd.Start() } +// Pid returns the PID of the php-fpm master. Call it only after Start. +func (p *Process) Pid() int { + return p.cmd.Process.Pid +} + func (p *Process) HandleSignal(signalCh chan os.Signal) { for { sig := <-signalCh From 59976ec16956d8780e7fda8dd18abee222d61a15 Mon Sep 17 00:00:00 2001 From: asp24 <488588+asp24@users.noreply.github.com> Date: Wed, 21 Oct 2026 21:42:24 +0200 Subject: [PATCH 11/18] Take each section except global as a php-fpm pool --- CHANGELOG.md | 1 + e2e/slowlog_test.go | 56 +++++++++++------ e2e/testdata/php-fpm.conf | 6 +- pkg/phpfpm/config.go | 8 +-- pkg/phpfpm/config_test.go | 70 +++++++++++++++++---- pkg/phpfpm/testdata/php-fpm.d/nostatus.conf | 5 ++ 6 files changed, 109 insertions(+), 37 deletions(-) create mode 100644 pkg/phpfpm/testdata/php-fpm.d/nostatus.conf diff --git a/CHANGELOG.md b/CHANGELOG.md index 922e1da..5fcc3e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,7 @@ and this project adheres to [Semantic Versioning](http://semver.org). - `--shutdown-delay` also applies to SIGQUIT. The php-fpm images have `STOPSIGNAL SIGQUIT`, so Docker and Kubernetes stopped php-fpm without the delay. - The error log proxy writes a line in an unknown format to stderr without a change and continues. Before, it stopped at the first such line, and all next entries of the error log were lost. Such lines come, for example, from a pool with `decorate_workers_output = no`. - The wrapper pipe (`FPM_WRAPPER_PIPE`) has mode `0666` for all umask values. Before, the umask gave `0644`, and workers that run as a different user than the master could not write to the pipe. +- The wrapper takes each section of the php-fpm config except `[global]` as a pool. Before, it ignored the pools without `pm.status_path`: their slowlog was not proxied. When such a pool was the last section, the wrapper did not start. - The wrapper exits with an error when `--log-encoder` has an unknown value. Before, it panicked. ## [1.0.1] - 2025-01-10 diff --git a/e2e/slowlog_test.go b/e2e/slowlog_test.go index cc99a7e..1c3350e 100644 --- a/e2e/slowlog_test.go +++ b/e2e/slowlog_test.go @@ -12,25 +12,41 @@ import ( func TestSlowlogProxy(t *testing.T) { t.Parallel() - w := startWrapper(t) - - assert.Equal(t, "ok", w.mustRequest(t, wwwPort, "slow.php", "")) - - entry := w.waitForEntry(t, func(e logEntry) bool { - return e.Message == "slowlog" - }) - - assert.Equal(t, "php-fpm", entry.Channel) - assert.Equal(t, "warn", entry.Level) - // The wrapper removes the common directory of the script and the trace from all paths. - assert.Equal(t, "/slow.php", entry.Filename) - - funcs := make([]string, 0, len(entry.Trace)) - for _, frame := range entry.Trace { - assert.Equal(t, "/slow.php", frame.Path) - funcs = append(funcs, frame.Func) + tests := []struct { + name string + port string + hasStatus bool + }{ + {name: "pool with status page", port: wwwPort, hasStatus: true}, + {name: "pool without status page", port: rawPort}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + w := startWrapper(t) + + assert.Equal(t, "ok", w.mustRequest(t, tt.port, "slow.php", "")) + + entry := w.waitForEntry(t, func(e logEntry) bool { + return e.Message == "slowlog" + }) + + assert.Equal(t, "php-fpm", entry.Channel) + assert.Equal(t, "warn", entry.Level) + // The wrapper removes the common directory of the script and the trace from all paths. + assert.Equal(t, "/slow.php", entry.Filename) + + funcs := make([]string, 0, len(entry.Trace)) + for _, frame := range entry.Trace { + assert.Equal(t, "/slow.php", frame.Path) + funcs = append(funcs, frame.Func) + } + assert.Equal(t, []string{"sleep()", "slowInner()", "slowOuter()"}, funcs) + + if tt.hasStatus { + assert.InDelta(t, 1, w.poolGauge(t.Context(), t, "phpfpm_slow_requests", "www"), 0) + } + }) } - assert.Equal(t, []string{"sleep()", "slowInner()", "slowOuter()"}, funcs) - - assert.InDelta(t, 1, w.poolGauge(t.Context(), t, "phpfpm_slow_requests", "www"), 0) } diff --git a/e2e/testdata/php-fpm.conf b/e2e/testdata/php-fpm.conf index 920695c..b0d0452 100644 --- a/e2e/testdata/php-fpm.conf +++ b/e2e/testdata/php-fpm.conf @@ -30,14 +30,16 @@ php_value[session.save_path] = /tmp/sessions php_value[session.gc_maxlifetime] = 2 php_admin_value[session.gc_probability] = 0 -; A pool that writes the stderr of the workers to the error log without a change. +; A pool without a status page. It writes the stderr of the workers to the error log without +; a change. [raw] user = www-data group = www-data listen = 9001 pm = static pm.max_children = 1 -pm.status_path = /status +slowlog = /tmp/php-fpm-$pool.slow.log +request_slowlog_timeout = 1 clear_env = no catch_workers_output = yes decorate_workers_output = no diff --git a/pkg/phpfpm/config.go b/pkg/phpfpm/config.go index c848047..9c98530 100644 --- a/pkg/phpfpm/config.go +++ b/pkg/phpfpm/config.go @@ -145,16 +145,16 @@ func ParseConfig(fpmConfigPath string) (Config, error) { } } + // php-fpm takes each section except [global] as a pool. for _, section := range cfg.Sections() { - _, err = section.GetKey("pm.status_path") - if err != nil { + if section.Name() == ini.DefaultSection || section.Name() == "global" { continue } if err := fillPull(&c, cfg, section.Name()); err != nil { - return c, err + return c, fmt.Errorf("pool %s: %w", section.Name(), err) } } - return c, err + return c, nil } diff --git a/pkg/phpfpm/config_test.go b/pkg/phpfpm/config_test.go index e5caad4..677dfa2 100644 --- a/pkg/phpfpm/config_test.go +++ b/pkg/phpfpm/config_test.go @@ -2,10 +2,12 @@ package phpfpm import ( "os" + "path/filepath" "testing" "github.com/otiai10/copy" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestParse(t *testing.T) { @@ -16,17 +18,63 @@ func TestParse(t *testing.T) { defer func() { _ = os.RemoveAll(dir) }() c, err := ParseConfig(dir + "/php-fpm.conf") - assert.Nil(t, err) + require.NoError(t, err) assert.Equal(t, dir+"/php-fpm.d/*.conf", c.Include) - assert.Equal(t, "www", c.Pools[0].Name) - assert.Equal(t, 1, c.Pools[0].RequestSlowlogTimeout) - assert.Equal(t, "/run/php-fpm/php-fpm.sock", c.Pools[0].Listen) - assert.Equal(t, "/status", c.Pools[0].StatusPath) - assert.Equal(t, "log/www.log.slow", c.Pools[0].SlowlogPath) - assert.Equal(t, map[string]string{ - "session.save_path": "2;/var/lib/php/sessions", - "session.gc_maxlifetime": "3600", - "log_errors": "on", - }, c.Pools[0].PhpValues) + assert.Equal(t, []Pool{ + { + Name: "nostatus", + Listen: "127.0.0.1:9001", + SlowlogPath: "/var/log/nostatus.slow.log", + RequestSlowlogTimeout: 5, + RequestSlowlogTraceDepth: 64, + PhpValues: map[string]string{}, + }, + { + Name: "www", + Listen: "/run/php-fpm/php-fpm.sock", + StatusPath: "/status", + SlowlogPath: "log/www.log.slow", + RequestSlowlogTimeout: 1, + RequestSlowlogTraceDepth: 64, + PhpValues: map[string]string{ + "session.save_path": "2;/var/lib/php/sessions", + "session.gc_maxlifetime": "3600", + "log_errors": "on", + }, + }, + }, c.Pools) +} + +func TestParseConfig_Errors(t *testing.T) { + tests := []struct { + name string + config string + wantErr string + }{ + { + name: "no global section", + config: "[www]\nlisten = 9000\n", + wantErr: `section "global" does not exist`, + }, + { + name: "pool without listen", + config: "[global]\nerror_log = /proc/self/fd/2\n\n[www]\nlisten = 9000\n\n[broken]\npm = static\n", + wantErr: "pool broken: ", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fPath := filepath.Join(t.TempDir(), "php-fpm.conf") + require.NoError(t, os.WriteFile(fPath, []byte(tt.config), 0o600)) + + _, err := ParseConfig(fPath) + assert.ErrorContains(t, err, tt.wantErr) + }) + } + + t.Run("missing file", func(t *testing.T) { + _, err := ParseConfig(filepath.Join(t.TempDir(), "php-fpm.conf")) + assert.ErrorContains(t, err, "no such file or directory") + }) } diff --git a/pkg/phpfpm/testdata/php-fpm.d/nostatus.conf b/pkg/phpfpm/testdata/php-fpm.d/nostatus.conf new file mode 100644 index 0000000..612e635 --- /dev/null +++ b/pkg/phpfpm/testdata/php-fpm.d/nostatus.conf @@ -0,0 +1,5 @@ +; A pool without a status page. +[nostatus] +listen = 127.0.0.1:9001 +slowlog = /var/log/$pool.slow.log +request_slowlog_timeout = 5 From 03c47c62b9121f2b77d4dc143b086847e4aa2db0 Mon Sep 17 00:00:00 2001 From: asp24 <488588+asp24@users.noreply.github.com> Date: Wed, 21 Oct 2026 21:43:26 +0200 Subject: [PATCH 12/18] Replace $pool in all directives of a pool --- CHANGELOG.md | 1 + pkg/phpfpm/config.go | 51 +++++++++++---------- pkg/phpfpm/config_test.go | 7 +-- pkg/phpfpm/testdata/php-fpm.d/nostatus.conf | 8 ++-- 4 files changed, 36 insertions(+), 31 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5fcc3e9..0b5447a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,7 @@ and this project adheres to [Semantic Versioning](http://semver.org). - The error log proxy writes a line in an unknown format to stderr without a change and continues. Before, it stopped at the first such line, and all next entries of the error log were lost. Such lines come, for example, from a pool with `decorate_workers_output = no`. - The wrapper pipe (`FPM_WRAPPER_PIPE`) has mode `0666` for all umask values. Before, the umask gave `0644`, and workers that run as a different user than the master could not write to the pipe. - The wrapper takes each section of the php-fpm config except `[global]` as a pool. Before, it ignored the pools without `pm.status_path`: their slowlog was not proxied. When such a pool was the last section, the wrapper did not start. +- The wrapper replaces each `$pool` in each directive of a pool, as php-fpm does. Before, it replaced only the first `$pool` in `slowlog`, so a `listen = /run/php/$pool.sock` pool had no metrics. - The wrapper exits with an error when `--log-encoder` has an unknown value. Before, it panicked. ## [1.0.1] - 2025-01-10 diff --git a/pkg/phpfpm/config.go b/pkg/phpfpm/config.go index 9c98530..0d11f5a 100644 --- a/pkg/phpfpm/config.go +++ b/pkg/phpfpm/config.go @@ -1,6 +1,7 @@ package phpfpm import ( + "errors" "fmt" "os" "path" @@ -31,9 +32,9 @@ type Pool struct { var phpValueKeyRegexp = regexp.MustCompile(`^php(_admin)?_(?:value|flag)\[(.+)]$`) -// parsePhpValues returns the PHP directives of the pool section. An admin directive takes -// precedence over a directive with the same name, as in php-fpm. -func parsePhpValues(section *ini.Section) map[string]string { +// parsePhpValues returns the PHP directives of the pool section with expand applied to the +// values. An admin directive takes precedence over a directive with the same name, as in php-fpm. +func parsePhpValues(section *ini.Section, expand func(string) string) map[string]string { values := make(map[string]string) isAdmin := make(map[string]bool) @@ -48,7 +49,7 @@ func parsePhpValues(section *ini.Section) map[string]string { continue } - values[name] = key.String() + values[name] = expand(key.String()) isAdmin[name] = admin } @@ -64,39 +65,39 @@ func fillPull(config *Config, iniConfig *ini.File, poolName string) error { return err } - key, err := section.GetKey("listen") - if err != nil { - return err + // php-fpm replaces each $pool in each directive with the name of the pool. + expand := func(value string) string { + return strings.ReplaceAll(value, "$pool", poolName) } - pool.Listen = key.String() + get := func(name string) (string, bool) { + key, err := section.GetKey(name) + if err != nil { + return "", false + } - key, err = section.GetKey("pm.status_path") - if err == nil { - pool.StatusPath = key.String() + return expand(key.String()), true } - key, err = section.GetKey("pm.status_listen") - if err == nil { - pool.StatusListen = key.String() + listen, ok := get("listen") + if !ok { + return errors.New(`key "listen" not exists`) } + pool.Listen = listen - key, err = section.GetKey("slowlog") - if err == nil { - pool.SlowlogPath = strings.Replace(key.String(), "$pool", poolName, 1) - } + pool.StatusPath, _ = get("pm.status_path") + pool.StatusListen, _ = get("pm.status_listen") + pool.SlowlogPath, _ = get("slowlog") - key, err = section.GetKey("request_slowlog_timeout") - if err == nil { - pool.RequestSlowlogTimeout, _ = strconv.Atoi(key.String()) + if value, ok := get("request_slowlog_timeout"); ok { + pool.RequestSlowlogTimeout, _ = strconv.Atoi(value) } pool.RequestSlowlogTraceDepth = 64 - key, err = section.GetKey("request_slowlog_trace_depth") - if err == nil { - pool.RequestSlowlogTraceDepth, _ = strconv.Atoi(key.String()) + if value, ok := get("request_slowlog_trace_depth"); ok { + pool.RequestSlowlogTraceDepth, _ = strconv.Atoi(value) } - pool.PhpValues = parsePhpValues(section) + pool.PhpValues = parsePhpValues(section, expand) config.Pools = append(config.Pools, pool) return nil diff --git a/pkg/phpfpm/config_test.go b/pkg/phpfpm/config_test.go index 677dfa2..3871bf3 100644 --- a/pkg/phpfpm/config_test.go +++ b/pkg/phpfpm/config_test.go @@ -24,11 +24,12 @@ func TestParse(t *testing.T) { assert.Equal(t, []Pool{ { Name: "nostatus", - Listen: "127.0.0.1:9001", - SlowlogPath: "/var/log/nostatus.slow.log", + Listen: "/run/php-fpm/nostatus.sock", + StatusListen: "/run/php-fpm/nostatus-status.sock", + SlowlogPath: "/var/log/nostatus/nostatus.slow.log", RequestSlowlogTimeout: 5, RequestSlowlogTraceDepth: 64, - PhpValues: map[string]string{}, + PhpValues: map[string]string{"session.save_path": "/var/lib/php/nostatus-sessions"}, }, { Name: "www", diff --git a/pkg/phpfpm/testdata/php-fpm.d/nostatus.conf b/pkg/phpfpm/testdata/php-fpm.d/nostatus.conf index 612e635..00f6b78 100644 --- a/pkg/phpfpm/testdata/php-fpm.d/nostatus.conf +++ b/pkg/phpfpm/testdata/php-fpm.d/nostatus.conf @@ -1,5 +1,7 @@ -; A pool without a status page. +; A pool without a status page. php-fpm replaces each $pool with the name of the pool. [nostatus] -listen = 127.0.0.1:9001 -slowlog = /var/log/$pool.slow.log +listen = /run/php-fpm/$pool.sock +pm.status_listen = /run/php-fpm/$pool-status.sock +slowlog = /var/log/$pool/$pool.slow.log request_slowlog_timeout = 5 +php_value[session.save_path] = /var/lib/php/$pool-sessions From 87ddb3d883150d0823f05bc93a31ade10247bdcb Mon Sep 17 00:00:00 2001 From: asp24 <488588+asp24@users.noreply.github.com> Date: Wed, 21 Oct 2026 21:48:59 +0200 Subject: [PATCH 13/18] Replace pflag and viper-lite with go-flags --- CHANGELOG.md | 5 + README.md | 17 ++- cmd/docker-fpm-wrapper/config.go | 98 +++++---------- cmd/docker-fpm-wrapper/config_test.go | 172 ++++++++++++++++++++++++++ cmd/docker-fpm-wrapper/main.go | 51 ++------ e2e/fpmargs_test.go | 18 +++ e2e/startfailure_test.go | 22 ++-- e2e/testdata/www/ini.php | 5 + go.mod | 10 +- go.sum | 27 +--- internal/zapx/loglevel.go | 39 ++++++ internal/zapx/loglevel_test.go | 39 ++++++ 12 files changed, 350 insertions(+), 153 deletions(-) create mode 100644 cmd/docker-fpm-wrapper/config_test.go create mode 100644 e2e/fpmargs_test.go create mode 100644 e2e/testdata/www/ini.php create mode 100644 internal/zapx/loglevel.go create mode 100644 internal/zapx/loglevel_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b5447a..5d91e00 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,11 @@ and this project adheres to [Semantic Versioning](http://semver.org). ### Changed - The minimum Go version is 1.27. Update all dependencies. +- Parse the flags and the environment variables with `go-flags` instead of `pflag` and `viper-lite`. The names of the flags and the environment variables do not change. These behaviors change: + - An empty environment variable sets an empty value. For example, `WRAPPER_PIPE=` disables the pipe. Before, the wrapper ignored empty environment variables. + - An argument before `--` that is not a flag of the wrapper stops the wrapper at start. Before, the wrapper ignored it. The wrapper passes the arguments after `--` to php-fpm, as before. + - `--log-level` accepts a name or a number from `-1` to `5`. Other values stop the wrapper at start. + - `--help` shows the environment variables and the defaults, and exits with code 0. - The release binaries are static (`CGO_ENABLED=0`). The `linux/amd64` binary of 1.0.2 needed glibc and did not start on Alpine images. - Replace the manual e2e test with automated e2e tests on testcontainers-go. - Replace Travis CI with GitHub Actions: lint, unit tests, and e2e tests on PHP 8.2–8.5. diff --git a/README.md b/README.md index 40fa231..52719ca 100644 --- a/README.md +++ b/README.md @@ -15,19 +15,28 @@ This project wraps around PHP-FPM, intercepting its logs and exposing Prometheus ## Available Configuration Options -The following options can be configured via command-line flags or environment variables: +The following options can be configured via command-line flags or environment variables. +The name of the environment variable is the name of the flag in upper case, with `_` instead of `-`: +for example, `FPM_CONFIG` for `--fpm-config`. A flag takes precedence over its environment variable. +Run `docker-fpm-wrapper --help` to see all options. + +The wrapper passes the arguments after `--` to php-fpm: + +```sh +docker-fpm-wrapper -f /usr/local/sbin/php-fpm -- -d memory_limit=256M +``` - **--log-level** - - *Description:* Sets the internal log level. Use `-1` for debug-level logging. + - *Description:* Sets the internal log level: a name (`debug`, `info`, `warn`, `error`) or a number from `-1` (debug) to `5` (fatal). - *Default:* `-1` - **--log-encoder** - - *Description:* Chooses the internal logging encoder. + - *Description:* Chooses the internal logging encoder: `auto`, `console`, or `json`. `auto` is `console` on a terminal and `json` otherwise. - *Default:* `auto` - **--fpm, -f** - *Description:* Path to the PHP-FPM executable. - - *Default:* _(No default, must be provided if not in PATH)_ + - *Default:* _(Required)_ - **--fpm-config, -c** - *Description:* Path to the PHP-FPM configuration file. diff --git a/cmd/docker-fpm-wrapper/config.go b/cmd/docker-fpm-wrapper/config.go index d9abaf7..f2b0b62 100644 --- a/cmd/docker-fpm-wrapper/config.go +++ b/cmd/docker-fpm-wrapper/config.go @@ -1,90 +1,60 @@ package main import ( - "strings" + "fmt" + "slices" "time" - "github.com/FZambia/viper-lite" + "github.com/jessevdk/go-flags" _ "github.com/joho/godotenv/autoload" - "github.com/spf13/pflag" + + "github.com/code-tool/docker-fpm-wrapper/internal/zapx" ) +// Config is the config of the wrapper. A flag takes precedence over its environment variable. type Config struct { - LogLevel string `mapstructure:"log-level"` - LogEncoder string `mapstructure:"log-encoder"` - - FpmPath string `mapstructure:"fpm"` - FpmConfigPath string `mapstructure:"fpm-config"` - - FpmNoErrlogProxy bool `mapstructure:"fpm-no-errlog"` - FpmNoSlowlogProxy bool `mapstructure:"fpm-no-slowlog"` - - // Logging proxy section - WrapperPipe string `mapstructure:"wrapper-pipe"` - WrapperSocket string `mapstructure:"wrapper-socket"` - LineBufferSize int `mapstructure:"line-buffer-size"` - - // - Listen string `mapstructure:"listen"` - MetricsPath string `mapstructure:"metrics-path"` - - ShutdownDelay time.Duration `mapstructure:"shutdown-delay"` - - SessionCleanupInterval time.Duration `mapstructure:"session-cleanup-interval"` -} - -func parseCommandLineFlags() { - pflag.String("log-level", "-1", "Log level. -1 debug ") - pflag.String("log-encoder", "auto", "Internal logging encoder") + LogLevel zapx.LogLevel `long:"log-level" env:"LOG_LEVEL" default:"-1" description:"Log level: a name (debug, info, warn, error) or a number (-1 is debug)"` + LogEncoder string `long:"log-encoder" env:"LOG_ENCODER" default:"auto" choice:"auto" choice:"console" choice:"json" description:"Internal logging encoder"` - pflag.StringP("fpm", "f", "", "path to php-fpm") - pflag.StringP("fpm-config", "c", "/etc/php/php-fpm.conf", "path to php-fpm config file") + FpmPath string `short:"f" long:"fpm" env:"FPM" required:"true" description:"Path to php-fpm"` + FpmConfigPath string `short:"c" long:"fpm-config" env:"FPM_CONFIG" default:"/etc/php/php-fpm.conf" description:"Path to the php-fpm config file"` - pflag.Bool("fpm-no-errlog", false, "Disable php-fpm errlog parsing and proxy") - pflag.Bool("fpm-no-slowlog", false, "Disable php-fpm slowlog parsing and proxy") + FpmNoErrlogProxy bool `long:"fpm-no-errlog" env:"FPM_NO_ERRLOG" description:"Disable php-fpm errlog parsing and proxy"` + FpmNoSlowlogProxy bool `long:"fpm-no-slowlog" env:"FPM_NO_SLOWLOG" description:"Disable php-fpm slowlog parsing and proxy"` // Logging proxy section - pflag.StringP("wrapper-pipe", "p", "/tmp/fpm-wrapper-pipe", "path to logging pipe, set '' to disable") - pflag.StringP("wrapper-socket", "s", "/tmp/fpm-wrapper.sock", "path to logging socket, set null to disable") - pflag.Uint("line-buffer-size", 16*1024, "Max log line size (in bytes)") + WrapperPipe string `short:"p" long:"wrapper-pipe" env:"WRAPPER_PIPE" default:"/tmp/fpm-wrapper-pipe" description:"Path to the logging pipe, set '' to disable"` + WrapperSocket string `short:"s" long:"wrapper-socket" env:"WRAPPER_SOCKET" default:"/tmp/fpm-wrapper.sock" description:"Path to the logging socket, set null to disable"` + LineBufferSize int `long:"line-buffer-size" env:"LINE_BUFFER_SIZE" default:"16384" description:"Max log line size (in bytes)"` // Prom section - pflag.String("listen", ":8080", "prometheus statistic addr") - pflag.String("metrics-path", "/metrics", "prometheus statistic path") + Listen string `long:"listen" env:"LISTEN" default:":8080" description:"Address of the Prometheus metrics endpoint"` + MetricsPath string `long:"metrics-path" env:"METRICS_PATH" default:"/metrics" description:"Path of the Prometheus metrics endpoint"` - pflag.Duration("shutdown-delay", 500*time.Millisecond, "Delay before process shutdown") + ShutdownDelay time.Duration `long:"shutdown-delay" env:"SHUTDOWN_DELAY" default:"500ms" description:"Delay before process shutdown"` - pflag.Duration("session-cleanup-interval", 0, "Interval of the removal of expired session files, 0 to disable") - - pflag.Parse() -} - -func parseAllFlags() error { - parseCommandLineFlags() - - viper.AutomaticEnv() - viper.SetEnvKeyReplacer(strings.NewReplacer("-", "_")) - - return viper.BindPFlags(pflag.CommandLine) + SessionCleanupInterval time.Duration `long:"session-cleanup-interval" env:"SESSION_CLEANUP_INTERVAL" default:"0" description:"Interval of the removal of expired session files, 0 to disable"` } -func CreateConfigFromViper(v *viper.Viper) (*Config, error) { - var conf Config - - err := v.UnmarshalExact(&conf) +// createConfig parses the command line and the environment. It returns the arguments after +// "--": the wrapper passes them to php-fpm. +func createConfig(args []string) (*Config, []string, error) { + var fpmArgs []string + if i := slices.Index(args, "--"); i >= 0 { + args, fpmArgs = args[:i], args[i+1:] + } - return &conf, err -} + var config Config -func createConfig() (*Config, error) { - if err := parseAllFlags(); err != nil { - return nil, err + parser := flags.NewParser(&config, flags.HelpFlag) + rest, err := parser.ParseArgs(args) + if err != nil { + return nil, nil, err } - config, err := CreateConfigFromViper(viper.GetViper()) - if err != nil { - return nil, err + if len(rest) > 0 { + return nil, nil, fmt.Errorf("unexpected arguments %q: put the arguments of php-fpm after --", rest) } - return config, nil + return &config, fpmArgs, nil } diff --git a/cmd/docker-fpm-wrapper/config_test.go b/cmd/docker-fpm-wrapper/config_test.go new file mode 100644 index 0000000..80655f4 --- /dev/null +++ b/cmd/docker-fpm-wrapper/config_test.go @@ -0,0 +1,172 @@ +package main + +import ( + "errors" + "testing" + "time" + + "github.com/jessevdk/go-flags" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/zap/zapcore" + + "github.com/code-tool/docker-fpm-wrapper/internal/zapx" +) + +// TestCreateConfig sets environment variables, so it does not run in parallel. +func TestCreateConfig(t *testing.T) { + defaults := Config{ + LogLevel: zapx.LogLevel(zapcore.DebugLevel), + LogEncoder: "auto", + FpmPath: "/usr/sbin/php-fpm", + FpmConfigPath: "/etc/php/php-fpm.conf", + WrapperPipe: "/tmp/fpm-wrapper-pipe", + WrapperSocket: "/tmp/fpm-wrapper.sock", + LineBufferSize: 16 * 1024, + Listen: ":8080", + MetricsPath: "/metrics", + ShutdownDelay: 500 * time.Millisecond, + } + with := func(change func(c *Config)) Config { + c := defaults + change(&c) + return c + } + + tests := []struct { + name string + args []string + env map[string]string + want Config + wantFpmArgs []string + wantErr string + }{ + { + name: "defaults", + args: []string{"-f", "/usr/sbin/php-fpm"}, + want: defaults, + }, + { + name: "flags", + args: []string{ + "--fpm", "/usr/sbin/php-fpm", "-c", "/etc/fpm.conf", + "--log-level", "-1", "--log-encoder", "json", + "--fpm-no-errlog", "--fpm-no-slowlog", + "-p", "", "-s", "null", "--line-buffer-size", "1024", + "--listen", ":9090", "--metrics-path", "/m", + "--shutdown-delay", "2s", "--session-cleanup-interval", "30m", + }, + want: Config{ + LogLevel: zapx.LogLevel(zapcore.DebugLevel), + LogEncoder: "json", + FpmPath: "/usr/sbin/php-fpm", + FpmConfigPath: "/etc/fpm.conf", + FpmNoErrlogProxy: true, + FpmNoSlowlogProxy: true, + WrapperPipe: "", + WrapperSocket: "null", + LineBufferSize: 1024, + Listen: ":9090", + MetricsPath: "/m", + ShutdownDelay: 2 * time.Second, + SessionCleanupInterval: 30 * time.Minute, + }, + }, + { + name: "environment", + env: map[string]string{ + "FPM": "/usr/sbin/php-fpm", + "LOG_LEVEL": "info", + "FPM_NO_ERRLOG": "true", + "WRAPPER_PIPE": "", + "SHUTDOWN_DELAY": "1s", + "SESSION_CLEANUP_INTERVAL": "30m", + }, + want: with(func(c *Config) { + c.LogLevel = zapx.LogLevel(zapcore.InfoLevel) + c.FpmNoErrlogProxy = true + c.WrapperPipe = "" + c.ShutdownDelay = time.Second + c.SessionCleanupInterval = 30 * time.Minute + }), + }, + { + name: "flag takes precedence over environment", + args: []string{"-f", "/usr/sbin/php-fpm", "--log-level", "error"}, + env: map[string]string{"LOG_LEVEL": "info"}, + want: with(func(c *Config) { c.LogLevel = zapx.LogLevel(zapcore.ErrorLevel) }), + }, + { + name: "arguments after double dash go to php-fpm", + args: []string{"-f", "/usr/sbin/php-fpm", "--", "-d", "memory_limit=1G", "--log-level", "info"}, + want: defaults, + wantFpmArgs: []string{"-d", "memory_limit=1G", "--log-level", "info"}, + }, + { + name: "php-fpm flag before double dash", + args: []string{"-f", "/usr/sbin/php-fpm", "--nodaemonize=false", "memory_limit=1G", "--", "-y", "/etc/fpm.conf"}, + wantErr: "unknown flag `nodaemonize'", + }, + { + name: "positional argument before double dash", + args: []string{"-f", "/usr/sbin/php-fpm", "memory_limit=1G", "--", "-y", "/etc/fpm.conf"}, + wantErr: `unexpected arguments ["memory_limit=1G"]: put the arguments of php-fpm after --`, + }, + { + name: "fpm path is not set", + wantErr: "the required flag `-f, --fpm' was not specified", + }, + { + name: "unknown log encoder", + args: []string{"-f", "/usr/sbin/php-fpm", "--log-encoder", "xml"}, + wantErr: "Invalid value `xml' for option `--log-encoder'", + }, + { + name: "invalid log level", + args: []string{"-f", "/usr/sbin/php-fpm", "--log-level", "verbose"}, + wantErr: `unknown log level "verbose"`, + }, + { + name: "invalid duration in environment", + args: []string{"-f", "/usr/sbin/php-fpm"}, + env: map[string]string{"SHUTDOWN_DELAY": "soon"}, + wantErr: `time: invalid duration "soon"`, + }, + { + name: "unknown flag", + args: []string{"-f", "/usr/sbin/php-fpm", "--fpm-pool", "www"}, + wantErr: "unknown flag `fpm-pool'", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + for name, value := range tt.env { + t.Setenv(name, value) + } + + got, fpmArgs, err := createConfig(tt.args) + if tt.wantErr != "" { + assert.ErrorContains(t, err, tt.wantErr) + return + } + + require.NoError(t, err) + assert.Equal(t, tt.want, *got) + if len(tt.wantFpmArgs) == 0 { + assert.Empty(t, fpmArgs) + } else { + assert.Equal(t, tt.wantFpmArgs, fpmArgs) + } + }) + } + + t.Run("help", func(t *testing.T) { + _, _, err := createConfig([]string{"--help"}) + + flagsErr, ok := errors.AsType[*flags.Error](err) + require.True(t, ok) + assert.Equal(t, flags.ErrHelp, flagsErr.Type) + assert.Contains(t, flagsErr.Message, "--session-cleanup-interval=") + assert.Contains(t, flagsErr.Message, "[$SESSION_CLEANUP_INTERVAL]") + }) +} diff --git a/cmd/docker-fpm-wrapper/main.go b/cmd/docker-fpm-wrapper/main.go index 297b88f..b227440 100644 --- a/cmd/docker-fpm-wrapper/main.go +++ b/cmd/docker-fpm-wrapper/main.go @@ -2,13 +2,14 @@ package main import ( "context" + "errors" "fmt" "net/http" "os" "os/signal" - "strconv" "syscall" + "github.com/jessevdk/go-flags" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/promhttp" "go.uber.org/zap" @@ -20,52 +21,21 @@ import ( "github.com/code-tool/docker-fpm-wrapper/pkg/phpfpm" ) -func findFpmArgs() []string { - doubleDashIndex := -1 - - for i := range os.Args { - if os.Args[i] == "--" { - doubleDashIndex = i - break - } - } - if doubleDashIndex == -1 || doubleDashIndex+1 == len(os.Args) { - return nil - } - - return os.Args[doubleDashIndex+1:] -} - func main() { - cfg, err := createConfig() + cfg, fpmArgs, err := createConfig(os.Args[1:]) + if flagsErr, ok := errors.AsType[*flags.Error](err); ok && flagsErr.Type == flags.ErrHelp { + fmt.Println(err) + os.Exit(0) + } if err != nil { - fmt.Printf("Can't create app config: %v\n", err) + _, _ = fmt.Fprintf(os.Stderr, "Can't create app config: %v\n", err) os.Exit(1) } syncStderr := zapcore.Lock(os.Stderr) - // cfg.LogLevel can be either int or string - // example: it can be -1 or debug - // try to parse it by string - logLever, err := zapcore.ParseLevel(cfg.LogLevel) + log, err := createLogger(cfg.LogEncoder, cfg.LogLevel.Level(), syncStderr) if err != nil { - // so, the string is not correct, try to parse it as int - logLeverRaw, err := strconv.Atoi(cfg.LogLevel) - if err != nil { - fmt.Printf("Can't parse log level '%v': %v\n", cfg.LogLevel, err) - os.Exit(1) - } - logLever = zapcore.Level(logLeverRaw) - } - - log, err := createLogger(cfg.LogEncoder, logLever, syncStderr) - if err != nil { - fmt.Printf("Can't create logger: %v\n", err) - os.Exit(1) - } - - if cfg.FpmPath == "" { - log.Error("php-fpm path not set") + _, _ = fmt.Fprintf(os.Stderr, "Can't create logger: %v\n", err) os.Exit(1) } @@ -118,7 +88,6 @@ func main() { } } - fpmArgs := findFpmArgs() fpmProcess := phpfpm. NewProcess(log, cfg.FpmPath, cfg.FpmConfigPath, os.Stdout, syncStderr, cfg.ShutdownDelay, env, fpmArgs...) diff --git a/e2e/fpmargs_test.go b/e2e/fpmargs_test.go new file mode 100644 index 0000000..c5e15a2 --- /dev/null +++ b/e2e/fpmargs_test.go @@ -0,0 +1,18 @@ +//go:build e2e + +package e2e + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +// TestFpmArgs examines the arguments after "--": the wrapper passes them to php-fpm. +func TestFpmArgs(t *testing.T) { + t.Parallel() + + w := startWrapper(t, "--log-level", "-1", "--", "-d", "memory_limit=77M") + + assert.Equal(t, "77M", w.mustRequest(t, wwwPort, "ini.php", "name=memory_limit")) +} diff --git a/e2e/startfailure_test.go b/e2e/startfailure_test.go index 73ba000..309d434 100644 --- a/e2e/startfailure_test.go +++ b/e2e/startfailure_test.go @@ -4,6 +4,7 @@ package e2e import ( "io" + "slices" "strings" "testing" @@ -27,7 +28,7 @@ func readLogs(t *testing.T, ctr *testcontainers.DockerContainer) []string { return strings.Split(string(data), "\n") } -// TestStartFailure examines the exit code and the log of the wrapper when it cannot start php-fpm. +// TestStartFailure examines the exit code and the output of the wrapper when it cannot start php-fpm. func TestStartFailure(t *testing.T) { t.Parallel() @@ -35,25 +36,25 @@ func TestStartFailure(t *testing.T) { name string args []string wantExitCode int - wantMessage string + wantOutput string // a part of a line of the output }{ { name: "fpm path is not set", args: []string{"--fpm-config", fpmConfigPath}, wantExitCode: 1, - wantMessage: "php-fpm path not set", + wantOutput: "the required flag `-f, --fpm' was not specified", }, { name: "fpm config does not exist", args: []string{"--fpm", fpmPath, "--fpm-config", "/nonexistent/php-fpm.conf"}, wantExitCode: 1, - wantMessage: "Can't parse fpm config", + wantOutput: `"message":"Can't parse fpm config"`, }, { name: "fpm binary does not exist", args: []string{"--fpm", "/nonexistent/php-fpm", "--fpm-config", fpmConfigPath}, wantExitCode: 1, - wantMessage: "Can't start php-fpm", + wantOutput: `"message":"Can't start php-fpm"`, }, { // The wrapper exits with the exit code of php-fpm: 78 is EX_CONFIG. @@ -72,15 +73,14 @@ func TestStartFailure(t *testing.T) { require.NoError(t, err) assert.Equal(t, tt.wantExitCode, state.ExitCode) - if tt.wantMessage == "" { + if tt.wantOutput == "" { return } - messages := make([]string, 0) - for _, entry := range parseEntries(readLogs(t, ctr)) { - messages = append(messages, entry.Message) - } - assert.Contains(t, messages, tt.wantMessage) + lines := readLogs(t, ctr) + assert.True(t, slices.ContainsFunc(lines, func(line string) bool { + return strings.Contains(line, tt.wantOutput) + }), "no line with %q in the output:\n%s", tt.wantOutput, strings.Join(lines, "\n")) }) } } diff --git a/e2e/testdata/www/ini.php b/e2e/testdata/www/ini.php new file mode 100644 index 0000000..6c33c78 --- /dev/null +++ b/e2e/testdata/www/ini.php @@ -0,0 +1,5 @@ + zapcore.FatalLevel { + return fmt.Errorf("log level %d is out of range [%d, %d]", number, zapcore.DebugLevel, zapcore.FatalLevel) + } + + *l = LogLevel(level) + + return nil +} + +func (l LogLevel) Level() zapcore.Level { + return zapcore.Level(l) +} diff --git a/internal/zapx/loglevel_test.go b/internal/zapx/loglevel_test.go new file mode 100644 index 0000000..eab9871 --- /dev/null +++ b/internal/zapx/loglevel_test.go @@ -0,0 +1,39 @@ +package zapx + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/zap/zapcore" +) + +func TestLogLevel_UnmarshalFlag(t *testing.T) { + tests := []struct { + name string + value string + want zapcore.Level + wantErr string + }{ + {name: "name", value: "warn", want: zapcore.WarnLevel}, + {name: "upper case name", value: "ERROR", want: zapcore.ErrorLevel}, + {name: "negative number", value: "-1", want: zapcore.DebugLevel}, + {name: "number", value: "2", want: zapcore.ErrorLevel}, + {name: "unknown name", value: "verbose", wantErr: `unknown log level "verbose"`}, + {name: "number below debug", value: "-2", wantErr: "log level -2 is out of range [-1, 5]"}, + {name: "number above fatal", value: "6", wantErr: "log level 6 is out of range [-1, 5]"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var level LogLevel + err := level.UnmarshalFlag(tt.value) + if tt.wantErr != "" { + assert.EqualError(t, err, tt.wantErr) + return + } + + require.NoError(t, err) + assert.Equal(t, tt.want, level.Level()) + }) + } +} From a6e11350b2dec14916faefb7ab2f3ebd4fd00941 Mon Sep 17 00:00:00 2001 From: asp24 <488588+asp24@users.noreply.github.com> Date: Wed, 21 Oct 2026 21:51:50 +0200 Subject: [PATCH 14/18] Do not cache the results of the e2e tests --- .github/workflows/test.yaml | 2 +- Makefile | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 25d3a7b..61e3544 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -50,6 +50,6 @@ jobs: - uses: actions/setup-go@v7 with: go-version-file: go.mod - - run: go test -race -tags e2e ./e2e/... + - run: go test -race -count=1 -tags e2e ./e2e/... env: E2E_PHP_IMAGE: ${{ matrix.image }} diff --git a/Makefile b/Makefile index 3dc9ff8..1bf38e7 100644 --- a/Makefile +++ b/Makefile @@ -7,5 +7,6 @@ test: go test -race ./... # The e2e tests need Docker. Set E2E_PHP_IMAGE to use a different php-fpm image. +# The tests build the wrapper at run time, so the test cache cannot see a change of the code. e2e: - go test -race -tags e2e ./e2e/... + go test -race -count=1 -tags e2e ./e2e/... From d1c198c240e9a83fc31ab10c6d9f459af29fb73a Mon Sep 17 00:00:00 2001 From: asp24 <488588+asp24@users.noreply.github.com> Date: Wed, 21 Oct 2026 21:55:42 +0200 Subject: [PATCH 15/18] update dependencies --- go.mod | 45 +++++++++++++++++++++++---------------------- go.sum | 49 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+), 22 deletions(-) diff --git a/go.mod b/go.mod index e9c984e..da01c3d 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +6,7 @@ require ( github.com/jessevdk/go-flags v1.6.1 github.com/joho/godotenv v1.5.1 github.com/mattn/go-isatty v0.0.24 - github.com/moby/moby/api v1.55.0 + github.com/moby/moby/api v1.56.1 github.com/otiai10/copy v1.14.1 github.com/prometheus/client_golang v1.25.0 github.com/prometheus/common v0.72.1 @@ -21,55 +21,56 @@ require ( require ( dario.cat/mergo v1.0.2 // indirect - github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect - github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/Azure/go-ansiterm v0.0.0-20261013165250-51075c6ea6bc // indirect + github.com/Microsoft/go-winio v0.6.4 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/containerd/errdefs v1.0.0 // indirect github.com/containerd/errdefs/pkg v0.3.0 // indirect - github.com/containerd/log v0.1.0 // indirect + github.com/containerd/log v0.2.0 // indirect github.com/containerd/platforms v0.2.1 // indirect github.com/cpuguy83/dockercfg v0.3.2 // indirect github.com/distribution/reference v0.6.0 // indirect - github.com/docker/go-connections v0.7.0 // indirect + github.com/docker/go-connections v0.8.2 // indirect github.com/docker/go-units v0.5.0 // indirect - github.com/ebitengine/purego v0.10.1 // indirect + github.com/ebitengine/purego v0.11.2 // indirect github.com/felixge/httpsnoop v1.1.0 // indirect - github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/logr v1.4.4 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-ole/go-ole v1.3.0 // indirect github.com/google/uuid v1.6.0 // indirect github.com/klauspost/compress v1.20.3 // indirect - github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e // indirect - github.com/magiconair/properties v1.8.10 // indirect + github.com/lufia/plan9stats v0.0.0-20260802145828-341c2f0c90b5 // indirect + github.com/magiconair/properties v1.18.12 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect - github.com/moby/go-archive v0.2.0 // indirect - github.com/moby/moby/client v0.5.0 // indirect + github.com/moby/go-archive v0.3.3 // indirect + github.com/moby/moby/client v0.6.2 // indirect github.com/moby/patternmatcher v0.6.1 // indirect github.com/moby/sys/sequential v0.7.0 // indirect - github.com/moby/sys/user v0.4.0 // indirect - github.com/moby/sys/userns v0.1.0 // indirect + github.com/moby/sys/user v0.4.1 // indirect + github.com/moby/sys/userns v0.2.1 // indirect github.com/moby/term v0.5.2 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.1 // indirect github.com/otiai10/mint v1.6.3 // indirect - github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect + github.com/power-devops/perfstat v0.0.0-20260916203055-22a1a467d9f0 // indirect github.com/prometheus/client_model v0.6.3 // indirect - github.com/shirou/gopsutil/v4 v4.26.6 // indirect - github.com/sirupsen/logrus v1.9.4 // indirect + github.com/shirou/gopsutil/v4 v4.26.9 // indirect + github.com/sirupsen/logrus v1.10.2 // indirect github.com/tklauser/go-sysconf v0.4.0 // indirect github.com/tklauser/numcpus v0.12.0 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect - go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 // indirect - go.opentelemetry.io/otel v1.44.0 // indirect - go.opentelemetry.io/otel/metric v1.44.0 // indirect - go.opentelemetry.io/otel/trace v1.44.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.72.0 // indirect + go.opentelemetry.io/otel v1.47.0 // indirect + go.opentelemetry.io/otel/log v1.47.0 // indirect + go.opentelemetry.io/otel/metric v1.47.0 // indirect + go.opentelemetry.io/otel/trace v1.47.0 // indirect go.uber.org/multierr v1.11.0 // indirect go.yaml.in/yaml/v3 v3.0.5 // indirect - golang.org/x/crypto v0.54.0 // indirect - golang.org/x/sync v0.22.0 // indirect + golang.org/x/crypto v0.58.0 // indirect + golang.org/x/sync v0.24.0 // indirect google.golang.org/protobuf v1.36.12 // indirect ) diff --git a/go.sum b/go.sum index 0381223..a1ec091 100644 --- a/go.sum +++ b/go.sum @@ -4,8 +4,12 @@ github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8af github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/Azure/go-ansiterm v0.0.0-20261013165250-51075c6ea6bc h1:58s5YzGPNAP7+e1uemaL0ax/NPSeJOHdRpRIowd+JDk= +github.com/Azure/go-ansiterm v0.0.0-20261013165250-51075c6ea6bc/go.mod h1:DMNF6Kxv+EcuUvVuNkvC3f+BjEEmc/Fn9aU+qiD8zpU= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/Microsoft/go-winio v0.6.4 h1:kJF2pamtYCWXf0xAT8sAkwKC0pMl2HYBrRPHAqyUc+g= +github.com/Microsoft/go-winio v0.6.4/go.mod h1:1xl5JXG4ze/m74GH6Dc4Mwxfc3VL5siRVQjIAlHH07U= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= @@ -18,6 +22,8 @@ github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151X github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= +github.com/containerd/log v0.2.0 h1:BewD/umNgVnoczglOpX8eRMyEy5t5iPlu5AIpnWDONc= +github.com/containerd/log v0.2.0/go.mod h1:/M7L7CXKcPTfNC74XzaK+5H5KbO5+4lJVpuVI6vRLoM= github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A= github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw= github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA= @@ -30,15 +36,21 @@ github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5Qvfr github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= github.com/docker/go-connections v0.7.0 h1:6SsRfJddP22WMrCkj19x9WKjEDTB+ahsdiGYf0mN39c= github.com/docker/go-connections v0.7.0/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q= +github.com/docker/go-connections v0.8.2 h1:9d9DEokx5W7qlr0shkFXIxEA7+TTD+llwzcykSlxMNc= +github.com/docker/go-connections v0.8.2/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q= github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= github.com/ebitengine/purego v0.10.1 h1:dewVBCBT2GaMu1SrNTYxQhgQBethzfhiwvZiLGP/qyY= github.com/ebitengine/purego v0.10.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= +github.com/ebitengine/purego v0.11.2 h1:WWiTx8rD8zYdOwSj8O/NiNbsOvSAgJMTlyV1XlbUSeU= +github.com/ebitengine/purego v0.11.2/go.mod h1:DCHPP08djqhNSoTfImcnHYQRZmd0qhakvrozqaEYhGQ= github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeORc= github.com/felixge/httpsnoop v1.1.0/go.mod h1:Zqxgdd+1Rkcz8euOqdr7lqgCRJztwr5hp9vDSi5UZCE= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8= +github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= @@ -58,26 +70,40 @@ github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0 github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e h1:Q6MvJtQK/iRcRtzAscm/zF23XxJlbECiGPyRicsX+Ak= github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg= +github.com/lufia/plan9stats v0.0.0-20260802145828-341c2f0c90b5 h1:eveIIGn4BGM3qknO74omf6HYr30/exH+eVUTuAgwjZ0= +github.com/lufia/plan9stats v0.0.0-20260802145828-341c2f0c90b5/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg= github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE= github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/magiconair/properties v1.18.12 h1:sT9zQpvTB3B4gzrX0tmZNTEaGyg8Zw55MFYRE32Mr9I= +github.com/magiconair/properties v1.18.12/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= github.com/moby/go-archive v0.2.0 h1:zg5QDUM2mi0JIM9fdQZWC7U8+2ZfixfTYoHL7rWUcP8= github.com/moby/go-archive v0.2.0/go.mod h1:mNeivT14o8xU+5q1YnNrkQVpK+dnNe/K6fHqnTg4qPU= +github.com/moby/go-archive v0.3.3 h1:OxxR9paxsluYi+zDUEXTTaIxtkK3viymW+Ka7vRhhME= +github.com/moby/go-archive v0.3.3/go.mod h1:Npdv43fFqlhZW7Xo8fbm3ZMYFvAGNviUPqX21VERbcE= github.com/moby/moby/api v1.55.0 h1:2/sexvQyqIWS8pRSCFddBfpW2qE7vR7FCL+vN8pxwMc= github.com/moby/moby/api v1.55.0/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs= +github.com/moby/moby/api v1.56.1 h1:PpWkvVPB7Fr/No8w+TfyJ/I6rWZ2YPZhT3JorIg+06c= +github.com/moby/moby/api v1.56.1/go.mod h1:sZ+THbVWkjOmBPPfbnzdD/G1LuIexWhqlSHHPTDQ1Uk= github.com/moby/moby/client v0.5.0 h1:5XhyPk2fuOWf6RlSFa3MkIIgDZkF25xToXW8Q/BH7cc= github.com/moby/moby/client v0.5.0/go.mod h1:rcVpF8ncl9vo5gaIBdol6CnbEtSj1uxMvEV/UrykF/s= +github.com/moby/moby/client v0.6.2 h1:ekSPlMccrZGcw6Scb8T9D39qE0mB+XupU/yS0zNw9N4= +github.com/moby/moby/client v0.6.2/go.mod h1:DGcnO7qmPJrqobx/ggQgQ/AUk+2wgVdQv7uxB0/mEsc= github.com/moby/patternmatcher v0.6.1 h1:qlhtafmr6kgMIJjKJMDmMWq7WLkKIo23hsrpR3x084U= github.com/moby/patternmatcher v0.6.1/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= github.com/moby/sys/sequential v0.7.0 h1:ASQNGNROJSuOO6LL6bPHbKvuZu6NU8P4ldPWk31zj/8= github.com/moby/sys/sequential v0.7.0/go.mod h1:NfSTAp6V3fw4tmkD62PEcOKeZKquXT8VKCkf7aVR79o= github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs= github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs= +github.com/moby/sys/user v0.4.1 h1:RgjRlaDKi/Xmyrz4t8lyzXT6v2ooFeO/7xtchmhVWE0= +github.com/moby/sys/user v0.4.1/go.mod h1:E9QsW5WRe1kUAf7kW8hXKwu1uhsZEAdPLYHYSDudF4Y= github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g= github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28= +github.com/moby/sys/userns v0.2.1 h1:4OvdM7BcPkASbuouHsbW3aeMJSFlYDldBRnXVZhaRk8= +github.com/moby/sys/userns v0.2.1/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28= github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= @@ -93,6 +119,8 @@ github.com/otiai10/mint v1.6.3/go.mod h1:MJm72SBthJjz8qhefc4z1PYEieWmy8Bku7CjcAq github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU= github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= +github.com/power-devops/perfstat v0.0.0-20260916203055-22a1a467d9f0 h1:XA01Vk/wv9YikCi1V51yRzIHPMT5of9+cMpZoZvDn/M= +github.com/power-devops/perfstat v0.0.0-20260916203055-22a1a467d9f0/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= github.com/prometheus/client_golang v1.25.0 h1:cDtW1TcFsMi1RJc8khwCKB4VhzwV04aKqbYtNOXuiK0= github.com/prometheus/client_golang v1.25.0/go.mod h1:j2X/4/kaQHLPnmyEU8zjmkndo1T317VpCIQEml8LTKk= github.com/prometheus/client_model v0.6.3 h1:O0jaTVAYNxTHYInEPFJt5I3+sN8zqBtVMPTB1qyxiEo= @@ -103,8 +131,12 @@ github.com/prometheus/procfs v0.22.0 h1:6q9+/JL9IKAPbCmBrv9n5O5Ty3NKnciV5X7YGw0o github.com/prometheus/procfs v0.22.0/go.mod h1:CvmFr/GVhIjIvWJZW3tgkODBQMRIf0EyWMQLHCHab58= github.com/shirou/gopsutil/v4 v4.26.6 h1:Mzr/npDtQC/xpeEuQKHZt8Zo9CmPvhTj8nkR8w5TLDs= github.com/shirou/gopsutil/v4 v4.26.6/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ= +github.com/shirou/gopsutil/v4 v4.26.9 h1:CaBo/hBFqvlJoLvAQEKdHdf5GD97/MN2ACfyv63+2Ig= +github.com/shirou/gopsutil/v4 v4.26.9/go.mod h1:nKH+8wX2zxr/mDbsR+AAy6Qd2z4ZOQ7wfQaEp+yrYmg= github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= +github.com/sirupsen/logrus v1.10.2 h1:G2SED73/qrAu6YwbdxOD6peLkCBI3z7L+ykJFTXJBBo= +github.com/sirupsen/logrus v1.10.2/go.mod h1:SLEg8TqYulVKKfIGHldVp2K2aYz2DKSVBq4g/H5bR7Q= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= @@ -131,16 +163,28 @@ go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 h1:8tvICD4vSTOOsNrsI4Ljf6C+6UKvpTEH5XY3JMoyPoo= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0/go.mod h1:z9+yiacE0IHRqM4qFfkbt/JYlmYXgss8GY/jXoNuPJI= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.72.0 h1:LxwW/9ctSCv+QkE/cLR7M91ZIkXNMqJtEMi1vCw9U8s= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.72.0/go.mod h1:tOsftB4SslBwwErVEPaenU2RpThXWPIU8DoJHEC4dyw= go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= +go.opentelemetry.io/otel v1.47.0 h1:j7ALJ/zgkS7Z6aeJW09p8VC9804bC+PpeTfCD4XPnOM= +go.opentelemetry.io/otel v1.47.0/go.mod h1:8wS9O2qfXrYrzp6hIF/HOYJJf/wIhFPhR2xLuP+iXQU= +go.opentelemetry.io/otel/log v1.47.0 h1:cOTS1CcLbSQeZKanGJ+0JpF/+t4PELi3O3bbl2lqCcI= +go.opentelemetry.io/otel/log v1.47.0/go.mod h1:9byitSQ5pLC6PpqwGXjqdMKya6ZTswHRZh2vvXT33nw= go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= +go.opentelemetry.io/otel/metric v1.47.0 h1:4PptaldXx3Eat1XjMZ68pPJEs5wrhlemctZE9a3UdWY= +go.opentelemetry.io/otel/metric v1.47.0/go.mod h1:ADGSXxRrXM6bjbvLo535EstVFlPpPYZm4LBKixjDHwU= go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58= go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0= +go.opentelemetry.io/otel/sdk v1.47.0 h1:zWXEr4j2lFefG87TU6Yg8a7ngfohIKFZHKp0Hf5hC6I= go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI= go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA= +go.opentelemetry.io/otel/sdk/metric v1.47.0 h1:lfISg2j93VT6yqdk9OfUaZmw/GfcZqCCV3jdXtsPnKw= go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= +go.opentelemetry.io/otel/trace v1.47.0 h1:JOjX/Oci8K94QHddo+bbfya/Ai/nf6/dt9ZfrFNWSrM= +go.opentelemetry.io/otel/trace v1.47.0/go.mod h1:jNaSLa2PZEYFG6fRjJABAu+bw4FS08uDmPg28lTghu0= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= @@ -153,8 +197,12 @@ go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= +golang.org/x/crypto v0.58.0 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54= +golang.org/x/crypto v0.58.0/go.mod h1:Xh+kl5A+M0gMiWDytH2Hhr5Z7WbNCpv/eB/EFgfSk+w= golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sync v0.24.0 h1:iqiDrGoDgyuvAKrhLQthUEzU/R378N78gXbBjxed7oA= +golang.org/x/sync v0.24.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -163,6 +211,7 @@ golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA= golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= +golang.org/x/term v0.47.0 h1:cpyOwQkr2Nzxe2p6KnrTG7Ei2wtzjsuSjhpXXepZ/BM= google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc= google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= From 1f947db2a3452269034b239fe64ff819a723a32e Mon Sep 17 00:00:00 2001 From: asp24 <488588+asp24@users.noreply.github.com> Date: Wed, 21 Oct 2026 22:09:49 +0200 Subject: [PATCH 16/18] Run the e2e tests on PHP 5.6 and 7.4 --- .github/workflows/test.yaml | 3 + CHANGELOG.md | 2 +- README.md | 3 +- e2e/errlog_test.go | 10 +- e2e/main_test.go | 28 +--- e2e/suite_test.go | 127 ++++++++++++++++++ .../{php-fpm.conf => php-fpm.conf.tmpl} | 5 +- e2e/testdata/www/applog.php | 7 +- e2e/testdata/www/index.php | 2 - e2e/testdata/www/ini.php | 4 +- e2e/testdata/www/session.php | 4 +- e2e/testdata/www/sleep.php | 4 +- e2e/testdata/www/slow.php | 6 +- e2e/testdata/www/stderr.php | 4 +- e2e/wrapper_test.go | 4 +- 15 files changed, 162 insertions(+), 51 deletions(-) create mode 100644 e2e/suite_test.go rename e2e/testdata/{php-fpm.conf => php-fpm.conf.tmpl} (86%) diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 61e3544..13e9a4f 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -40,6 +40,9 @@ jobs: fail-fast: false matrix: image: + # PHP 5.6 and 7.4 are EOL, but the wrapper must support them. + - php:5.6-fpm-alpine + - php:7.4-fpm-alpine - php:8.2-fpm-alpine - php:8.3-fpm-alpine - php:8.4-fpm-alpine diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d91e00..a50d192 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,7 +23,7 @@ and this project adheres to [Semantic Versioning](http://semver.org). - `--help` shows the environment variables and the defaults, and exits with code 0. - The release binaries are static (`CGO_ENABLED=0`). The `linux/amd64` binary of 1.0.2 needed glibc and did not start on Alpine images. - Replace the manual e2e test with automated e2e tests on testcontainers-go. -- Replace Travis CI with GitHub Actions: lint, unit tests, and e2e tests on PHP 8.2–8.5. +- Replace Travis CI with GitHub Actions: lint, unit tests, and e2e tests on PHP 5.6, 7.4, and 8.2–8.5. ### Fixed diff --git a/README.md b/README.md index 52719ca..858f744 100644 --- a/README.md +++ b/README.md @@ -100,7 +100,8 @@ Run the checks before you push: - `make e2e` runs the end-to-end tests. These tests need Docker. The end-to-end tests start the wrapper with php-fpm in a container (testcontainers-go). -The default image is `php:8.5-fpm-alpine`. To use a different image, set `E2E_PHP_IMAGE`: +The default image is `php:8.5-fpm-alpine`. CI also runs the tests on PHP 5.6, 7.4, and 8.2–8.5. +The PHP scripts of the tests must work on PHP 5.6. To use a different image, set `E2E_PHP_IMAGE`: ```sh E2E_PHP_IMAGE=php:8.2-fpm make e2e diff --git a/e2e/errlog_test.go b/e2e/errlog_test.go index 8cc8820..901a538 100644 --- a/e2e/errlog_test.go +++ b/e2e/errlog_test.go @@ -17,8 +17,14 @@ func TestErrorLogProxy(t *testing.T) { // The "raw" pool writes a line in an unknown format to the error log before the "www" pool // writes its line. The wrapper must write the raw line without a change and keep parsing. - assert.Equal(t, "ok", w.mustRequest(t, rawPort, "stderr.php", "message=raw+stderr+line")) - w.waitForLine(t, "raw stderr line") + t.Run("raw line is written without a change", func(t *testing.T) { + if !env.hasDecorateWorkersOutput() { + t.Skip("php-fpm before PHP 7.3 has no decorate_workers_output") + } + + assert.Equal(t, "ok", w.mustRequest(t, rawPort, "stderr.php", "message=raw+stderr+line")) + w.waitForLine(t, "raw stderr line") + }) assert.Equal(t, "ok", w.mustRequest(t, wwwPort, "stderr.php", "")) diff --git a/e2e/main_test.go b/e2e/main_test.go index e3b6db1..b56ad7d 100644 --- a/e2e/main_test.go +++ b/e2e/main_test.go @@ -6,11 +6,9 @@ package e2e import ( "cmp" + "context" "fmt" "os" - "os/exec" - "path/filepath" - "runtime" "testing" "time" ) @@ -34,9 +32,9 @@ const ( eventuallyTick = 50 * time.Millisecond ) -// wrapperBinary is the path of the wrapper binary on the host. TestMain builds the binary once -// for all tests, and the go tool gives no other way to share it with the tests. -var wrapperBinary string //nolint:gochecknoglobals // TestMain sets it before the tests start. +// env holds the facts that TestMain finds once for all tests. The go tool gives no other way +// to share them with the tests. +var env suite //nolint:gochecknoglobals // TestMain sets it before the tests start. func TestMain(m *testing.M) { os.Exit(run(m)) @@ -50,8 +48,8 @@ func run(m *testing.M) int { } defer func() { _ = os.RemoveAll(dir) }() - wrapperBinary = filepath.Join(dir, "docker-fpm-wrapper") - if err := buildWrapper(wrapperBinary); err != nil { + env, err = newSuite(context.Background(), dir, phpImage()) + if err != nil { _, _ = fmt.Fprintln(os.Stderr, err) return 1 } @@ -59,20 +57,6 @@ func run(m *testing.M) int { return m.Run() } -// buildWrapper builds a static Linux binary for the architecture of the host. -func buildWrapper(output string) error { - cmd := exec.Command("go", "build", "-o", output, "../cmd/docker-fpm-wrapper") - cmd.Env = append(os.Environ(), "CGO_ENABLED=0", "GOOS=linux", "GOARCH="+runtime.GOARCH) - cmd.Stdout = os.Stderr - cmd.Stderr = os.Stderr - - if err := cmd.Run(); err != nil { - return fmt.Errorf("build the wrapper: %w", err) - } - - return nil -} - func phpImage() string { return cmp.Or(os.Getenv("E2E_PHP_IMAGE"), defaultPHPImage) } diff --git a/e2e/suite_test.go b/e2e/suite_test.go new file mode 100644 index 0000000..e1ee5ff --- /dev/null +++ b/e2e/suite_test.go @@ -0,0 +1,127 @@ +//go:build e2e + +package e2e + +import ( + "context" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "runtime" + "strconv" + "strings" + "text/template" + + "github.com/testcontainers/testcontainers-go" + "github.com/testcontainers/testcontainers-go/wait" +) + +// phpVersionDecorateWorkersOutput is the first PHP version with decorate_workers_output. +const phpVersionDecorateWorkersOutput = 70300 + +// buildWrapper builds a static Linux binary for the architecture of the host. +func buildWrapper(output string) error { + cmd := exec.Command("go", "build", "-o", output, "../cmd/docker-fpm-wrapper") + cmd.Env = append(os.Environ(), "CGO_ENABLED=0", "GOOS=linux", "GOARCH="+runtime.GOARCH) + cmd.Stdout = os.Stderr + cmd.Stderr = os.Stderr + + if err := cmd.Run(); err != nil { + return fmt.Errorf("build the wrapper: %w", err) + } + + return nil +} + +// readPHPVersionID runs PHP in the image and returns its PHP_VERSION_ID. +func readPHPVersionID(ctx context.Context, image string) (int, error) { + ctr, err := testcontainers.Run(ctx, image, + testcontainers.WithCmd("php", "-r", "echo PHP_VERSION_ID;"), + testcontainers.WithWaitStrategy(wait.ForExit()), + ) + if ctr != nil { + defer func() { _ = ctr.Terminate(context.WithoutCancel(ctx)) }() + } + if err != nil { + return 0, fmt.Errorf("run php: %w", err) + } + + logs, err := ctr.Logs(ctx) + if err != nil { + return 0, fmt.Errorf("read the output of php: %w", err) + } + defer func() { _ = logs.Close() }() + + out, err := io.ReadAll(logs) + if err != nil { + return 0, fmt.Errorf("read the output of php: %w", err) + } + + versionID, err := strconv.Atoi(strings.TrimSpace(string(out))) + if err != nil { + return 0, fmt.Errorf("parse PHP_VERSION_ID: %w", err) + } + + return versionID, nil +} + +// renderFpmConfig renders testdata/php-fpm.conf.tmpl for the PHP version into output. +func renderFpmConfig(output string, phpVersionID int) error { + tmpl, err := template.ParseFiles("testdata/php-fpm.conf.tmpl") + if err != nil { + return fmt.Errorf("parse the php-fpm config template: %w", err) + } + + f, err := os.Create(output) + if err != nil { + return fmt.Errorf("create the php-fpm config: %w", err) + } + defer func() { _ = f.Close() }() + + data := struct{ DecorateWorkersOutput bool }{ + DecorateWorkersOutput: phpVersionID >= phpVersionDecorateWorkersOutput, + } + if err := tmpl.Execute(f, data); err != nil { + return fmt.Errorf("render the php-fpm config: %w", err) + } + + return f.Close() +} + +// suite holds the facts that TestMain finds once for all tests. +type suite struct { + wrapperBinary string // the path of the wrapper binary on the host + fpmConfig string // the path of the rendered php-fpm config on the host + phpVersionID int // PHP_VERSION_ID of the image +} + +// newSuite builds the wrapper and prepares the php-fpm config for image in dir. +func newSuite(ctx context.Context, dir, image string) (suite, error) { + s := suite{ + wrapperBinary: filepath.Join(dir, "docker-fpm-wrapper"), + fpmConfig: filepath.Join(dir, "php-fpm.conf"), + } + + if err := buildWrapper(s.wrapperBinary); err != nil { + return suite{}, err + } + + versionID, err := readPHPVersionID(ctx, image) + if err != nil { + return suite{}, err + } + s.phpVersionID = versionID + + if err := renderFpmConfig(s.fpmConfig, versionID); err != nil { + return suite{}, err + } + + return s, nil +} + +// hasDecorateWorkersOutput tells if php-fpm of the image has decorate_workers_output. +func (s suite) hasDecorateWorkersOutput() bool { + return s.phpVersionID >= phpVersionDecorateWorkersOutput +} diff --git a/e2e/testdata/php-fpm.conf b/e2e/testdata/php-fpm.conf.tmpl similarity index 86% rename from e2e/testdata/php-fpm.conf rename to e2e/testdata/php-fpm.conf.tmpl index b0d0452..c4c8627 100644 --- a/e2e/testdata/php-fpm.conf +++ b/e2e/testdata/php-fpm.conf.tmpl @@ -1,4 +1,5 @@ -; The php-fpm config for the e2e tests. +; The php-fpm config for the e2e tests. TestMain renders it with text/template for the PHP +; version of the image: decorate_workers_output is PHP 7.3+. ; The wrapper replaces error_log and slowlog with FIFOs and parses them. [global] @@ -42,4 +43,6 @@ slowlog = /tmp/php-fpm-$pool.slow.log request_slowlog_timeout = 1 clear_env = no catch_workers_output = yes +{{- if .DecorateWorkersOutput}} decorate_workers_output = no +{{- end}} diff --git a/e2e/testdata/www/applog.php b/e2e/testdata/www/applog.php index d75b2af..10549ee 100644 --- a/e2e/testdata/www/applog.php +++ b/e2e/testdata/www/applog.php @@ -1,10 +1,9 @@ Date: Wed, 21 Oct 2026 22:24:25 +0200 Subject: [PATCH 17/18] Read the php-fpm config with php-fpm -tt and run php-fpm with an overlay config --- CHANGELOG.md | 7 + README.md | 24 +- cmd/docker-fpm-wrapper/config.go | 1 + cmd/docker-fpm-wrapper/config_test.go | 4 +- cmd/docker-fpm-wrapper/fpmconfig.go | 107 +++++ cmd/docker-fpm-wrapper/fpmconfig_test.go | 164 ++++++++ cmd/docker-fpm-wrapper/main.go | 33 +- e2e/fpmconfig_test.go | 60 +++ e2e/startfailure_test.go | 27 +- e2e/testdata/php-fpm.conf.tmpl | 6 +- e2e/wrapper_test.go | 21 +- go.mod | 4 - go.sum | 80 +--- pkg/phpfpm/config.go | 234 ++++++----- pkg/phpfpm/config_test.go | 172 +++++--- pkg/phpfpm/overlay.go | 78 ++++ pkg/phpfpm/overlay_test.go | 87 ++++ pkg/phpfpm/testdata/dump-5.6.txt | 93 +++++ pkg/phpfpm/testdata/dump-7.4.txt | 101 +++++ pkg/phpfpm/testdata/dump-8.5.txt | 104 +++++ pkg/phpfpm/testdata/php-fpm.conf | 125 ------ pkg/phpfpm/testdata/php-fpm.d/nostatus.conf | 7 - pkg/phpfpm/testdata/php-fpm.d/www.conf | 417 -------------------- 23 files changed, 1153 insertions(+), 803 deletions(-) create mode 100644 cmd/docker-fpm-wrapper/fpmconfig.go create mode 100644 cmd/docker-fpm-wrapper/fpmconfig_test.go create mode 100644 e2e/fpmconfig_test.go create mode 100644 pkg/phpfpm/overlay.go create mode 100644 pkg/phpfpm/overlay_test.go create mode 100644 pkg/phpfpm/testdata/dump-5.6.txt create mode 100644 pkg/phpfpm/testdata/dump-7.4.txt create mode 100644 pkg/phpfpm/testdata/dump-8.5.txt delete mode 100644 pkg/phpfpm/testdata/php-fpm.conf delete mode 100644 pkg/phpfpm/testdata/php-fpm.d/nostatus.conf delete mode 100644 pkg/phpfpm/testdata/php-fpm.d/www.conf diff --git a/CHANGELOG.md b/CHANGELOG.md index a50d192..4e723b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,13 @@ and this project adheres to [Semantic Versioning](http://semver.org). ### Changed - The minimum Go version is 1.27. Update all dependencies. +- The wrapper reads the php-fpm config with `php-fpm -tt` and runs php-fpm with an overlay config in `--run-dir`. The overlay moves `error_log` and the slowlogs to FIFOs in `--run-dir`. Before, the wrapper parsed the config itself and replaced the files at `error_log` and `slowlog` with FIFOs. These problems are gone: + - The wrapper did not see `error_log` in an included file, so the error log of the official Docker images was not proxied. + - The wrapper did not find a relative `include`, for example `include=etc/php-fpm.d/*.conf` of the PHP 7.4 images. + - Without root, the wrapper could not create a FIFO for an `error_log` in `/var/log`. +- `error_log = syslog` also goes through the proxy. Use `--fpm-no-errlog` to keep it. +- The slowlog parser uses the `request_slowlog_trace_depth` of php-fpm (default 20). Before, the default was 64. +- When php-fpm rejects the config at start, the wrapper exits with the exit code of php-fpm, for example 78. - Parse the flags and the environment variables with `go-flags` instead of `pflag` and `viper-lite`. The names of the flags and the environment variables do not change. These behaviors change: - An empty environment variable sets an empty value. For example, `WRAPPER_PIPE=` disables the pipe. Before, the wrapper ignored empty environment variables. - An argument before `--` that is not a flag of the wrapper stops the wrapper at start. Before, the wrapper ignored it. The wrapper passes the arguments after `--` to php-fpm, as before. diff --git a/README.md b/README.md index 858f744..c1e0cf2 100644 --- a/README.md +++ b/README.md @@ -42,12 +42,16 @@ docker-fpm-wrapper -f /usr/local/sbin/php-fpm -- -d memory_limit=256M - *Description:* Path to the PHP-FPM configuration file. - *Default:* `/etc/php/php-fpm.conf` +- **--run-dir** + - *Description:* Directory for the generated php-fpm config and the log FIFOs. The user of the wrapper must have write access to it. + - *Default:* `/tmp/docker-fpm-wrapper` + - **--fpm-no-errlog** - - *Description:* Disable php-fpm error log parsing and proxy. + - *Description:* Disable php-fpm error log parsing and proxy. php-fpm then uses the `error_log` of its config. - *Default:* `false` - **--fpm-no-slowlog** - - *Description:* Disable php-fpm slow log parsing and proxy. + - *Description:* Disable php-fpm slow log parsing and proxy. php-fpm then uses the `slowlog` of each pool. - *Default:* `false` - **--wrapper-pipe, -p** @@ -78,6 +82,22 @@ docker-fpm-wrapper -f /usr/local/sbin/php-fpm -- -d memory_limit=256M - *Description:* Interval of the removal of expired session files. Set to `0` to disable. - *Default:* `0` +## How the Wrapper Runs php-fpm + +The wrapper does not parse the php-fpm config itself. At start, it does these steps: + +1. It runs `php-fpm -tt` and reads the config that php-fpm shows: the pools after all includes, `$pool` variables, and defaults. +2. It writes an overlay config to `--run-dir`. The overlay includes the config from `--fpm-config` and moves `error_log` and the `slowlog` of each pool to FIFOs in `--run-dir`. +3. It starts php-fpm with the overlay config and parses the FIFOs. + +The wrapper does not change the files of the config, and php-fpm does not need write access to the directories of `error_log` and `slowlog`. +`error_log = /proc/self/fd/2` of the official Docker images and `error_log = syslog` also go through the proxy. +Use `--fpm-no-errlog` or `--fpm-no-slowlog` to keep the paths of the config. + +`php-fpm -tt` opens the `slowlog` of each pool with `request_slowlog_timeout`. The user of the wrapper must have write access to these paths. + +The wrapper works with php-fpm of PHP 5.6 and later. It does not need root. + ## Session Cleanup Debian packages of PHP set `session.gc_probability = 0` and remove the expired session files with a cron job. diff --git a/cmd/docker-fpm-wrapper/config.go b/cmd/docker-fpm-wrapper/config.go index f2b0b62..218d24c 100644 --- a/cmd/docker-fpm-wrapper/config.go +++ b/cmd/docker-fpm-wrapper/config.go @@ -18,6 +18,7 @@ type Config struct { FpmPath string `short:"f" long:"fpm" env:"FPM" required:"true" description:"Path to php-fpm"` FpmConfigPath string `short:"c" long:"fpm-config" env:"FPM_CONFIG" default:"/etc/php/php-fpm.conf" description:"Path to the php-fpm config file"` + RunDir string `long:"run-dir" env:"RUN_DIR" default:"/tmp/docker-fpm-wrapper" description:"Directory for the generated php-fpm config and the log FIFOs"` FpmNoErrlogProxy bool `long:"fpm-no-errlog" env:"FPM_NO_ERRLOG" description:"Disable php-fpm errlog parsing and proxy"` FpmNoSlowlogProxy bool `long:"fpm-no-slowlog" env:"FPM_NO_SLOWLOG" description:"Disable php-fpm slowlog parsing and proxy"` diff --git a/cmd/docker-fpm-wrapper/config_test.go b/cmd/docker-fpm-wrapper/config_test.go index 80655f4..a89ac7f 100644 --- a/cmd/docker-fpm-wrapper/config_test.go +++ b/cmd/docker-fpm-wrapper/config_test.go @@ -20,6 +20,7 @@ func TestCreateConfig(t *testing.T) { LogEncoder: "auto", FpmPath: "/usr/sbin/php-fpm", FpmConfigPath: "/etc/php/php-fpm.conf", + RunDir: "/tmp/docker-fpm-wrapper", WrapperPipe: "/tmp/fpm-wrapper-pipe", WrapperSocket: "/tmp/fpm-wrapper.sock", LineBufferSize: 16 * 1024, @@ -49,7 +50,7 @@ func TestCreateConfig(t *testing.T) { { name: "flags", args: []string{ - "--fpm", "/usr/sbin/php-fpm", "-c", "/etc/fpm.conf", + "--fpm", "/usr/sbin/php-fpm", "-c", "/etc/fpm.conf", "--run-dir", "/run/wrapper", "--log-level", "-1", "--log-encoder", "json", "--fpm-no-errlog", "--fpm-no-slowlog", "-p", "", "-s", "null", "--line-buffer-size", "1024", @@ -61,6 +62,7 @@ func TestCreateConfig(t *testing.T) { LogEncoder: "json", FpmPath: "/usr/sbin/php-fpm", FpmConfigPath: "/etc/fpm.conf", + RunDir: "/run/wrapper", FpmNoErrlogProxy: true, FpmNoSlowlogProxy: true, WrapperPipe: "", diff --git a/cmd/docker-fpm-wrapper/fpmconfig.go b/cmd/docker-fpm-wrapper/fpmconfig.go new file mode 100644 index 0000000..0bef900 --- /dev/null +++ b/cmd/docker-fpm-wrapper/fpmconfig.go @@ -0,0 +1,107 @@ +package main + +import ( + "context" + "fmt" + "net/url" + "os" + "path/filepath" + "time" + + "github.com/code-tool/docker-fpm-wrapper/pkg/phpfpm" +) + +const ( + // fpmConfigReadTimeout limits "php-fpm -tt". It blocks on a FIFO without a reader. + fpmConfigReadTimeout = 30 * time.Second + + fpmTestConfigName = "php-fpm-test.conf" + fpmConfigName = "php-fpm.conf" + fpmErrorLogName = "error.log" +) + +// fpmSetup tells how the wrapper runs php-fpm. +type fpmSetup struct { + // configPath is the overlay config for "php-fpm --fpm-config". + configPath string + // errorLogPath is the FIFO for error_log. It is empty when the wrapper does not proxy the error log. + errorLogPath string + // pools are the pools of php-fpm. SlowlogPath of a pool is the FIFO of the wrapper. It is + // empty when the wrapper does not proxy the slowlog of the pool. + pools []phpfpm.Pool +} + +// prepareFpmConfig reads the php-fpm config of the user and writes an overlay config to runDir. +// The overlay moves error_log and the slowlog of each pool to FIFOs in runDir. Then the wrapper +// does not change the files of the user, and php-fpm does not need write access to their +// directories. +func prepareFpmConfig( + ctx context.Context, + fpmPath, userConfigPath string, fpmArgs []string, + runDir string, + proxyErrorLog, proxySlowlog bool, +) (fpmSetup, error) { + userConfigPath, err := filepath.Abs(userConfigPath) + if err != nil { + return fpmSetup{}, fmt.Errorf("find php-fpm config: %w", err) + } + + // php-fpm ignores an include without matching files, so check the file here. + if _, err := os.Stat(userConfigPath); err != nil { + return fpmSetup{}, fmt.Errorf("find php-fpm config: %w", err) + } + + if err := os.MkdirAll(runDir, 0o755); err != nil { + return fpmSetup{}, fmt.Errorf("create run dir: %w", err) + } + + // "php-fpm -tt" opens error_log. /dev/null keeps it away from the files and FIFOs of the + // user. php-fpm writes the dump at the notice level. + testOverlay := phpfpm.Overlay{ + Include: userConfigPath, + Global: map[string]string{"error_log": "/dev/null", "log_level": "notice"}, + } + testConfigPath := filepath.Join(runDir, fpmTestConfigName) + if err := testOverlay.WriteFile(testConfigPath); err != nil { + return fpmSetup{}, err + } + + readCtx, cancel := context.WithTimeout(ctx, fpmConfigReadTimeout) + defer cancel() + + fpmConfig, err := phpfpm.ReadConfig(readCtx, fpmPath, testConfigPath, fpmArgs) + if err != nil { + return fpmSetup{}, err + } + + setup := fpmSetup{configPath: filepath.Join(runDir, fpmConfigName), pools: fpmConfig.Pools} + overlay := phpfpm.Overlay{ + Include: userConfigPath, + Global: make(map[string]string), + Pools: make(map[string]map[string]string), + } + + if proxyErrorLog { + setup.errorLogPath = filepath.Join(runDir, fpmErrorLogName) + overlay.Global["error_log"] = setup.errorLogPath + } + + for i := range setup.pools { + pool := &setup.pools[i] + + // php-fpm writes the slowlog only when request_slowlog_timeout is set. + if !proxySlowlog || pool.RequestSlowlogTimeout == 0 { + pool.SlowlogPath = "" + continue + } + + pool.SlowlogPath = filepath.Join(runDir, "slowlog-"+url.PathEscape(pool.Name)+".log") + overlay.Pools[pool.Name] = map[string]string{"slowlog": pool.SlowlogPath} + } + + if err := overlay.WriteFile(setup.configPath); err != nil { + return fpmSetup{}, err + } + + return setup, nil +} diff --git a/cmd/docker-fpm-wrapper/fpmconfig_test.go b/cmd/docker-fpm-wrapper/fpmconfig_test.go new file mode 100644 index 0000000..19330c8 --- /dev/null +++ b/cmd/docker-fpm-wrapper/fpmconfig_test.go @@ -0,0 +1,164 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// fakeFpmDump is a short "php-fpm -tt" dump with a pool that writes the slowlog and a pool +// that does not. +const fakeFpmDump = `[21-Oct-2026 20:14:43] NOTICE: [global] +[21-Oct-2026 20:14:43] NOTICE: error_log = /dev/null +[21-Oct-2026 20:14:43] NOTICE: +[21-Oct-2026 20:14:43] NOTICE: [www] +[21-Oct-2026 20:14:43] NOTICE: listen = 9000 +[21-Oct-2026 20:14:43] NOTICE: slowlog = /var/log/www.slow +[21-Oct-2026 20:14:43] NOTICE: request_slowlog_timeout = 3s +[21-Oct-2026 20:14:43] NOTICE: +[21-Oct-2026 20:14:43] NOTICE: [api] +[21-Oct-2026 20:14:43] NOTICE: listen = 9001 +[21-Oct-2026 20:14:43] NOTICE: slowlog = /var/log/api.slow +[21-Oct-2026 20:14:43] NOTICE: request_slowlog_timeout = 0s +[21-Oct-2026 20:14:43] NOTICE: +[21-Oct-2026 20:14:43] NOTICE: configuration file /tmp/php-fpm-test.conf test is successful +` + +// writeFakeFpm writes a php-fpm script that keeps its arguments in a file and prints the dump. +// It also writes an empty php-fpm.conf to dir. +func writeFakeFpm(t *testing.T, dir, dump string) (fpmPath, argsPath string) { + t.Helper() + + fpmPath = filepath.Join(dir, "php-fpm") + argsPath = filepath.Join(dir, "args") + require.NoError(t, os.WriteFile(filepath.Join(dir, "php-fpm.conf"), nil, 0o644)) + dumpPath := filepath.Join(dir, "dump") + require.NoError(t, os.WriteFile(dumpPath, []byte(dump), 0o644)) + + script := "#!/bin/sh\necho \"$*\" > '" + argsPath + "'\ncat '" + dumpPath + "' >&2\n" + require.NoError(t, os.WriteFile(fpmPath, []byte(script), 0o755)) + + return fpmPath, argsPath +} + +func TestPrepareFpmConfig(t *testing.T) { + tests := []struct { + name string + proxyErrorLog bool + proxySlowlog bool + wantOverlay string // the text of the overlay after the include section; %s is the run dir + wantErrorLog string // %s is the run dir + wantSlowlogs []string + }{ + { + name: "all proxies", + proxyErrorLog: true, + proxySlowlog: true, + wantOverlay: "[global]\nerror_log = \"%s/error.log\"\n\n[www]\nslowlog = \"%s/slowlog-www.log\"\n", + wantErrorLog: "%s/error.log", + wantSlowlogs: []string{"%s/slowlog-www.log", ""}, + }, + { + name: "no error log proxy", + proxySlowlog: true, + wantOverlay: "[www]\nslowlog = \"%s/slowlog-www.log\"\n", + wantSlowlogs: []string{"%s/slowlog-www.log", ""}, + }, + { + name: "no slowlog proxy", + proxyErrorLog: true, + wantOverlay: "[global]\nerror_log = \"%s/error.log\"\n", + wantErrorLog: "%s/error.log", + wantSlowlogs: []string{"", ""}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dir := t.TempDir() + runDir := filepath.Join(dir, "run") + fpmPath, argsPath := writeFakeFpm(t, dir, fakeFpmDump) + userConfig := filepath.Join(dir, "php-fpm.conf") + inRunDir := func(s string) string { return strings.ReplaceAll(s, "%s", runDir) } + + setup, err := prepareFpmConfig(t.Context(), fpmPath, userConfig, []string{"-d", "a=b"}, + runDir, tt.proxyErrorLog, tt.proxySlowlog) + require.NoError(t, err) + + args, err := os.ReadFile(argsPath) + require.NoError(t, err) + assert.Equal(t, "-d a=b --fpm-config "+runDir+"/php-fpm-test.conf -tt", strings.TrimSpace(string(args))) + + testOverlay, err := os.ReadFile(filepath.Join(runDir, "php-fpm-test.conf")) + require.NoError(t, err) + assert.Contains(t, string(testOverlay), "include = \""+userConfig+"\"\n") + assert.Contains(t, string(testOverlay), "error_log = \"/dev/null\"\nlog_level = \"notice\"\n") + + assert.Equal(t, filepath.Join(runDir, "php-fpm.conf"), setup.configPath) + overlay, err := os.ReadFile(setup.configPath) + require.NoError(t, err) + assert.Equal(t, + "; Generated by docker-fpm-wrapper. Do not edit.\n\n[global]\ninclude = \""+userConfig+"\"\n\n"+inRunDir(tt.wantOverlay), + string(overlay)) + + assert.Equal(t, inRunDir(tt.wantErrorLog), setup.errorLogPath) + + slowlogs := make([]string, 0, len(setup.pools)) + for _, pool := range setup.pools { + slowlogs = append(slowlogs, pool.SlowlogPath) + } + wantSlowlogs := make([]string, 0, len(tt.wantSlowlogs)) + for _, slowlog := range tt.wantSlowlogs { + wantSlowlogs = append(wantSlowlogs, inRunDir(slowlog)) + } + assert.Equal(t, wantSlowlogs, slowlogs) + }) + } +} + +func TestPrepareFpmConfig_Errors(t *testing.T) { + t.Run("relative config path becomes absolute", func(t *testing.T) { + dir := t.TempDir() + fpmPath, _ := writeFakeFpm(t, dir, fakeFpmDump) + t.Chdir(dir) + + setup, err := prepareFpmConfig(t.Context(), fpmPath, "php-fpm.conf", nil, filepath.Join(dir, "run"), true, true) + require.NoError(t, err) + + overlay, err := os.ReadFile(setup.configPath) + require.NoError(t, err) + assert.Contains(t, string(overlay), "include = \""+filepath.Join(dir, "php-fpm.conf")+"\"") + }) + + t.Run("config does not exist", func(t *testing.T) { + dir := t.TempDir() + fpmPath, _ := writeFakeFpm(t, dir, fakeFpmDump) + + _, err := prepareFpmConfig(t.Context(), fpmPath, filepath.Join(dir, "missing.conf"), nil, filepath.Join(dir, "run"), true, true) + assert.ErrorContains(t, err, "find php-fpm config") + }) + + t.Run("php-fpm rejects the config", func(t *testing.T) { + dir := t.TempDir() + fpmPath := filepath.Join(dir, "php-fpm") + require.NoError(t, os.WriteFile(fpmPath, []byte("#!/bin/sh\necho 'ERROR: No pool defined' >&2\nexit 78\n"), 0o755)) + userConfig := filepath.Join(dir, "php-fpm.conf") + require.NoError(t, os.WriteFile(userConfig, nil, 0o644)) + + _, err := prepareFpmConfig(t.Context(), fpmPath, userConfig, nil, filepath.Join(dir, "run"), true, true) + assert.ErrorContains(t, err, "run php-fpm -tt: exit status 78: ERROR: No pool defined") + }) + + t.Run("run dir cannot be created", func(t *testing.T) { + dir := t.TempDir() + fpmPath, _ := writeFakeFpm(t, dir, fakeFpmDump) + file := filepath.Join(dir, "file") + require.NoError(t, os.WriteFile(file, nil, 0o644)) + + _, err := prepareFpmConfig(t.Context(), fpmPath, filepath.Join(dir, "php-fpm.conf"), nil, filepath.Join(file, "run"), true, true) + assert.ErrorContains(t, err, "create run dir") + }) +} diff --git a/cmd/docker-fpm-wrapper/main.go b/cmd/docker-fpm-wrapper/main.go index b227440..083b932 100644 --- a/cmd/docker-fpm-wrapper/main.go +++ b/cmd/docker-fpm-wrapper/main.go @@ -6,6 +6,7 @@ import ( "fmt" "net/http" "os" + "os/exec" "os/signal" "syscall" @@ -68,33 +69,35 @@ func main() { go applog.NewPipeProxy(log.Named("pipe-proxy"), syncStderr).Proxy(wrapperPipe) } - fpmConfig, err := phpfpm.ParseConfig(cfg.FpmConfigPath) + fpm, err := prepareFpmConfig(ctx, cfg.FpmPath, cfg.FpmConfigPath, fpmArgs, cfg.RunDir, + !cfg.FpmNoErrlogProxy, !cfg.FpmNoSlowlogProxy) if err != nil { - log.Fatal("Can't parse fpm config", zap.Error(err)) + log.Error("Can't read php-fpm config", zap.Error(err)) + + // Exit with the code of php-fpm, for example 78 for an invalid config. + if exitErr, ok := errors.AsType[*exec.ExitError](err); ok && exitErr.ExitCode() > 0 { + os.Exit(exitErr.ExitCode()) + } os.Exit(1) } - if !cfg.FpmNoErrlogProxy && fpmConfig.ErrorLog != "syslog" { - if err := startErrLogProxy(ctx, log.Named("php-fpm"), syncStderr, fpmConfig.ErrorLog); err != nil { - log.Error("can't start err_log proxy", zap.String("path", fpmConfig.ErrorLog), zap.Error(err)) - os.Exit(1) - } + if err := startErrLogProxy(ctx, log.Named("php-fpm"), syncStderr, fpm.errorLogPath); err != nil { + log.Error("can't start err_log proxy", zap.String("path", fpm.errorLogPath), zap.Error(err)) + os.Exit(1) } - if !cfg.FpmNoSlowlogProxy { - if err = startSlowlogProxies(ctx, log.Named("php-fpm"), fpmConfig.Pools); err != nil { - log.Error("Can't start slowlog proxies", zap.Error(err)) - os.Exit(1) - } + if err = startSlowlogProxies(ctx, log.Named("php-fpm"), fpm.pools); err != nil { + log.Error("Can't start slowlog proxies", zap.Error(err)) + os.Exit(1) } fpmProcess := phpfpm. - NewProcess(log, cfg.FpmPath, cfg.FpmConfigPath, os.Stdout, syncStderr, cfg.ShutdownDelay, env, fpmArgs...) + NewProcess(log, cfg.FpmPath, fpm.configPath, os.Stdout, syncStderr, cfg.ShutdownDelay, env, fpmArgs...) var sessionCleaner *session.Cleaner if cfg.SessionCleanupInterval > 0 { sessionCleaner, err = createSessionCleaner(ctx, log.Named("session-cleaner"), fpmProcess, - cfg.FpmPath, fpmArgs, fpmConfig.Pools, cfg.SessionCleanupInterval) + cfg.FpmPath, fpmArgs, fpm.pools, cfg.SessionCleanupInterval) if err != nil { log.Error("Can't create session cleaner", zap.Error(err)) os.Exit(1) @@ -115,7 +118,7 @@ func main() { } prometheus.MustRegister( - phpfpm.NewPromCollector(log.Named("prom-collector"), phpfpm.NewPromMetrics(), fpmConfig.Pools), + phpfpm.NewPromCollector(log.Named("prom-collector"), phpfpm.NewPromMetrics(), fpm.pools), ) signalCh := make(chan os.Signal, 1) diff --git a/e2e/fpmconfig_test.go b/e2e/fpmconfig_test.go new file mode 100644 index 0000000..f5f3e72 --- /dev/null +++ b/e2e/fpmconfig_test.go @@ -0,0 +1,60 @@ +//go:build e2e + +package e2e + +import ( + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/testcontainers/testcontainers-go/wait" +) + +// TestOfficialImageConfig runs the wrapper with the default php-fpm config of the image. The +// config has error_log = /proc/self/fd/2 in an included file, and PHP 7.4 and earlier use a +// relative include. +func TestOfficialImageConfig(t *testing.T) { + t.Parallel() + + logs := newLogCollector() + ctr := runContainer(t, "www-data", wait.ForListeningPort(wwwPort), logs, + "--fpm", fpmPath, "--fpm-config", "/usr/local/etc/php-fpm.conf") + w := &wrapper{ctr: ctr, logs: logs} + + // The wrapper proxies the error log: the entries of php-fpm are JSON. + w.waitForEntry(t, func(e logEntry) bool { + return e.Channel == "php-fpm" && e.Level == "info" && strings.Contains(e.Message, "ready to handle connections") + }) +} + +// TestRestart restarts the container. The FIFOs and the configs of the first start stay in +// the run dir of the wrapper. +func TestRestart(t *testing.T) { + t.Parallel() + + w := startWrapper(t) + assert.Equal(t, "ok", w.mustRequest(t, wwwPort, "index.php", "")) + + require.NoError(t, w.ctr.Stop(t.Context(), new(15*time.Second))) + require.NoError(t, w.ctr.Start(t.Context())) + w.readEndpoints(t) + + require.EventuallyWithT(t, func(c *assert.CollectT) { + body, err := w.request(wwwPort, "index.php", "") + require.NoError(c, err) + assert.Equal(c, "ok", body) + }, eventuallyTimeout, eventuallyTick) + + // php-fpm reported "ready to handle connections" through the error log proxy after each start. + require.EventuallyWithT(t, func(c *assert.CollectT) { + ready := 0 + for _, entry := range parseEntries(readLogs(t.Context(), c, w.ctr)) { + if entry.Channel == "php-fpm" && strings.Contains(entry.Message, "ready to handle connections") { + ready++ + } + } + assert.Equal(c, 2, ready) + }, eventuallyTimeout, eventuallyTick) +} diff --git a/e2e/startfailure_test.go b/e2e/startfailure_test.go index 309d434..7a18d25 100644 --- a/e2e/startfailure_test.go +++ b/e2e/startfailure_test.go @@ -3,6 +3,7 @@ package e2e import ( + "context" "io" "slices" "strings" @@ -14,11 +15,14 @@ import ( "github.com/testcontainers/testcontainers-go/wait" ) -// readLogs reads all output of a container that exited. -func readLogs(t *testing.T, ctr *testcontainers.DockerContainer) []string { - t.Helper() +// readLogs reads all output of a container until now. +// It takes require.TestingT, so that a condition of require.EventuallyWithT can call it. +func readLogs(ctx context.Context, t require.TestingT, ctr *testcontainers.DockerContainer) []string { + if h, ok := t.(interface{ Helper() }); ok { + h.Helper() + } - reader, err := ctr.Logs(t.Context()) + reader, err := ctr.Logs(ctx) require.NoError(t, err) defer func() { _ = reader.Close() }() @@ -48,19 +52,28 @@ func TestStartFailure(t *testing.T) { name: "fpm config does not exist", args: []string{"--fpm", fpmPath, "--fpm-config", "/nonexistent/php-fpm.conf"}, wantExitCode: 1, - wantOutput: `"message":"Can't parse fpm config"`, + wantOutput: `"message":"Can't read php-fpm config","error":"find php-fpm config: stat /nonexistent/php-fpm.conf: no such file or directory"`, }, { name: "fpm binary does not exist", args: []string{"--fpm", "/nonexistent/php-fpm", "--fpm-config", fpmConfigPath}, wantExitCode: 1, - wantOutput: `"message":"Can't start php-fpm"`, + wantOutput: `"message":"Can't read php-fpm config","error":"run php-fpm -tt: fork/exec /nonexistent/php-fpm: no such file or directory"`, }, { // The wrapper exits with the exit code of php-fpm: 78 is EX_CONFIG. name: "php-fpm rejects the config", args: []string{"--fpm", fpmPath, "--fpm-config", invalidFpmConfigPath}, wantExitCode: 78, + wantOutput: "invalid process manager", + }, + { + // With --fpm-no-errlog, php-fpm uses the error_log of the config: /var/log is not + // writable for www-data. + name: "error_log of the config is used without the proxy", + args: []string{"--fpm", fpmPath, "--fpm-config", fpmConfigPath, "--fpm-no-errlog"}, + wantExitCode: 78, + wantOutput: "failed to open error_log (/var/log/php-fpm-e2e.log)", }, } for _, tt := range tests { @@ -77,7 +90,7 @@ func TestStartFailure(t *testing.T) { return } - lines := readLogs(t, ctr) + lines := readLogs(t.Context(), t, ctr) assert.True(t, slices.ContainsFunc(lines, func(line string) bool { return strings.Contains(line, tt.wantOutput) }), "no line with %q in the output:\n%s", tt.wantOutput, strings.Join(lines, "\n")) diff --git a/e2e/testdata/php-fpm.conf.tmpl b/e2e/testdata/php-fpm.conf.tmpl index c4c8627..98910db 100644 --- a/e2e/testdata/php-fpm.conf.tmpl +++ b/e2e/testdata/php-fpm.conf.tmpl @@ -1,9 +1,11 @@ ; The php-fpm config for the e2e tests. TestMain renders it with text/template for the PHP ; version of the image: decorate_workers_output is PHP 7.3+. -; The wrapper replaces error_log and slowlog with FIFOs and parses them. +; The wrapper moves error_log and the slowlogs to its FIFOs and parses them. [global] -error_log = /tmp/php-fpm.log +; www-data cannot write to /var/log. The tests run as www-data, so they fail if the wrapper +; does not move error_log. +error_log = /var/log/php-fpm-e2e.log log_level = notice ; Let the children finish the current request after SIGQUIT. process_control_timeout = 10s diff --git a/e2e/wrapper_test.go b/e2e/wrapper_test.go index ed77ddd..c75e3d9 100644 --- a/e2e/wrapper_test.go +++ b/e2e/wrapper_test.go @@ -100,20 +100,29 @@ func startWrapperAs(t *testing.T, user string, extraArgs ...string) *wrapper { ) logs := newLogCollector() - ctr := runContainer(t, user, strategy, logs, args...) + w := &wrapper{ctr: runContainer(t, user, strategy, logs, args...), logs: logs} + w.readEndpoints(t) - fcgiAddrs := make(map[string]string) + return w +} + +// readEndpoints reads the host addresses of the ports. Call it again after a restart of the +// container: Docker can map the ports to other host ports. +func (w *wrapper) readEndpoints(t *testing.T) { + t.Helper() + + w.fcgiAddrs = make(map[string]string) for _, port := range []string{wwwPort, rawPort} { - addr, err := ctr.PortEndpoint(t.Context(), port, "") + addr, err := w.ctr.PortEndpoint(t.Context(), port, "") require.NoError(t, err) - fcgiAddrs[port] = addr + w.fcgiAddrs[port] = addr } - metricsEndpoint, err := ctr.PortEndpoint(t.Context(), metricsPort, "http") + metricsEndpoint, err := w.ctr.PortEndpoint(t.Context(), metricsPort, "http") require.NoError(t, err) - return &wrapper{ctr: ctr, logs: logs, fcgiAddrs: fcgiAddrs, metricsURL: metricsEndpoint + "/metrics"} + w.metricsURL = metricsEndpoint + "/metrics" } // request sends a FastCGI GET request for a script in scriptDir to the pool on port and returns diff --git a/go.mod b/go.mod index da01c3d..681aa80 100644 --- a/go.mod +++ b/go.mod @@ -7,7 +7,6 @@ require ( github.com/joho/godotenv v1.5.1 github.com/mattn/go-isatty v0.0.24 github.com/moby/moby/api v1.56.1 - github.com/otiai10/copy v1.14.1 github.com/prometheus/client_golang v1.25.0 github.com/prometheus/common v0.72.1 github.com/prometheus/procfs v0.22.0 @@ -16,7 +15,6 @@ require ( github.com/tomasen/fcgi_client v0.0.0-20180423082037-2bb3d819fd19 go.uber.org/zap v1.28.0 golang.org/x/sys v0.49.0 - gopkg.in/ini.v1 v1.67.3 ) require ( @@ -54,7 +52,6 @@ require ( github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.1 // indirect - github.com/otiai10/mint v1.6.3 // indirect github.com/power-devops/perfstat v0.0.0-20260916203055-22a1a467d9f0 // indirect github.com/prometheus/client_model v0.6.3 // indirect github.com/shirou/gopsutil/v4 v4.26.9 // indirect @@ -71,6 +68,5 @@ require ( go.uber.org/multierr v1.11.0 // indirect go.yaml.in/yaml/v3 v3.0.5 // indirect golang.org/x/crypto v0.58.0 // indirect - golang.org/x/sync v0.24.0 // indirect google.golang.org/protobuf v1.36.12 // indirect ) diff --git a/go.sum b/go.sum index a1ec091..33438e9 100644 --- a/go.sum +++ b/go.sum @@ -2,12 +2,8 @@ dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk= github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= -github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= -github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= github.com/Azure/go-ansiterm v0.0.0-20261013165250-51075c6ea6bc h1:58s5YzGPNAP7+e1uemaL0ax/NPSeJOHdRpRIowd+JDk= github.com/Azure/go-ansiterm v0.0.0-20261013165250-51075c6ea6bc/go.mod h1:DMNF6Kxv+EcuUvVuNkvC3f+BjEEmc/Fn9aU+qiD8zpU= -github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= -github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/Microsoft/go-winio v0.6.4 h1:kJF2pamtYCWXf0xAT8sAkwKC0pMl2HYBrRPHAqyUc+g= github.com/Microsoft/go-winio v0.6.4/go.mod h1:1xl5JXG4ze/m74GH6Dc4Mwxfc3VL5siRVQjIAlHH07U= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= @@ -20,8 +16,6 @@ github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= -github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= -github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= github.com/containerd/log v0.2.0 h1:BewD/umNgVnoczglOpX8eRMyEy5t5iPlu5AIpnWDONc= github.com/containerd/log v0.2.0/go.mod h1:/M7L7CXKcPTfNC74XzaK+5H5KbO5+4lJVpuVI6vRLoM= github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A= @@ -30,25 +24,17 @@ github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GK github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc= github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= -github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= -github.com/docker/go-connections v0.7.0 h1:6SsRfJddP22WMrCkj19x9WKjEDTB+ahsdiGYf0mN39c= -github.com/docker/go-connections v0.7.0/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q= github.com/docker/go-connections v0.8.2 h1:9d9DEokx5W7qlr0shkFXIxEA7+TTD+llwzcykSlxMNc= github.com/docker/go-connections v0.8.2/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q= github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= -github.com/ebitengine/purego v0.10.1 h1:dewVBCBT2GaMu1SrNTYxQhgQBethzfhiwvZiLGP/qyY= -github.com/ebitengine/purego v0.10.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= github.com/ebitengine/purego v0.11.2 h1:WWiTx8rD8zYdOwSj8O/NiNbsOvSAgJMTlyV1XlbUSeU= github.com/ebitengine/purego v0.11.2/go.mod h1:DCHPP08djqhNSoTfImcnHYQRZmd0qhakvrozqaEYhGQ= github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeORc= github.com/felixge/httpsnoop v1.1.0/go.mod h1:Zqxgdd+1Rkcz8euOqdr7lqgCRJztwr5hp9vDSi5UZCE= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= -github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= -github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8= github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= @@ -68,40 +54,30 @@ github.com/klauspost/compress v1.20.3 h1:zWNoFqq/lSuYQRim3dUXlXeA0IqwuZGicdG5ZTf github.com/klauspost/compress v1.20.3/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= -github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e h1:Q6MvJtQK/iRcRtzAscm/zF23XxJlbECiGPyRicsX+Ak= -github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg= github.com/lufia/plan9stats v0.0.0-20260802145828-341c2f0c90b5 h1:eveIIGn4BGM3qknO74omf6HYr30/exH+eVUTuAgwjZ0= github.com/lufia/plan9stats v0.0.0-20260802145828-341c2f0c90b5/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg= -github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE= -github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= github.com/magiconair/properties v1.18.12 h1:sT9zQpvTB3B4gzrX0tmZNTEaGyg8Zw55MFYRE32Mr9I= github.com/magiconair/properties v1.18.12/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= -github.com/moby/go-archive v0.2.0 h1:zg5QDUM2mi0JIM9fdQZWC7U8+2ZfixfTYoHL7rWUcP8= -github.com/moby/go-archive v0.2.0/go.mod h1:mNeivT14o8xU+5q1YnNrkQVpK+dnNe/K6fHqnTg4qPU= github.com/moby/go-archive v0.3.3 h1:OxxR9paxsluYi+zDUEXTTaIxtkK3viymW+Ka7vRhhME= github.com/moby/go-archive v0.3.3/go.mod h1:Npdv43fFqlhZW7Xo8fbm3ZMYFvAGNviUPqX21VERbcE= -github.com/moby/moby/api v1.55.0 h1:2/sexvQyqIWS8pRSCFddBfpW2qE7vR7FCL+vN8pxwMc= -github.com/moby/moby/api v1.55.0/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs= github.com/moby/moby/api v1.56.1 h1:PpWkvVPB7Fr/No8w+TfyJ/I6rWZ2YPZhT3JorIg+06c= github.com/moby/moby/api v1.56.1/go.mod h1:sZ+THbVWkjOmBPPfbnzdD/G1LuIexWhqlSHHPTDQ1Uk= -github.com/moby/moby/client v0.5.0 h1:5XhyPk2fuOWf6RlSFa3MkIIgDZkF25xToXW8Q/BH7cc= -github.com/moby/moby/client v0.5.0/go.mod h1:rcVpF8ncl9vo5gaIBdol6CnbEtSj1uxMvEV/UrykF/s= github.com/moby/moby/client v0.6.2 h1:ekSPlMccrZGcw6Scb8T9D39qE0mB+XupU/yS0zNw9N4= github.com/moby/moby/client v0.6.2/go.mod h1:DGcnO7qmPJrqobx/ggQgQ/AUk+2wgVdQv7uxB0/mEsc= github.com/moby/patternmatcher v0.6.1 h1:qlhtafmr6kgMIJjKJMDmMWq7WLkKIo23hsrpR3x084U= github.com/moby/patternmatcher v0.6.1/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= +github.com/moby/sys/mount v0.3.5 h1:eS3fsZTjHaBihwjp4/+5Z3jxqLXYsbwxqpVSfFv3M00= +github.com/moby/sys/mount v0.3.5/go.mod h1:WUQDO+/uCiCIkIztx8SrwIDVn2dtMFRBebRhpDFT71M= +github.com/moby/sys/mountinfo v0.7.2 h1:1shs6aH5s4o5H2zQLn796ADW1wMrIwHsyJ2v9KouLrg= +github.com/moby/sys/mountinfo v0.7.2/go.mod h1:1YOa8w8Ih7uW0wALDUgT1dTTSBrZ+HiBLGws92L2RU4= github.com/moby/sys/sequential v0.7.0 h1:ASQNGNROJSuOO6LL6bPHbKvuZu6NU8P4ldPWk31zj/8= github.com/moby/sys/sequential v0.7.0/go.mod h1:NfSTAp6V3fw4tmkD62PEcOKeZKquXT8VKCkf7aVR79o= -github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs= -github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs= github.com/moby/sys/user v0.4.1 h1:RgjRlaDKi/Xmyrz4t8lyzXT6v2ooFeO/7xtchmhVWE0= github.com/moby/sys/user v0.4.1/go.mod h1:E9QsW5WRe1kUAf7kW8hXKwu1uhsZEAdPLYHYSDudF4Y= -github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g= -github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28= github.com/moby/sys/userns v0.2.1 h1:4OvdM7BcPkASbuouHsbW3aeMJSFlYDldBRnXVZhaRk8= github.com/moby/sys/userns v0.2.1/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28= github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= @@ -112,13 +88,6 @@ github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8 github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= -github.com/otiai10/copy v1.14.1 h1:5/7E6qsUMBaH5AnQ0sSLzzTg1oTECmcCmT6lvF45Na8= -github.com/otiai10/copy v1.14.1/go.mod h1:oQwrEDDOci3IM8dJF0d8+jnbfPDllW6vUjNc3DoZm9I= -github.com/otiai10/mint v1.6.3 h1:87qsV/aw1F5as1eH1zS/yqHY85ANKVMgkDrf9rcxbQs= -github.com/otiai10/mint v1.6.3/go.mod h1:MJm72SBthJjz8qhefc4z1PYEieWmy8Bku7CjcAqyUSM= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU= -github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= github.com/power-devops/perfstat v0.0.0-20260916203055-22a1a467d9f0 h1:XA01Vk/wv9YikCi1V51yRzIHPMT5of9+cMpZoZvDn/M= github.com/power-devops/perfstat v0.0.0-20260916203055-22a1a467d9f0/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= github.com/prometheus/client_golang v1.25.0 h1:cDtW1TcFsMi1RJc8khwCKB4VhzwV04aKqbYtNOXuiK0= @@ -129,24 +98,12 @@ github.com/prometheus/common v0.72.1 h1:ZAiQed/nk8IIcsdzb2TlJu23NEUDyDLQ9TXTN2WR github.com/prometheus/common v0.72.1/go.mod h1:1IoBY4IRaJnhs2ESjNzjilP1cJ/P8pimGoomq7zRXrA= github.com/prometheus/procfs v0.22.0 h1:6q9+/JL9IKAPbCmBrv9n5O5Ty3NKnciV5X7YGw0oics= github.com/prometheus/procfs v0.22.0/go.mod h1:CvmFr/GVhIjIvWJZW3tgkODBQMRIf0EyWMQLHCHab58= -github.com/shirou/gopsutil/v4 v4.26.6 h1:Mzr/npDtQC/xpeEuQKHZt8Zo9CmPvhTj8nkR8w5TLDs= -github.com/shirou/gopsutil/v4 v4.26.6/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ= github.com/shirou/gopsutil/v4 v4.26.9 h1:CaBo/hBFqvlJoLvAQEKdHdf5GD97/MN2ACfyv63+2Ig= github.com/shirou/gopsutil/v4 v4.26.9/go.mod h1:nKH+8wX2zxr/mDbsR+AAy6Qd2z4ZOQ7wfQaEp+yrYmg= -github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= -github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= github.com/sirupsen/logrus v1.10.2 h1:G2SED73/qrAu6YwbdxOD6peLkCBI3z7L+ykJFTXJBBo= github.com/sirupsen/logrus v1.10.2/go.mod h1:SLEg8TqYulVKKfIGHldVp2K2aYz2DKSVBq4g/H5bR7Q= -github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= -github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= -github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4= github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0= -github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= -github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= -github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= github.com/testcontainers/testcontainers-go v0.44.0 h1:/Fwh6HY1mIikhnm9e7HwoxGycx0lzRAE0f5VQpjFxzI= @@ -161,28 +118,18 @@ github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 h1:8tvICD4vSTOOsNrsI4Ljf6C+6UKvpTEH5XY3JMoyPoo= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0/go.mod h1:z9+yiacE0IHRqM4qFfkbt/JYlmYXgss8GY/jXoNuPJI= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.72.0 h1:LxwW/9ctSCv+QkE/cLR7M91ZIkXNMqJtEMi1vCw9U8s= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.72.0/go.mod h1:tOsftB4SslBwwErVEPaenU2RpThXWPIU8DoJHEC4dyw= -go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= -go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= go.opentelemetry.io/otel v1.47.0 h1:j7ALJ/zgkS7Z6aeJW09p8VC9804bC+PpeTfCD4XPnOM= go.opentelemetry.io/otel v1.47.0/go.mod h1:8wS9O2qfXrYrzp6hIF/HOYJJf/wIhFPhR2xLuP+iXQU= go.opentelemetry.io/otel/log v1.47.0 h1:cOTS1CcLbSQeZKanGJ+0JpF/+t4PELi3O3bbl2lqCcI= go.opentelemetry.io/otel/log v1.47.0/go.mod h1:9byitSQ5pLC6PpqwGXjqdMKya6ZTswHRZh2vvXT33nw= -go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= -go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= go.opentelemetry.io/otel/metric v1.47.0 h1:4PptaldXx3Eat1XjMZ68pPJEs5wrhlemctZE9a3UdWY= go.opentelemetry.io/otel/metric v1.47.0/go.mod h1:ADGSXxRrXM6bjbvLo535EstVFlPpPYZm4LBKixjDHwU= -go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58= -go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0= go.opentelemetry.io/otel/sdk v1.47.0 h1:zWXEr4j2lFefG87TU6Yg8a7ngfohIKFZHKp0Hf5hC6I= -go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI= -go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA= +go.opentelemetry.io/otel/sdk v1.47.0/go.mod h1:VUc24kiOeoGsxG8G9ULx3fWKvB7jMhnGE8Oi607lgR0= go.opentelemetry.io/otel/sdk/metric v1.47.0 h1:lfISg2j93VT6yqdk9OfUaZmw/GfcZqCCV3jdXtsPnKw= -go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= -go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= +go.opentelemetry.io/otel/sdk/metric v1.47.0/go.mod h1:ypLp+mW1Nt2x+Szt3b5/i1syodyts49lMOwxpDI3VGw= go.opentelemetry.io/otel/trace v1.47.0 h1:JOjX/Oci8K94QHddo+bbfya/Ai/nf6/dt9ZfrFNWSrM= go.opentelemetry.io/otel/trace v1.47.0/go.mod h1:jNaSLa2PZEYFG6fRjJABAu+bw4FS08uDmPg28lTghu0= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= @@ -195,30 +142,17 @@ go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= -golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= -golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= golang.org/x/crypto v0.58.0 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54= golang.org/x/crypto v0.58.0/go.mod h1:Xh+kl5A+M0gMiWDytH2Hhr5Z7WbNCpv/eB/EFgfSk+w= -golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= -golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= -golang.org/x/sync v0.24.0 h1:iqiDrGoDgyuvAKrhLQthUEzU/R378N78gXbBjxed7oA= -golang.org/x/sync v0.24.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA= golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= -golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= -golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= golang.org/x/term v0.47.0 h1:cpyOwQkr2Nzxe2p6KnrTG7Ei2wtzjsuSjhpXXepZ/BM= +golang.org/x/term v0.47.0/go.mod h1:efAfAuG6RKABvg1ArGebRkvX6LXul14SM67o2e4mUCY= google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc= google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/ini.v1 v1.67.3 h1:iM9Lhz5MRSGhHVGGwCuzG9KO8PoirCXj/m/qTmOJJQw= -gopkg.in/ini.v1 v1.67.3/go.mod h1:x/cyOwCgZqOkJoDIJ3c1KNHMo10+nLGAhh+kn3Zizss= -gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk= diff --git a/pkg/phpfpm/config.go b/pkg/phpfpm/config.go index 0d11f5a..b3dafb0 100644 --- a/pkg/phpfpm/config.go +++ b/pkg/phpfpm/config.go @@ -1,21 +1,27 @@ package phpfpm import ( + "bufio" + "bytes" + "context" "errors" "fmt" - "os" - "path" + "io" + "os/exec" "regexp" + "slices" "strconv" "strings" - - "gopkg.in/ini.v1" + "time" ) +// defaultSlowlogTraceDepth is the trace depth of php-fpm. PHP before 7.2 has no +// request_slowlog_trace_depth and always uses this depth. +const defaultSlowlogTraceDepth = 20 + +// Config is the config of php-fpm, as php-fpm reads it. type Config struct { - Include string - ErrorLog string - Pools []Pool + Pools []Pool } type Pool struct { @@ -24,138 +30,168 @@ type Pool struct { StatusPath string StatusListen string SlowlogPath string - RequestSlowlogTimeout int + RequestSlowlogTimeout time.Duration RequestSlowlogTraceDepth int - // PhpValues holds the php_value, php_flag, php_admin_value, and php_admin_flag directives. + // PhpValues holds the php_value and php_admin_value directives. php-fpm shows a php_flag + // directive as php_value. PhpValues map[string]string } -var phpValueKeyRegexp = regexp.MustCompile(`^php(_admin)?_(?:value|flag)\[(.+)]$`) +var ( + dumpLineRegexp = regexp.MustCompile(`^\[[^]]*] NOTICE: (.*)$`) + dumpSectionRegexp = regexp.MustCompile(`^\[(.+)]$`) + phpValueKeyRegexp = regexp.MustCompile(`^php(_admin)?_value\[(.+)]$`) +) + +// newPool makes a pool from the directives of its section in the dump. +func newPool(name string, directives map[string]string, adminValues map[string]bool) (Pool, error) { + pool := Pool{ + Name: name, + Listen: directives["listen"], + StatusPath: directives["pm.status_path"], + StatusListen: directives["pm.status_listen"], + SlowlogPath: directives["slowlog"], + RequestSlowlogTraceDepth: defaultSlowlogTraceDepth, + PhpValues: make(map[string]string), + } -// parsePhpValues returns the PHP directives of the pool section with expand applied to the -// values. An admin directive takes precedence over a directive with the same name, as in php-fpm. -func parsePhpValues(section *ini.Section, expand func(string) string) map[string]string { - values := make(map[string]string) - isAdmin := make(map[string]bool) + if value := directives["request_slowlog_timeout"]; value != "" { + timeout, err := time.ParseDuration(value) + if err != nil { + return Pool{}, fmt.Errorf("pool %s: parse request_slowlog_timeout: %w", name, err) + } + pool.RequestSlowlogTimeout = timeout + } + + if value := directives["request_slowlog_trace_depth"]; value != "" { + depth, err := strconv.Atoi(value) + if err != nil { + return Pool{}, fmt.Errorf("pool %s: parse request_slowlog_trace_depth: %w", name, err) + } + pool.RequestSlowlogTraceDepth = depth + } - for _, key := range section.Keys() { - matches := phpValueKeyRegexp.FindStringSubmatch(key.Name()) + for key, value := range directives { + matches := phpValueKeyRegexp.FindStringSubmatch(key) if matches == nil { continue } - name, admin := matches[2], matches[1] != "" - if isAdmin[name] && !admin { + // An admin directive takes precedence, as in php-fpm. + phpName, admin := matches[2], matches[1] != "" + if !admin && adminValues[phpName] { continue } - - values[name] = expand(key.String()) - isAdmin[name] = admin + pool.PhpValues[phpName] = value } - return values + return pool, nil } -func fillPull(config *Config, iniConfig *ini.File, poolName string) error { - pool := Pool{} - pool.Name = poolName - - section, err := iniConfig.GetSection(poolName) - if err != nil { - return err - } +// ParseDump reads the output of "php-fpm -tt": the config after php-fpm applied the includes, +// the $pool variables, and the defaults. The global section is "[global]", or "[General]" in +// PHP 5.6. Each other section is a pool. +func ParseDump(r io.Reader) (Config, error) { + var ( + config Config + section string + directives map[string]string + adminValues map[string]bool + ) + + addPool := func() error { + if section == "" || section == "global" || section == "General" { + return nil + } - // php-fpm replaces each $pool in each directive with the name of the pool. - expand := func(value string) string { - return strings.ReplaceAll(value, "$pool", poolName) - } - get := func(name string) (string, bool) { - key, err := section.GetKey(name) + pool, err := newPool(section, directives, adminValues) if err != nil { - return "", false + return err } + config.Pools = append(config.Pools, pool) - return expand(key.String()), true + return nil } - listen, ok := get("listen") - if !ok { - return errors.New(`key "listen" not exists`) - } - pool.Listen = listen + scanner := bufio.NewScanner(r) + for scanner.Scan() { + matches := dumpLineRegexp.FindStringSubmatch(scanner.Text()) + if matches == nil { + continue + } + content := matches[1] - pool.StatusPath, _ = get("pm.status_path") - pool.StatusListen, _ = get("pm.status_listen") - pool.SlowlogPath, _ = get("slowlog") + if sectionMatches := dumpSectionRegexp.FindStringSubmatch(content); sectionMatches != nil { + if err := addPool(); err != nil { + return Config{}, err + } - if value, ok := get("request_slowlog_timeout"); ok { - pool.RequestSlowlogTimeout, _ = strconv.Atoi(value) - } + section = sectionMatches[1] + directives = make(map[string]string) + adminValues = make(map[string]bool) - pool.RequestSlowlogTraceDepth = 64 - if value, ok := get("request_slowlog_trace_depth"); ok { - pool.RequestSlowlogTraceDepth, _ = strconv.Atoi(value) - } + continue + } - pool.PhpValues = parsePhpValues(section, expand) + directive, found := strings.CutPrefix(content, "\t") + if !found || section == "" { + continue + } - config.Pools = append(config.Pools, pool) - return nil -} + key, value, found := strings.Cut(directive, " = ") + if !found { + continue + } + if value == "undefined" { + value = "" + } -func ParseConfig(fpmConfigPath string) (Config, error) { - c := Config{} - // A ";" after a space starts a comment. A ";" in a value stays, for example "2;/var/lib/php/sessions". - cfg := ini.Empty(ini.LoadOptions{SpaceBeforeInlineComment: true}) - err := cfg.Append(fpmConfigPath) - if err != nil { - return c, err + directives[key] = value + if matches := phpValueKeyRegexp.FindStringSubmatch(key); matches != nil && matches[1] != "" { + adminValues[matches[2]] = true + } } - global, err := cfg.GetSection("global") - if err != nil { - return c, err + if err := scanner.Err(); err != nil { + return Config{}, fmt.Errorf("read php-fpm config dump: %w", err) } - if key, err := global.GetKey("error_log"); err == nil { - c.ErrorLog = key.String() + if err := addPool(); err != nil { + return Config{}, err } - if include, err := global.GetKey("include"); err == nil { - c.Include = include.Value() - - file := regexp.QuoteMeta(path.Base(c.Include)) - file = strings.Replace(file, regexp.QuoteMeta("*"), "(.+)", 1) - file = fmt.Sprintf("^%s$", file) - fileRx := regexp.MustCompile(file) - - fmpPoolsDir := path.Dir(c.Include) - osFileInfo, err := os.ReadDir(fmpPoolsDir) - if err != nil { - return c, err - } + if len(config.Pools) == 0 { + return Config{}, errors.New("php-fpm config dump has no pools") + } - for _, info := range osFileInfo { - if info.IsDir() || !fileRx.MatchString(info.Name()) { - continue - } + return config, nil +} - if err = cfg.Append(fmt.Sprintf("%s/%s", fmpPoolsDir, info.Name())); err != nil { - return c, err - } +// ReadConfig runs "php-fpm -tt" for the config and returns the config that php-fpm reads. +// The extra arguments of php-fpm go before the other arguments, as in Process. +// +// "php-fpm -tt" opens error_log and the slowlog files. It blocks on a FIFO that has no reader, +// so ctx must have a deadline. +func ReadConfig(ctx context.Context, fpmPath, fpmConfigPath string, fpmArgs []string) (Config, error) { + args := append(slices.Clone(fpmArgs), "--fpm-config", fpmConfigPath, "-tt") + cmd := exec.CommandContext(ctx, fpmPath, args...) + + var out bytes.Buffer + cmd.Stdout = &out + cmd.Stderr = &out + + if err := cmd.Run(); err != nil { + if errors.Is(ctx.Err(), context.DeadlineExceeded) { + return Config{}, fmt.Errorf( + "run php-fpm -tt: no exit before the deadline: a FIFO without a reader at error_log or slowlog can block php-fpm: %w", err) } - } - // php-fpm takes each section except [global] as a pool. - for _, section := range cfg.Sections() { - if section.Name() == ini.DefaultSection || section.Name() == "global" { - continue + if output := bytes.TrimSpace(out.Bytes()); len(output) > 0 { + return Config{}, fmt.Errorf("run php-fpm -tt: %w: %s", err, output) } - if err := fillPull(&c, cfg, section.Name()); err != nil { - return c, fmt.Errorf("pool %s: %w", section.Name(), err) - } + return Config{}, fmt.Errorf("run php-fpm -tt: %w", err) } - return c, nil + return ParseDump(&out) } diff --git a/pkg/phpfpm/config_test.go b/pkg/phpfpm/config_test.go index 3871bf3..b361f42 100644 --- a/pkg/phpfpm/config_test.go +++ b/pkg/phpfpm/config_test.go @@ -1,81 +1,163 @@ package phpfpm import ( + "context" "os" "path/filepath" + "strings" "testing" + "time" - "github.com/otiai10/copy" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func TestParse(t *testing.T) { - dir := "/tmp/fpm-test" - assert.NoError(t, os.Mkdir(dir, 0777)) - assert.NoError(t, copy.Copy("testdata", dir)) - - defer func() { _ = os.RemoveAll(dir) }() - - c, err := ParseConfig(dir + "/php-fpm.conf") - require.NoError(t, err) - assert.Equal(t, dir+"/php-fpm.d/*.conf", c.Include) - - assert.Equal(t, []Pool{ - { - Name: "nostatus", - Listen: "/run/php-fpm/nostatus.sock", - StatusListen: "/run/php-fpm/nostatus-status.sock", - SlowlogPath: "/var/log/nostatus/nostatus.slow.log", - RequestSlowlogTimeout: 5, - RequestSlowlogTraceDepth: 64, - PhpValues: map[string]string{"session.save_path": "/var/lib/php/nostatus-sessions"}, - }, +// The dumps in testdata are the output of "php-fpm -tt" of PHP 5.6, 7.4, and 8.5 for this config: +// +// [www] +// listen = /run/php/$pool.sock +// pm.status_path = /status +// slowlog = /tmp/$pool.slow +// request_slowlog_timeout = 3 +// php_value[session.save_path] = "2;/var/lib/php/$pool" +// php_flag[display_errors] = off +// php_admin_value[session.gc_maxlifetime] = 3600 +// +// [api] +// listen = 127.0.0.1:9001 +// php_value[session.gc_maxlifetime] = 60 +// php_admin_value[session.gc_maxlifetime] = 7200 +func dumpPools() []Pool { + return []Pool{ { Name: "www", - Listen: "/run/php-fpm/php-fpm.sock", + Listen: "/run/php/www.sock", StatusPath: "/status", - SlowlogPath: "log/www.log.slow", - RequestSlowlogTimeout: 1, - RequestSlowlogTraceDepth: 64, + SlowlogPath: "/tmp/www.slow", + RequestSlowlogTimeout: 3 * time.Second, + RequestSlowlogTraceDepth: 20, PhpValues: map[string]string{ - "session.save_path": "2;/var/lib/php/sessions", + "display_errors": "0", + "session.save_path": "2;/var/lib/php/www", "session.gc_maxlifetime": "3600", - "log_errors": "on", }, }, - }, c.Pools) + { + Name: "api", + Listen: "127.0.0.1:9001", + RequestSlowlogTraceDepth: 20, + PhpValues: map[string]string{"session.gc_maxlifetime": "7200"}, + }, + } } -func TestParseConfig_Errors(t *testing.T) { +func TestParseDump(t *testing.T) { + for _, version := range []string{"5.6", "7.4", "8.5"} { + t.Run("PHP "+version, func(t *testing.T) { + f, err := os.Open(filepath.Join("testdata", "dump-"+version+".txt")) + require.NoError(t, err) + t.Cleanup(func() { _ = f.Close() }) + + config, err := ParseDump(f) + require.NoError(t, err) + assert.Equal(t, dumpPools(), config.Pools) + }) + } +} + +func TestParseDump_Errors(t *testing.T) { + const prefix = "[21-Oct-2026 20:14:43] NOTICE: " + tests := []struct { name string - config string + dump string wantErr string }{ { - name: "no global section", - config: "[www]\nlisten = 9000\n", - wantErr: `section "global" does not exist`, + name: "empty output", + dump: "", + wantErr: "php-fpm config dump has no pools", + }, + { + name: "global section only", + dump: prefix + "[global]\n" + prefix + "\terror_log = /dev/null\n", + wantErr: "php-fpm config dump has no pools", + }, + { + name: "invalid slowlog timeout", + dump: prefix + "[www]\n" + prefix + "\trequest_slowlog_timeout = soon\n", + wantErr: "pool www: parse request_slowlog_timeout", }, { - name: "pool without listen", - config: "[global]\nerror_log = /proc/self/fd/2\n\n[www]\nlisten = 9000\n\n[broken]\npm = static\n", - wantErr: "pool broken: ", + name: "invalid trace depth", + dump: prefix + "[www]\n" + prefix + "\trequest_slowlog_trace_depth = deep\n", + wantErr: "pool www: parse request_slowlog_trace_depth", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - fPath := filepath.Join(t.TempDir(), "php-fpm.conf") - require.NoError(t, os.WriteFile(fPath, []byte(tt.config), 0o600)) - - _, err := ParseConfig(fPath) + _, err := ParseDump(strings.NewReader(tt.dump)) assert.ErrorContains(t, err, tt.wantErr) }) } +} - t.Run("missing file", func(t *testing.T) { - _, err := ParseConfig(filepath.Join(t.TempDir(), "php-fpm.conf")) - assert.ErrorContains(t, err, "no such file or directory") - }) +func TestReadConfig(t *testing.T) { + dump, err := filepath.Abs("testdata/dump-8.5.txt") + require.NoError(t, err) + + tests := []struct { + name string + script string + timeout time.Duration + wantArgs string + wantErr string + }{ + { + name: "dump of php-fpm", + script: "echo \"$*\" > \"$ARGS_FILE\"\ncat '" + dump + "' >&2\n", + wantArgs: "-d memory_limit=1G --fpm-config /etc/php-fpm.conf -tt", + }, + { + name: "php-fpm rejects the config", + script: "echo '[21-Oct-2026 20:14:43] ERROR: failed to open error_log' >&2\nexit 78\n", + wantErr: "run php-fpm -tt: exit status 78: [21-Oct-2026 20:14:43] ERROR: failed to open error_log", + }, + { + name: "php-fpm does not exit", + script: "exec sleep 10\n", + timeout: 100 * time.Millisecond, + wantErr: "a FIFO without a reader at error_log or slowlog can block php-fpm", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dir := t.TempDir() + argsFile := filepath.Join(dir, "args") + t.Setenv("ARGS_FILE", argsFile) + + fpmPath := filepath.Join(dir, "php-fpm") + require.NoError(t, os.WriteFile(fpmPath, []byte("#!/bin/sh\n"+tt.script), 0o755)) + + ctx := t.Context() + if tt.timeout > 0 { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, tt.timeout) + t.Cleanup(cancel) + } + + config, err := ReadConfig(ctx, fpmPath, "/etc/php-fpm.conf", []string{"-d", "memory_limit=1G"}) + if tt.wantErr != "" { + assert.ErrorContains(t, err, tt.wantErr) + return + } + + require.NoError(t, err) + assert.Equal(t, dumpPools(), config.Pools) + + args, err := os.ReadFile(argsFile) + require.NoError(t, err) + assert.Equal(t, tt.wantArgs, strings.TrimSpace(string(args))) + }) + } } diff --git a/pkg/phpfpm/overlay.go b/pkg/phpfpm/overlay.go new file mode 100644 index 0000000..f4459e5 --- /dev/null +++ b/pkg/phpfpm/overlay.go @@ -0,0 +1,78 @@ +package phpfpm + +import ( + "bytes" + "fmt" + "maps" + "os" + "slices" + "strings" +) + +// Overlay is a php-fpm config that includes another config and then changes some directives of +// it. php-fpm applies the directives in order, so a directive of the overlay replaces the same +// directive of the included config. A pool section of the overlay adds to the pool with the same +// name. +type Overlay struct { + Include string // the path of the included config + Global map[string]string // the directives of [global] + Pools map[string]map[string]string // the directives of each pool, by the name of the pool +} + +// overlayValueUnsafeChars are the characters that the php-fpm ini parser does not keep as they +// are in a double-quoted value. +const overlayValueUnsafeChars = "\"\r\n$\\" + +func writeOverlaySection(buf *bytes.Buffer, name string, directives map[string]string) error { + if len(directives) == 0 { + return nil + } + + _, _ = fmt.Fprintf(buf, "\n[%s]\n", name) + for _, key := range slices.Sorted(maps.Keys(directives)) { + value := directives[key] + if strings.ContainsAny(value, overlayValueUnsafeChars) { + return fmt.Errorf("value of %s in [%s] has a character from %q: %q", key, name, overlayValueUnsafeChars, value) + } + + _, _ = fmt.Fprintf(buf, "%s = \"%s\"\n", key, value) + } + + return nil +} + +// Render returns the text of the overlay. +func (o Overlay) Render() ([]byte, error) { + var buf bytes.Buffer + buf.WriteString("; Generated by docker-fpm-wrapper. Do not edit.\n") + + if err := writeOverlaySection(&buf, "global", map[string]string{"include": o.Include}); err != nil { + return nil, err + } + + if err := writeOverlaySection(&buf, "global", o.Global); err != nil { + return nil, err + } + + for _, name := range slices.Sorted(maps.Keys(o.Pools)) { + if err := writeOverlaySection(&buf, name, o.Pools[name]); err != nil { + return nil, err + } + } + + return buf.Bytes(), nil +} + +// WriteFile renders the overlay to the file at path. +func (o Overlay) WriteFile(path string) error { + data, err := o.Render() + if err != nil { + return fmt.Errorf("render php-fpm config overlay: %w", err) + } + + if err := os.WriteFile(path, data, 0o644); err != nil { + return fmt.Errorf("write php-fpm config overlay: %w", err) + } + + return nil +} diff --git a/pkg/phpfpm/overlay_test.go b/pkg/phpfpm/overlay_test.go new file mode 100644 index 0000000..cc730bf --- /dev/null +++ b/pkg/phpfpm/overlay_test.go @@ -0,0 +1,87 @@ +package phpfpm + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestOverlay_Render(t *testing.T) { + tests := []struct { + name string + overlay Overlay + want string + wantErr string + }{ + { + name: "include, global, and pools", + overlay: Overlay{ + Include: "/etc/php/php-fpm.conf", + Global: map[string]string{"log_level": "notice", "error_log": "/dev/null"}, + Pools: map[string]map[string]string{ + "www": {"slowlog": "/tmp/docker-fpm-wrapper/slowlog-www.log"}, + "api": {"slowlog": "/tmp/docker-fpm-wrapper/slowlog-api.log"}, + }, + }, + want: `; Generated by docker-fpm-wrapper. Do not edit. + +[global] +include = "/etc/php/php-fpm.conf" + +[global] +error_log = "/dev/null" +log_level = "notice" + +[api] +slowlog = "/tmp/docker-fpm-wrapper/slowlog-api.log" + +[www] +slowlog = "/tmp/docker-fpm-wrapper/slowlog-www.log" +`, + }, + { + name: "include only", + overlay: Overlay{Include: "/etc/php/php-fpm.conf", Pools: map[string]map[string]string{"www": {}}}, + want: "; Generated by docker-fpm-wrapper. Do not edit.\n\n[global]\ninclude = \"/etc/php/php-fpm.conf\"\n", + }, + { + name: "value with a double quote", + overlay: Overlay{Include: `/etc/php/"php-fpm".conf`}, + wantErr: `value of include in [global] has a character`, + }, + { + name: "value with a variable", + overlay: Overlay{Include: "/etc/php/php-fpm.conf", Pools: map[string]map[string]string{"www": {"slowlog": "/tmp/$pool.log"}}}, + wantErr: `value of slowlog in [www] has a character`, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := tt.overlay.Render() + if tt.wantErr != "" { + assert.ErrorContains(t, err, tt.wantErr) + return + } + + require.NoError(t, err) + assert.Equal(t, tt.want, string(got)) + }) + } +} + +func TestOverlay_WriteFile(t *testing.T) { + path := filepath.Join(t.TempDir(), "php-fpm.conf") + require.NoError(t, Overlay{Include: "/etc/php/php-fpm.conf"}.WriteFile(path)) + + data, err := os.ReadFile(path) + require.NoError(t, err) + assert.Contains(t, string(data), `include = "/etc/php/php-fpm.conf"`) + + t.Run("missing directory is an error", func(t *testing.T) { + err := Overlay{Include: "/etc/php/php-fpm.conf"}.WriteFile(filepath.Join(t.TempDir(), "missing", "php-fpm.conf")) + assert.ErrorContains(t, err, "write php-fpm config overlay") + }) +} diff --git a/pkg/phpfpm/testdata/dump-5.6.txt b/pkg/phpfpm/testdata/dump-5.6.txt new file mode 100644 index 0000000..98e84d0 --- /dev/null +++ b/pkg/phpfpm/testdata/dump-5.6.txt @@ -0,0 +1,93 @@ +[21-Oct-2026 20:14:43] NOTICE: [General] +[21-Oct-2026 20:14:43] NOTICE: pid = undefined +[21-Oct-2026 20:14:43] NOTICE: error_log = /dev/null +[21-Oct-2026 20:14:43] NOTICE: syslog.ident = php-fpm +[21-Oct-2026 20:14:43] NOTICE: syslog.facility = 24 +[21-Oct-2026 20:14:43] NOTICE: log_level = unknown value +[21-Oct-2026 20:14:43] NOTICE: emergency_restart_interval = 0s +[21-Oct-2026 20:14:43] NOTICE: emergency_restart_threshold = 0 +[21-Oct-2026 20:14:43] NOTICE: process_control_timeout = 0s +[21-Oct-2026 20:14:43] NOTICE: process.max = 0 +[21-Oct-2026 20:14:43] NOTICE: process.priority = undefined +[21-Oct-2026 20:14:43] NOTICE: daemonize = yes +[21-Oct-2026 20:14:43] NOTICE: rlimit_files = 0 +[21-Oct-2026 20:14:43] NOTICE: rlimit_core = 0 +[21-Oct-2026 20:14:43] NOTICE: events.mechanism = epoll +[21-Oct-2026 20:14:43] NOTICE: +[21-Oct-2026 20:14:43] NOTICE: [www] +[21-Oct-2026 20:14:43] NOTICE: prefix = undefined +[21-Oct-2026 20:14:43] NOTICE: user = undefined +[21-Oct-2026 20:14:43] NOTICE: group = undefined +[21-Oct-2026 20:14:43] NOTICE: listen = /run/php/www.sock +[21-Oct-2026 20:14:43] NOTICE: listen.backlog = 65535 +[21-Oct-2026 20:14:43] NOTICE: listen.owner = undefined +[21-Oct-2026 20:14:43] NOTICE: listen.group = undefined +[21-Oct-2026 20:14:43] NOTICE: listen.mode = undefined +[21-Oct-2026 20:14:43] NOTICE: listen.allowed_clients = undefined +[21-Oct-2026 20:14:43] NOTICE: process.priority = undefined +[21-Oct-2026 20:14:43] NOTICE: process.dumpable = no +[21-Oct-2026 20:14:43] NOTICE: pm = static +[21-Oct-2026 20:14:43] NOTICE: pm.max_children = 2 +[21-Oct-2026 20:14:43] NOTICE: pm.start_servers = 0 +[21-Oct-2026 20:14:43] NOTICE: pm.min_spare_servers = 0 +[21-Oct-2026 20:14:43] NOTICE: pm.max_spare_servers = 0 +[21-Oct-2026 20:14:43] NOTICE: pm.process_idle_timeout = 10 +[21-Oct-2026 20:14:43] NOTICE: pm.max_requests = 0 +[21-Oct-2026 20:14:43] NOTICE: pm.status_path = /status +[21-Oct-2026 20:14:43] NOTICE: ping.path = undefined +[21-Oct-2026 20:14:43] NOTICE: ping.response = undefined +[21-Oct-2026 20:14:43] NOTICE: access.log = undefined +[21-Oct-2026 20:14:43] NOTICE: access.format = undefined +[21-Oct-2026 20:14:43] NOTICE: slowlog = /tmp/www.slow +[21-Oct-2026 20:14:43] NOTICE: request_slowlog_timeout = 3s +[21-Oct-2026 20:14:43] NOTICE: request_terminate_timeout = 0s +[21-Oct-2026 20:14:43] NOTICE: rlimit_files = 0 +[21-Oct-2026 20:14:43] NOTICE: rlimit_core = 0 +[21-Oct-2026 20:14:43] NOTICE: chroot = undefined +[21-Oct-2026 20:14:43] NOTICE: chdir = undefined +[21-Oct-2026 20:14:43] NOTICE: catch_workers_output = no +[21-Oct-2026 20:14:43] NOTICE: clear_env = yes +[21-Oct-2026 20:14:43] NOTICE: security.limit_extensions = .php .phar +[21-Oct-2026 20:14:43] NOTICE: php_value[display_errors] = 0 +[21-Oct-2026 20:14:43] NOTICE: php_value[session.save_path] = 2;/var/lib/php/www +[21-Oct-2026 20:14:43] NOTICE: php_admin_value[session.gc_maxlifetime] = 3600 +[21-Oct-2026 20:14:43] NOTICE: +[21-Oct-2026 20:14:43] NOTICE: [api] +[21-Oct-2026 20:14:43] NOTICE: prefix = undefined +[21-Oct-2026 20:14:43] NOTICE: user = undefined +[21-Oct-2026 20:14:43] NOTICE: group = undefined +[21-Oct-2026 20:14:43] NOTICE: listen = 127.0.0.1:9001 +[21-Oct-2026 20:14:43] NOTICE: listen.backlog = 65535 +[21-Oct-2026 20:14:43] NOTICE: listen.owner = undefined +[21-Oct-2026 20:14:43] NOTICE: listen.group = undefined +[21-Oct-2026 20:14:43] NOTICE: listen.mode = undefined +[21-Oct-2026 20:14:43] NOTICE: listen.allowed_clients = undefined +[21-Oct-2026 20:14:43] NOTICE: process.priority = undefined +[21-Oct-2026 20:14:43] NOTICE: process.dumpable = no +[21-Oct-2026 20:14:43] NOTICE: pm = ondemand +[21-Oct-2026 20:14:43] NOTICE: pm.max_children = 1 +[21-Oct-2026 20:14:43] NOTICE: pm.start_servers = 0 +[21-Oct-2026 20:14:43] NOTICE: pm.min_spare_servers = 0 +[21-Oct-2026 20:14:43] NOTICE: pm.max_spare_servers = 0 +[21-Oct-2026 20:14:43] NOTICE: pm.process_idle_timeout = 10 +[21-Oct-2026 20:14:43] NOTICE: pm.max_requests = 0 +[21-Oct-2026 20:14:43] NOTICE: pm.status_path = undefined +[21-Oct-2026 20:14:43] NOTICE: ping.path = undefined +[21-Oct-2026 20:14:43] NOTICE: ping.response = undefined +[21-Oct-2026 20:14:43] NOTICE: access.log = undefined +[21-Oct-2026 20:14:43] NOTICE: access.format = undefined +[21-Oct-2026 20:14:43] NOTICE: slowlog = undefined +[21-Oct-2026 20:14:43] NOTICE: request_slowlog_timeout = 0s +[21-Oct-2026 20:14:43] NOTICE: request_terminate_timeout = 0s +[21-Oct-2026 20:14:43] NOTICE: rlimit_files = 0 +[21-Oct-2026 20:14:43] NOTICE: rlimit_core = 0 +[21-Oct-2026 20:14:43] NOTICE: chroot = undefined +[21-Oct-2026 20:14:43] NOTICE: chdir = undefined +[21-Oct-2026 20:14:43] NOTICE: catch_workers_output = no +[21-Oct-2026 20:14:43] NOTICE: clear_env = yes +[21-Oct-2026 20:14:43] NOTICE: security.limit_extensions = .php .phar +[21-Oct-2026 20:14:43] NOTICE: php_value[session.gc_maxlifetime] = 60 +[21-Oct-2026 20:14:43] NOTICE: php_admin_value[session.gc_maxlifetime] = 7200 +[21-Oct-2026 20:14:43] NOTICE: +[21-Oct-2026 20:14:43] NOTICE: configuration file /etc/dump/test.conf test is successful + diff --git a/pkg/phpfpm/testdata/dump-7.4.txt b/pkg/phpfpm/testdata/dump-7.4.txt new file mode 100644 index 0000000..ad787c0 --- /dev/null +++ b/pkg/phpfpm/testdata/dump-7.4.txt @@ -0,0 +1,101 @@ +[21-Oct-2026 20:14:43] NOTICE: [global] +[21-Oct-2026 20:14:43] NOTICE: pid = undefined +[21-Oct-2026 20:14:43] NOTICE: error_log = /dev/null +[21-Oct-2026 20:14:43] NOTICE: syslog.ident = php-fpm +[21-Oct-2026 20:14:43] NOTICE: syslog.facility = 24 +[21-Oct-2026 20:14:43] NOTICE: log_buffering = yes +[21-Oct-2026 20:14:43] NOTICE: log_level = unknown value +[21-Oct-2026 20:14:43] NOTICE: log_limit = 1024 +[21-Oct-2026 20:14:43] NOTICE: emergency_restart_interval = 0s +[21-Oct-2026 20:14:43] NOTICE: emergency_restart_threshold = 0 +[21-Oct-2026 20:14:43] NOTICE: process_control_timeout = 0s +[21-Oct-2026 20:14:43] NOTICE: process.max = 0 +[21-Oct-2026 20:14:43] NOTICE: process.priority = undefined +[21-Oct-2026 20:14:43] NOTICE: daemonize = yes +[21-Oct-2026 20:14:43] NOTICE: rlimit_files = 0 +[21-Oct-2026 20:14:43] NOTICE: rlimit_core = 0 +[21-Oct-2026 20:14:43] NOTICE: events.mechanism = epoll +[21-Oct-2026 20:14:43] NOTICE: +[21-Oct-2026 20:14:43] NOTICE: [www] +[21-Oct-2026 20:14:43] NOTICE: prefix = undefined +[21-Oct-2026 20:14:43] NOTICE: user = undefined +[21-Oct-2026 20:14:43] NOTICE: group = undefined +[21-Oct-2026 20:14:43] NOTICE: listen = /run/php/www.sock +[21-Oct-2026 20:14:43] NOTICE: listen.backlog = 511 +[21-Oct-2026 20:14:43] NOTICE: listen.owner = undefined +[21-Oct-2026 20:14:43] NOTICE: listen.group = undefined +[21-Oct-2026 20:14:43] NOTICE: listen.mode = undefined +[21-Oct-2026 20:14:43] NOTICE: listen.allowed_clients = undefined +[21-Oct-2026 20:14:43] NOTICE: process.priority = undefined +[21-Oct-2026 20:14:43] NOTICE: process.dumpable = no +[21-Oct-2026 20:14:43] NOTICE: pm = static +[21-Oct-2026 20:14:43] NOTICE: pm.max_children = 2 +[21-Oct-2026 20:14:43] NOTICE: pm.start_servers = 0 +[21-Oct-2026 20:14:43] NOTICE: pm.min_spare_servers = 0 +[21-Oct-2026 20:14:43] NOTICE: pm.max_spare_servers = 0 +[21-Oct-2026 20:14:43] NOTICE: pm.process_idle_timeout = 10 +[21-Oct-2026 20:14:43] NOTICE: pm.max_requests = 0 +[21-Oct-2026 20:14:43] NOTICE: pm.status_path = /status +[21-Oct-2026 20:14:43] NOTICE: ping.path = undefined +[21-Oct-2026 20:14:43] NOTICE: ping.response = undefined +[21-Oct-2026 20:14:43] NOTICE: access.log = undefined +[21-Oct-2026 20:14:43] NOTICE: access.format = undefined +[21-Oct-2026 20:14:43] NOTICE: slowlog = /tmp/www.slow +[21-Oct-2026 20:14:43] NOTICE: request_slowlog_timeout = 3s +[21-Oct-2026 20:14:43] NOTICE: request_slowlog_trace_depth = 20 +[21-Oct-2026 20:14:43] NOTICE: request_terminate_timeout = 0s +[21-Oct-2026 20:14:43] NOTICE: request_terminate_timeout_track_finished = no +[21-Oct-2026 20:14:43] NOTICE: rlimit_files = 0 +[21-Oct-2026 20:14:43] NOTICE: rlimit_core = 0 +[21-Oct-2026 20:14:43] NOTICE: chroot = undefined +[21-Oct-2026 20:14:43] NOTICE: chdir = undefined +[21-Oct-2026 20:14:43] NOTICE: catch_workers_output = no +[21-Oct-2026 20:14:43] NOTICE: decorate_workers_output = yes +[21-Oct-2026 20:14:43] NOTICE: clear_env = yes +[21-Oct-2026 20:14:43] NOTICE: security.limit_extensions = .php .phar +[21-Oct-2026 20:14:43] NOTICE: php_value[display_errors] = 0 +[21-Oct-2026 20:14:43] NOTICE: php_value[session.save_path] = 2;/var/lib/php/www +[21-Oct-2026 20:14:43] NOTICE: php_admin_value[session.gc_maxlifetime] = 3600 +[21-Oct-2026 20:14:43] NOTICE: +[21-Oct-2026 20:14:43] NOTICE: [api] +[21-Oct-2026 20:14:43] NOTICE: prefix = undefined +[21-Oct-2026 20:14:43] NOTICE: user = undefined +[21-Oct-2026 20:14:43] NOTICE: group = undefined +[21-Oct-2026 20:14:43] NOTICE: listen = 127.0.0.1:9001 +[21-Oct-2026 20:14:43] NOTICE: listen.backlog = 511 +[21-Oct-2026 20:14:43] NOTICE: listen.owner = undefined +[21-Oct-2026 20:14:43] NOTICE: listen.group = undefined +[21-Oct-2026 20:14:43] NOTICE: listen.mode = undefined +[21-Oct-2026 20:14:43] NOTICE: listen.allowed_clients = undefined +[21-Oct-2026 20:14:43] NOTICE: process.priority = undefined +[21-Oct-2026 20:14:43] NOTICE: process.dumpable = no +[21-Oct-2026 20:14:43] NOTICE: pm = ondemand +[21-Oct-2026 20:14:43] NOTICE: pm.max_children = 1 +[21-Oct-2026 20:14:43] NOTICE: pm.start_servers = 0 +[21-Oct-2026 20:14:43] NOTICE: pm.min_spare_servers = 0 +[21-Oct-2026 20:14:43] NOTICE: pm.max_spare_servers = 0 +[21-Oct-2026 20:14:43] NOTICE: pm.process_idle_timeout = 10 +[21-Oct-2026 20:14:43] NOTICE: pm.max_requests = 0 +[21-Oct-2026 20:14:43] NOTICE: pm.status_path = undefined +[21-Oct-2026 20:14:43] NOTICE: ping.path = undefined +[21-Oct-2026 20:14:43] NOTICE: ping.response = undefined +[21-Oct-2026 20:14:43] NOTICE: access.log = undefined +[21-Oct-2026 20:14:43] NOTICE: access.format = undefined +[21-Oct-2026 20:14:43] NOTICE: slowlog = undefined +[21-Oct-2026 20:14:43] NOTICE: request_slowlog_timeout = 0s +[21-Oct-2026 20:14:43] NOTICE: request_slowlog_trace_depth = 20 +[21-Oct-2026 20:14:43] NOTICE: request_terminate_timeout = 0s +[21-Oct-2026 20:14:43] NOTICE: request_terminate_timeout_track_finished = no +[21-Oct-2026 20:14:43] NOTICE: rlimit_files = 0 +[21-Oct-2026 20:14:43] NOTICE: rlimit_core = 0 +[21-Oct-2026 20:14:43] NOTICE: chroot = undefined +[21-Oct-2026 20:14:43] NOTICE: chdir = undefined +[21-Oct-2026 20:14:43] NOTICE: catch_workers_output = no +[21-Oct-2026 20:14:43] NOTICE: decorate_workers_output = yes +[21-Oct-2026 20:14:43] NOTICE: clear_env = yes +[21-Oct-2026 20:14:43] NOTICE: security.limit_extensions = .php .phar +[21-Oct-2026 20:14:43] NOTICE: php_value[session.gc_maxlifetime] = 60 +[21-Oct-2026 20:14:43] NOTICE: php_admin_value[session.gc_maxlifetime] = 7200 +[21-Oct-2026 20:14:43] NOTICE: +[21-Oct-2026 20:14:43] NOTICE: configuration file /etc/dump/test.conf test is successful + diff --git a/pkg/phpfpm/testdata/dump-8.5.txt b/pkg/phpfpm/testdata/dump-8.5.txt new file mode 100644 index 0000000..a00891b --- /dev/null +++ b/pkg/phpfpm/testdata/dump-8.5.txt @@ -0,0 +1,104 @@ +[21-Oct-2026 20:14:43] NOTICE: [global] +[21-Oct-2026 20:14:43] NOTICE: pid = undefined +[21-Oct-2026 20:14:43] NOTICE: error_log = /dev/null +[21-Oct-2026 20:14:43] NOTICE: syslog.ident = php-fpm +[21-Oct-2026 20:14:43] NOTICE: syslog.facility = 24 +[21-Oct-2026 20:14:43] NOTICE: log_buffering = yes +[21-Oct-2026 20:14:43] NOTICE: log_level = unknown value +[21-Oct-2026 20:14:43] NOTICE: log_limit = 1024 +[21-Oct-2026 20:14:43] NOTICE: emergency_restart_interval = 0s +[21-Oct-2026 20:14:43] NOTICE: emergency_restart_threshold = 0 +[21-Oct-2026 20:14:43] NOTICE: process_control_timeout = 0s +[21-Oct-2026 20:14:43] NOTICE: process.max = 0 +[21-Oct-2026 20:14:43] NOTICE: process.priority = undefined +[21-Oct-2026 20:14:43] NOTICE: daemonize = yes +[21-Oct-2026 20:14:43] NOTICE: rlimit_files = 0 +[21-Oct-2026 20:14:43] NOTICE: rlimit_core = 0 +[21-Oct-2026 20:14:43] NOTICE: events.mechanism = epoll +[21-Oct-2026 20:14:43] NOTICE: +[21-Oct-2026 20:14:43] NOTICE: [www] +[21-Oct-2026 20:14:43] NOTICE: prefix = undefined +[21-Oct-2026 20:14:43] NOTICE: user = undefined +[21-Oct-2026 20:14:43] NOTICE: group = undefined +[21-Oct-2026 20:14:43] NOTICE: listen = /run/php/www.sock +[21-Oct-2026 20:14:43] NOTICE: listen.backlog = -1 +[21-Oct-2026 20:14:43] NOTICE: listen.owner = undefined +[21-Oct-2026 20:14:43] NOTICE: listen.group = undefined +[21-Oct-2026 20:14:43] NOTICE: listen.mode = undefined +[21-Oct-2026 20:14:43] NOTICE: listen.allowed_clients = undefined +[21-Oct-2026 20:14:43] NOTICE: process.priority = undefined +[21-Oct-2026 20:14:43] NOTICE: process.dumpable = no +[21-Oct-2026 20:14:43] NOTICE: pm = static +[21-Oct-2026 20:14:43] NOTICE: pm.max_children = 2 +[21-Oct-2026 20:14:43] NOTICE: pm.start_servers = 0 +[21-Oct-2026 20:14:43] NOTICE: pm.min_spare_servers = 0 +[21-Oct-2026 20:14:43] NOTICE: pm.max_spare_servers = 0 +[21-Oct-2026 20:14:43] NOTICE: pm.max_spawn_rate = 32 +[21-Oct-2026 20:14:43] NOTICE: pm.process_idle_timeout = 10 +[21-Oct-2026 20:14:43] NOTICE: pm.max_requests = 0 +[21-Oct-2026 20:14:43] NOTICE: pm.status_path = /status +[21-Oct-2026 20:14:43] NOTICE: pm.status_listen = undefined +[21-Oct-2026 20:14:43] NOTICE: ping.path = undefined +[21-Oct-2026 20:14:43] NOTICE: ping.response = undefined +[21-Oct-2026 20:14:43] NOTICE: access.log = undefined +[21-Oct-2026 20:14:43] NOTICE: access.format = undefined +[21-Oct-2026 20:14:43] NOTICE: slowlog = /tmp/www.slow +[21-Oct-2026 20:14:43] NOTICE: request_slowlog_timeout = 3s +[21-Oct-2026 20:14:43] NOTICE: request_slowlog_trace_depth = 20 +[21-Oct-2026 20:14:43] NOTICE: request_terminate_timeout = 0s +[21-Oct-2026 20:14:43] NOTICE: request_terminate_timeout_track_finished = no +[21-Oct-2026 20:14:43] NOTICE: rlimit_files = 0 +[21-Oct-2026 20:14:43] NOTICE: rlimit_core = 0 +[21-Oct-2026 20:14:43] NOTICE: chroot = undefined +[21-Oct-2026 20:14:43] NOTICE: chdir = undefined +[21-Oct-2026 20:14:43] NOTICE: catch_workers_output = no +[21-Oct-2026 20:14:43] NOTICE: decorate_workers_output = yes +[21-Oct-2026 20:14:43] NOTICE: clear_env = yes +[21-Oct-2026 20:14:43] NOTICE: security.limit_extensions = .php .phar +[21-Oct-2026 20:14:43] NOTICE: php_value[display_errors] = 0 +[21-Oct-2026 20:14:43] NOTICE: php_value[session.save_path] = 2;/var/lib/php/www +[21-Oct-2026 20:14:43] NOTICE: php_admin_value[session.gc_maxlifetime] = 3600 +[21-Oct-2026 20:14:43] NOTICE: +[21-Oct-2026 20:14:43] NOTICE: [api] +[21-Oct-2026 20:14:43] NOTICE: prefix = undefined +[21-Oct-2026 20:14:43] NOTICE: user = undefined +[21-Oct-2026 20:14:43] NOTICE: group = undefined +[21-Oct-2026 20:14:43] NOTICE: listen = 127.0.0.1:9001 +[21-Oct-2026 20:14:43] NOTICE: listen.backlog = -1 +[21-Oct-2026 20:14:43] NOTICE: listen.owner = undefined +[21-Oct-2026 20:14:43] NOTICE: listen.group = undefined +[21-Oct-2026 20:14:43] NOTICE: listen.mode = undefined +[21-Oct-2026 20:14:43] NOTICE: listen.allowed_clients = undefined +[21-Oct-2026 20:14:43] NOTICE: process.priority = undefined +[21-Oct-2026 20:14:43] NOTICE: process.dumpable = no +[21-Oct-2026 20:14:43] NOTICE: pm = ondemand +[21-Oct-2026 20:14:43] NOTICE: pm.max_children = 1 +[21-Oct-2026 20:14:43] NOTICE: pm.start_servers = 0 +[21-Oct-2026 20:14:43] NOTICE: pm.min_spare_servers = 0 +[21-Oct-2026 20:14:43] NOTICE: pm.max_spare_servers = 0 +[21-Oct-2026 20:14:43] NOTICE: pm.max_spawn_rate = 32 +[21-Oct-2026 20:14:43] NOTICE: pm.process_idle_timeout = 10 +[21-Oct-2026 20:14:43] NOTICE: pm.max_requests = 0 +[21-Oct-2026 20:14:43] NOTICE: pm.status_path = undefined +[21-Oct-2026 20:14:43] NOTICE: pm.status_listen = undefined +[21-Oct-2026 20:14:43] NOTICE: ping.path = undefined +[21-Oct-2026 20:14:43] NOTICE: ping.response = undefined +[21-Oct-2026 20:14:43] NOTICE: access.log = undefined +[21-Oct-2026 20:14:43] NOTICE: access.format = undefined +[21-Oct-2026 20:14:43] NOTICE: slowlog = undefined +[21-Oct-2026 20:14:43] NOTICE: request_slowlog_timeout = 0s +[21-Oct-2026 20:14:43] NOTICE: request_slowlog_trace_depth = 20 +[21-Oct-2026 20:14:43] NOTICE: request_terminate_timeout = 0s +[21-Oct-2026 20:14:43] NOTICE: request_terminate_timeout_track_finished = no +[21-Oct-2026 20:14:43] NOTICE: rlimit_files = 0 +[21-Oct-2026 20:14:43] NOTICE: rlimit_core = 0 +[21-Oct-2026 20:14:43] NOTICE: chroot = undefined +[21-Oct-2026 20:14:43] NOTICE: chdir = undefined +[21-Oct-2026 20:14:43] NOTICE: catch_workers_output = no +[21-Oct-2026 20:14:43] NOTICE: decorate_workers_output = yes +[21-Oct-2026 20:14:43] NOTICE: clear_env = yes +[21-Oct-2026 20:14:43] NOTICE: security.limit_extensions = .php .phar +[21-Oct-2026 20:14:43] NOTICE: php_value[session.gc_maxlifetime] = 60 +[21-Oct-2026 20:14:43] NOTICE: php_admin_value[session.gc_maxlifetime] = 7200 +[21-Oct-2026 20:14:43] NOTICE: +[21-Oct-2026 20:14:43] NOTICE: configuration file /etc/dump/test.conf test is successful diff --git a/pkg/phpfpm/testdata/php-fpm.conf b/pkg/phpfpm/testdata/php-fpm.conf deleted file mode 100644 index 7b517ae..0000000 --- a/pkg/phpfpm/testdata/php-fpm.conf +++ /dev/null @@ -1,125 +0,0 @@ -;;;;;;;;;;;;;;;;;;;;; -; FPM Configuration ; -;;;;;;;;;;;;;;;;;;;;; - -; All relative paths in this configuration file are relative to PHP's install -; prefix (/usr). This prefix can be dynamically changed by using the -; '-p' argument from the command line. - -;;;;;;;;;;;;;;;;;; -; Global Options ; -;;;;;;;;;;;;;;;;;; - -[global] -; Pid file -; Note: the default prefix is /var -; Default Value: none -;pid = /run/php-fpm/php-fpm.pid - -; Error log file -; If it's set to "syslog", log is sent to syslogd instead of being written -; into a local file. -; Note: the default prefix is /var -; Default Value: log/php-fpm.log -error_log = syslog - -; syslog_facility is used to specify what type of program is logging the -; message. This lets syslogd specify that messages from different facilities -; will be handled differently. -; See syslog(3) for possible values (ex daemon equiv LOG_DAEMON) -; Default Value: daemon -;syslog.facility = daemon - -; syslog_ident is prepended to every message. If you have multiple FPM -; instances running on the same server, you can change the default value -; which must suit common needs. -; Default Value: php-fpm -;syslog.ident = php-fpm - -; Log level -; Possible Values: alert, error, warning, notice, debug -; Default Value: notice -;log_level = notice - -; If this number of child processes exit with SIGSEGV or SIGBUS within the time -; interval set by emergency_restart_interval then FPM will restart. A value -; of '0' means 'Off'. -; Default Value: 0 -;emergency_restart_threshold = 0 - -; Interval of time used by emergency_restart_interval to determine when -; a graceful restart will be initiated. This can be useful to work around -; accidental corruptions in an accelerator's shared memory. -; Available Units: s(econds), m(inutes), h(ours), or d(ays) -; Default Unit: seconds -; Default Value: 0 -;emergency_restart_interval = 0 - -; Time limit for child processes to wait for a reaction on signals from master. -; Available units: s(econds), m(inutes), h(ours), or d(ays) -; Default Unit: seconds -; Default Value: 0 -;process_control_timeout = 0 - -; The maximum number of processes FPM will fork. This has been designed to control -; the global number of processes when using dynamic PM within a lot of pools. -; Use it with caution. -; Note: A value of 0 indicates no limit -; Default Value: 0 -; process.max = 128 - -; Specify the nice(2) priority to apply to the master process (only if set) -; The value can vary from -19 (highest priority) to 20 (lowest priority) -; Note: - It will only work if the FPM master process is launched as root -; - The pool process will inherit the master process priority -; unless specified otherwise -; Default Value: no set -; process.priority = -19 - -; Send FPM to background. Set to 'no' to keep FPM in foreground for debugging. -; Default Value: yes -;daemonize = yes - -; Set open file descriptor rlimit for the master process. -; Default Value: system defined value -;rlimit_files = 1024 - -; Set max core size rlimit for the master process. -; Possible Values: 'unlimited' or an integer greater or equal to 0 -; Default Value: system defined value -;rlimit_core = 0 - -; Specify the event mechanism FPM will use. The following is available: -; - select (any POSIX os) -; - poll (any POSIX os) -; - epoll (linux >= 2.5.44) -; - kqueue (FreeBSD >= 4.1, OpenBSD >= 2.9, NetBSD >= 2.0) -; - /dev/poll (Solaris >= 7) -; - port (Solaris >= 10) -; Default Value: not set (auto detection) -;events.mechanism = epoll - -; When FPM is built with systemd integration, specify the interval, -; in seconds, between health report notification to systemd. -; Set to 0 to disable. -; Available Units: s(econds), m(inutes), h(ours) -; Default Unit: seconds -; Default value: 10 -;systemd_interval = 10 - -;;;;;;;;;;;;;;;;;;;; -; Pool Definitions ; -;;;;;;;;;;;;;;;;;;;; - -; Multiple pools of child processes may be started with different listening -; ports and different management options. The name of the pool will be -; used in logs and stats. There is no limitation on the number of pools which -; FPM can handle. Your system will tell you anyway :) - -; Include one or more files. If glob(3) exists, it is used to include a bunch of -; files from a glob(3) pattern. This directive can be used everywhere in the -; file. -; Relative path can also be used. They will be prefixed by: -; - the global prefix if it's been set (-p argument) -; - /usr otherwise -include=/tmp/fpm-test/php-fpm.d/*.conf diff --git a/pkg/phpfpm/testdata/php-fpm.d/nostatus.conf b/pkg/phpfpm/testdata/php-fpm.d/nostatus.conf deleted file mode 100644 index 00f6b78..0000000 --- a/pkg/phpfpm/testdata/php-fpm.d/nostatus.conf +++ /dev/null @@ -1,7 +0,0 @@ -; A pool without a status page. php-fpm replaces each $pool with the name of the pool. -[nostatus] -listen = /run/php-fpm/$pool.sock -pm.status_listen = /run/php-fpm/$pool-status.sock -slowlog = /var/log/$pool/$pool.slow.log -request_slowlog_timeout = 5 -php_value[session.save_path] = /var/lib/php/$pool-sessions diff --git a/pkg/phpfpm/testdata/php-fpm.d/www.conf b/pkg/phpfpm/testdata/php-fpm.d/www.conf deleted file mode 100644 index 590ca4d..0000000 --- a/pkg/phpfpm/testdata/php-fpm.d/www.conf +++ /dev/null @@ -1,417 +0,0 @@ -; Start a new pool named 'www'. -; the variable $pool can be used in any directive and will be replaced by the -; pool name ('www' here) -[www] - -; Per pool prefix -; It only applies on the following directives: -; - 'access.log' -; - 'slowlog' -; - 'listen' (unixsocket) -; - 'chroot' -; - 'chdir' -; - 'php_values' -; - 'php_admin_values' -; When not set, the global prefix (or /usr) applies instead. -; Note: This directive can also be relative to the global prefix. -; Default Value: none -;prefix = /path/to/pools/$pool - -; Unix user/group of processes -; Note: The user is mandatory. If the group is not set, the default user's group -; will be used. -user = http -group = http - -; The address on which to accept FastCGI requests. -; Valid syntaxes are: -; 'ip.add.re.ss:port' - to listen on a TCP socket to a specific IPv4 address on -; a specific port; -; '[ip:6:addr:ess]:port' - to listen on a TCP socket to a specific IPv6 address on -; a specific port; -; 'port' - to listen on a TCP socket to all addresses -; (IPv6 and IPv4-mapped) on a specific port; -; '/path/to/unix/socket' - to listen on a unix socket. -; Note: This value is mandatory. -listen = /run/php-fpm/php-fpm.sock - -; Set listen(2) backlog. -; Default Value: 511 (-1 on FreeBSD and OpenBSD) -;listen.backlog = 511 - -; Set permissions for unix socket, if one is used. In Linux, read/write -; permissions must be set in order to allow connections from a web server. Many -; BSD-derived systems allow connections regardless of permissions. -; Default Values: user and group are set as the running user -; mode is set to 0660 -;listen.owner = http -;listen.group = http -listen.mode = 0666 -; When POSIX Access Control Lists are supported you can set them using -; these options, value is a comma separated list of user/group names. -; When set, listen.owner and listen.group are ignored -;listen.acl_users = -;listen.acl_groups = - -; List of addresses (IPv4/IPv6) of FastCGI clients which are allowed to connect. -; Equivalent to the FCGI_WEB_SERVER_ADDRS environment variable in the original -; PHP FCGI (5.2.2+). Makes sense only with a tcp listening socket. Each address -; must be separated by a comma. If this value is left blank, connections will be -; accepted from any ip address. -; Default Value: any -;listen.allowed_clients = 127.0.0.1 - -; Specify the nice(2) priority to apply to the pool processes (only if set) -; The value can vary from -19 (highest priority) to 20 (lower priority) -; Note: - It will only work if the FPM master process is launched as root -; - The pool processes will inherit the master process priority -; unless it specified otherwise -; Default Value: no set -; process.priority = -19 - -; Choose how the process manager will control the number of child processes. -; Possible Values: -; static - a fixed number (pm.max_children) of child processes; -; dynamic - the number of child processes are set dynamically based on the -; following directives. With this process management, there will be -; always at least 1 children. -; pm.max_children - the maximum number of children that can -; be alive at the same time. -; pm.start_servers - the number of children created on startup. -; pm.min_spare_servers - the minimum number of children in 'idle' -; state (waiting to process). If the number -; of 'idle' processes is less than this -; number then some children will be created. -; pm.max_spare_servers - the maximum number of children in 'idle' -; state (waiting to process). If the number -; of 'idle' processes is greater than this -; number then some children will be killed. -; ondemand - no children are created at startup. Children will be forked when -; new requests will connect. The following parameter are used: -; pm.max_children - the maximum number of children that -; can be alive at the same time. -; pm.process_idle_timeout - The number of seconds after which -; an idle process will be killed. -; Note: This value is mandatory. -pm = dynamic - -; The number of child processes to be created when pm is set to 'static' and the -; maximum number of child processes when pm is set to 'dynamic' or 'ondemand'. -; This value sets the limit on the number of simultaneous requests that will be -; served. Equivalent to the ApacheMaxClients directive with mpm_prefork. -; Equivalent to the PHP_FCGI_CHILDREN environment variable in the original PHP -; CGI. The below defaults are based on a server without much resources. Don't -; forget to tweak pm.* to fit your needs. -; Note: Used when pm is set to 'static', 'dynamic' or 'ondemand' -; Note: This value is mandatory. -pm.max_children = 5 - -; The number of child processes created on startup. -; Note: Used only when pm is set to 'dynamic' -; Default Value: min_spare_servers + (max_spare_servers - min_spare_servers) / 2 -pm.start_servers = 2 - -; The desired minimum number of idle server processes. -; Note: Used only when pm is set to 'dynamic' -; Note: Mandatory when pm is set to 'dynamic' -pm.min_spare_servers = 1 - -; The desired maximum number of idle server processes. -; Note: Used only when pm is set to 'dynamic' -; Note: Mandatory when pm is set to 'dynamic' -pm.max_spare_servers = 3 - -; The number of seconds after which an idle process will be killed. -; Note: Used only when pm is set to 'ondemand' -; Default Value: 10s -;pm.process_idle_timeout = 10s; - -; The number of requests each child process should execute before respawning. -; This can be useful to work around memory leaks in 3rd party libraries. For -; endless request processing specify '0'. Equivalent to PHP_FCGI_MAX_REQUESTS. -; Default Value: 0 -;pm.max_requests = 500 - -; The URI to view the FPM status page. If this value is not set, no URI will be -; recognized as a status page. It shows the following informations: -; pool - the name of the pool; -; process manager - static, dynamic or ondemand; -; start time - the date and time FPM has started; -; start since - number of seconds since FPM has started; -; accepted conn - the number of request accepted by the pool; -; listen queue - the number of request in the queue of pending -; connections (see backlog in listen(2)); -; max listen queue - the maximum number of requests in the queue -; of pending connections since FPM has started; -; listen queue len - the size of the socket queue of pending connections; -; idle processes - the number of idle processes; -; active processes - the number of active processes; -; total processes - the number of idle + active processes; -; max active processes - the maximum number of active processes since FPM -; has started; -; max children reached - number of times, the process limit has been reached, -; when pm tries to start more children (works only for -; pm 'dynamic' and 'ondemand'); -; Value are updated in real time. -; Example output: -; pool: www -; process manager: static -; start time: 01/Jul/2011:17:53:49 +0200 -; start since: 62636 -; accepted conn: 190460 -; listen queue: 0 -; max listen queue: 1 -; listen queue len: 42 -; idle processes: 4 -; active processes: 11 -; total processes: 15 -; max active processes: 12 -; max children reached: 0 -; -; By default the status page output is formatted as text/plain. Passing either -; 'html', 'xml' or 'json' in the query string will return the corresponding -; output syntax. Example: -; http://www.foo.bar/status -; http://www.foo.bar/status?json -; http://www.foo.bar/status?html -; http://www.foo.bar/status?xml -; -; By default the status page only outputs short status. Passing 'full' in the -; query string will also return status for each pool process. -; Example: -; http://www.foo.bar/status?full -; http://www.foo.bar/status?json&full -; http://www.foo.bar/status?html&full -; http://www.foo.bar/status?xml&full -; The Full status returns for each process: -; pid - the PID of the process; -; state - the state of the process (Idle, Running, ...); -; start time - the date and time the process has started; -; start since - the number of seconds since the process has started; -; requests - the number of requests the process has served; -; request duration - the duration in µs of the requests; -; request method - the request method (GET, POST, ...); -; request URI - the request URI with the query string; -; content length - the content length of the request (only with POST); -; user - the user (PHP_AUTH_USER) (or '-' if not set); -; script - the main script called (or '-' if not set); -; last request cpu - the %cpu the last request consumed -; it's always 0 if the process is not in Idle state -; because CPU calculation is done when the request -; processing has terminated; -; last request memory - the max amount of memory the last request consumed -; it's always 0 if the process is not in Idle state -; because memory calculation is done when the request -; processing has terminated; -; If the process is in Idle state, then informations are related to the -; last request the process has served. Otherwise informations are related to -; the current request being served. -; Example output: -; ************************ -; pid: 31330 -; state: Running -; start time: 01/Jul/2011:17:53:49 +0200 -; start since: 63087 -; requests: 12808 -; request duration: 1250261 -; request method: GET -; request URI: /test_mem.php?N=10000 -; content length: 0 -; user: - -; script: /home/fat/web/docs/php/test_mem.php -; last request cpu: 0.00 -; last request memory: 0 -; -; Note: There is a real-time FPM status monitoring sample web page available -; It's available in: /usr/share/php/fpm/status.html -; -; Note: The value must start with a leading slash (/). The value can be -; anything, but it may not be a good idea to use the .php extension or it -; may conflict with a real PHP file. -; Default Value: not set -pm.status_path = /status - -; The ping URI to call the monitoring page of FPM. If this value is not set, no -; URI will be recognized as a ping page. This could be used to test from outside -; that FPM is alive and responding, or to -; - create a graph of FPM availability (rrd or such); -; - remove a server from a group if it is not responding (load balancing); -; - trigger alerts for the operating team (24/7). -; Note: The value must start with a leading slash (/). The value can be -; anything, but it may not be a good idea to use the .php extension or it -; may conflict with a real PHP file. -; Default Value: not set -;ping.path = /ping - -; This directive may be used to customize the response of a ping request. The -; response is formatted as text/plain with a 200 response code. -; Default Value: pong -;ping.response = pong - -; The access log file -; Default: not set -;access.log = log/$pool.access.log - -; The access log format. -; The following syntax is allowed -; %%: the '%' character -; %C: %CPU used by the request -; it can accept the following format: -; - %{user}C for user CPU only -; - %{system}C for system CPU only -; - %{total}C for user + system CPU (default) -; %d: time taken to serve the request -; it can accept the following format: -; - %{seconds}d (default) -; - %{miliseconds}d -; - %{mili}d -; - %{microseconds}d -; - %{micro}d -; %e: an environment variable (same as $_ENV or $_SERVER) -; it must be associated with embraces to specify the name of the env -; variable. Some exemples: -; - server specifics like: %{REQUEST_METHOD}e or %{SERVER_PROTOCOL}e -; - HTTP headers like: %{HTTP_HOST}e or %{HTTP_USER_AGENT}e -; %f: script filename -; %l: content-length of the request (for POST request only) -; %m: request method -; %M: peak of memory allocated by PHP -; it can accept the following format: -; - %{bytes}M (default) -; - %{kilobytes}M -; - %{kilo}M -; - %{megabytes}M -; - %{mega}M -; %n: pool name -; %o: output header -; it must be associated with embraces to specify the name of the header: -; - %{Content-Type}o -; - %{X-Powered-By}o -; - %{Transfert-Encoding}o -; - .... -; %p: PID of the child that serviced the request -; %P: PID of the parent of the child that serviced the request -; %q: the query string -; %Q: the '?' character if query string exists -; %r: the request URI (without the query string, see %q and %Q) -; %R: remote IP address -; %s: status (response code) -; %t: server time the request was received -; it can accept a strftime(3) format: -; %d/%b/%Y:%H:%M:%S %z (default) -; The strftime(3) format must be encapsuled in a %{}t tag -; e.g. for a ISO8601 formatted timestring, use: %{%Y-%m-%dT%H:%M:%S%z}t -; %T: time the log has been written (the request has finished) -; it can accept a strftime(3) format: -; %d/%b/%Y:%H:%M:%S %z (default) -; The strftime(3) format must be encapsuled in a %{}t tag -; e.g. for a ISO8601 formatted timestring, use: %{%Y-%m-%dT%H:%M:%S%z}t -; %u: remote user -; -; Default: "%R - %u %t \"%m %r\" %s" -;access.format = "%R - %u %t \"%m %r%Q%q\" %s %f %{mili}d %{kilo}M %C%%" - -; The log file for slow requests -; Default Value: not set -; Note: slowlog is mandatory if request_slowlog_timeout is set -slowlog = log/$pool.log.slow - -; The timeout for serving a single request after which a PHP backtrace will be -; dumped to the 'slowlog' file. A value of '0s' means 'off'. -; Available units: s(econds)(default), m(inutes), h(ours), or d(ays) -; Default Value: 0 -request_slowlog_timeout = 1 - -; The timeout for serving a single request after which the worker process will -; be killed. This option should be used when the 'max_execution_time' ini option -; does not stop script execution for some reason. A value of '0' means 'off'. -; Available units: s(econds)(default), m(inutes), h(ours), or d(ays) -; Default Value: 0 -;request_terminate_timeout = 0 - -; Set open file descriptor rlimit. -; Default Value: system defined value -;rlimit_files = 1024 - -; Set max core size rlimit. -; Possible Values: 'unlimited' or an integer greater or equal to 0 -; Default Value: system defined value -;rlimit_core = 0 - -; Chroot to this directory at the start. This value must be defined as an -; absolute path. When this value is not set, chroot is not used. -; Note: you can prefix with '$prefix' to chroot to the pool prefix or one -; of its subdirectories. If the pool prefix is not set, the global prefix -; will be used instead. -; Note: chrooting is a great security feature and should be used whenever -; possible. However, all PHP paths will be relative to the chroot -; (error_log, sessions.save_path, ...). -; Default Value: not set -;chroot = - -; Chdir to this directory at the start. -; Note: relative path can be used. -; Default Value: current directory or / when chroot -;chdir = /srv/http - -; Redirect worker stdout and stderr into main error log. If not set, stdout and -; stderr will be redirected to /dev/null according to FastCGI specs. -; Note: on highloaded environement, this can cause some delay in the page -; process time (several ms). -; Default Value: no -;catch_workers_output = yes - -; Clear environment in FPM workers -; Prevents arbitrary environment variables from reaching FPM worker processes -; by clearing the environment in workers before env vars specified in this -; pool configuration are added. -; Setting to "no" will make all environment variables available to PHP code -; via getenv(), $_ENV and $_SERVER. -; Default Value: yes -;clear_env = no - -; Limits the extensions of the main script FPM will allow to parse. This can -; prevent configuration mistakes on the web server side. You should only limit -; FPM to .php extensions to prevent malicious users to use other extensions to -; execute php code. -; Note: set an empty value to allow all extensions. -; Default Value: .php -;security.limit_extensions = .php .php3 .php4 .php5 .php7 - -; Pass environment variables like LD_LIBRARY_PATH. All $VARIABLEs are taken from -; the current environment. -; Default Value: clean env -;env[HOSTNAME] = $HOSTNAME -;env[PATH] = /usr/local/bin:/usr/bin:/bin -;env[TMP] = /tmp -;env[TMPDIR] = /tmp -;env[TEMP] = /tmp - -; Additional php.ini defines, specific to this pool of workers. These settings -; overwrite the values previously defined in the php.ini. The directives are the -; same as the PHP SAPI: -; php_value/php_flag - you can set classic ini defines which can -; be overwritten from PHP call 'ini_set'. -; php_admin_value/php_admin_flag - these directives won't be overwritten by -; PHP call 'ini_set' -; For php_*flag, valid values are on, off, 1, 0, true, false, yes or no. - -; Defining 'extension' will load the corresponding shared extension from -; extension_dir. Defining 'disable_functions' or 'disable_classes' will not -; overwrite previously defined php.ini values, but will append the new value -; instead. - -; Note: path INI options can be relative and will be expanded with the prefix -; (pool, global or /usr) - -; Default Value: nothing is defined by default except the values in php.ini and -; specified at startup with the -d argument -;php_admin_value[sendmail_path] = /usr/sbin/sendmail -t -i -f www@my.domain.com -;php_flag[display_errors] = off -;php_admin_value[error_log] = /var/log/fpm-php.www.log -;php_admin_flag[log_errors] = on -;php_admin_value[memory_limit] = 32M -php_value[session.save_path] = "2;/var/lib/php/sessions" -php_value[session.gc_maxlifetime] = 60 -php_admin_value[session.gc_maxlifetime] = 3600 -php_admin_flag[log_errors] = on ; comment From 8e9000952cc1863b4f1e7dce927952fa82ba7fd4 Mon Sep 17 00:00:00 2001 From: asp24 <488588+asp24@users.noreply.github.com> Date: Wed, 21 Oct 2026 22:33:18 +0200 Subject: [PATCH 18/18] Prepare release 1.1.0 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e723b9..4f661ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,7 @@ and this project adheres to [Semantic Versioning](http://semver.org). --- -## [Unreleased] +## [1.1.0] - 2026-10-21 ### Added