feat: add experimental CLI support (#239)

* feat: add CLI support

* updated

* debug

* fix tests

* Caddy php-cli command

* use thread

* $_SERVER and input streams support

* Update frankenphp.c

Co-authored-by: Francis Lavoie <lavofr@gmail.com>

---------

Co-authored-by: Francis Lavoie <lavofr@gmail.com>
This commit is contained in:
Kévin Dunglas
2023-10-09 14:38:15 +02:00
committed by GitHub
parent af3ed6e26d
commit c615fe0087
10 changed files with 282 additions and 39 deletions

View File

@@ -41,6 +41,10 @@ jobs:
run: go build
env:
GOEXPERIMENT: cgocheck2
-
name: Build testcli binary
working-directory: internal/testcli/
run: go build
-
name: Run library tests
run: go test -race -v ./...

1
.gitignore vendored
View File

@@ -1,5 +1,6 @@
/caddy/frankenphp/frankenphp
/internal/testserver/testserver
internal/testcli/testcli
.idea/
.vscode/
__debug_bin

37
caddy/php-cli.go Normal file
View File

@@ -0,0 +1,37 @@
package caddy
import (
"errors"
"os"
caddycmd "github.com/caddyserver/caddy/v2/cmd"
"github.com/dunglas/frankenphp"
"github.com/spf13/cobra"
)
func init() {
caddycmd.RegisterCommand(caddycmd.Command{
Name: "php-cli",
Usage: "script.php [args ...]",
Short: "Runs a PHP command",
Long: `
Executes a PHP script similarly to the CLI SAPI.`,
CobraFunc: func(cmd *cobra.Command) {
cmd.DisableFlagParsing = true
cmd.RunE = caddycmd.WrapCommandFuncForCobra(cmdPHPCLI)
},
})
}
func cmdPHPCLI(fs caddycmd.Flags) (int, error) {
args := os.Args[2:]
if len(args) < 1 {
return 1, errors.New("the path to the PHP script is required")
}
status := frankenphp.ExecuteScriptCLI(args[0], args)
os.Exit(status)
return status, nil
}

View File

@@ -23,7 +23,7 @@ import (
func init() {
caddycmd.RegisterCommand(caddycmd.Command{
Name: "php-server",
Usage: "[--domain <example.com>] [--root <path>] [--listen <addr>] [--access-log]",
Usage: "[--domain <example.com>] [--root <path>] [--listen <addr>] [--access-log] [--debug] [--no-compress]",
Short: "Spins up a production-ready PHP server",
Long: `
A simple but production-ready PHP server. Useful for quick deployments,

View File

@@ -12,6 +12,7 @@
#include <Zend/zend_types.h>
#include <Zend/zend_exceptions.h>
#include <Zend/zend_interfaces.h>
#include <sapi/embed/php_embed.h>
#include <ext/standard/head.h>
#include <ext/spl/spl_exceptions.h>
@@ -663,3 +664,133 @@ int frankenphp_execute_script(const char* file_name)
return status;
}
// Use global variables to store CLI arguments to prevent useless allocations
static char *cli_script;
static int cli_argc;
static char **cli_argv;
// Adapted from https://github.com/php/php-src/sapi/cli/php_cli.c (The PHP Group, The PHP License)
static void cli_register_file_handles(bool no_close) /* {{{ */
{
php_stream *s_in, *s_out, *s_err;
php_stream_context *sc_in=NULL, *sc_out=NULL, *sc_err=NULL;
zend_constant ic, oc, ec;
s_in = php_stream_open_wrapper_ex("php://stdin", "rb", 0, NULL, sc_in);
s_out = php_stream_open_wrapper_ex("php://stdout", "wb", 0, NULL, sc_out);
s_err = php_stream_open_wrapper_ex("php://stderr", "wb", 0, NULL, sc_err);
if (s_in==NULL || s_out==NULL || s_err==NULL) {
if (s_in) php_stream_close(s_in);
if (s_out) php_stream_close(s_out);
if (s_err) php_stream_close(s_err);
return;
}
if (no_close) {
s_in->flags |= PHP_STREAM_FLAG_NO_CLOSE;
s_out->flags |= PHP_STREAM_FLAG_NO_CLOSE;
s_err->flags |= PHP_STREAM_FLAG_NO_CLOSE;
}
//s_in_process = s_in;
php_stream_to_zval(s_in, &ic.value);
php_stream_to_zval(s_out, &oc.value);
php_stream_to_zval(s_err, &ec.value);
ZEND_CONSTANT_SET_FLAGS(&ic, CONST_CS, 0);
ic.name = zend_string_init_interned("STDIN", sizeof("STDIN")-1, 0);
zend_register_constant(&ic);
ZEND_CONSTANT_SET_FLAGS(&oc, CONST_CS, 0);
oc.name = zend_string_init_interned("STDOUT", sizeof("STDOUT")-1, 0);
zend_register_constant(&oc);
ZEND_CONSTANT_SET_FLAGS(&ec, CONST_CS, 0);
ec.name = zend_string_init_interned("STDERR", sizeof("STDERR")-1, 0);
zend_register_constant(&ec);
}
/* }}} */
static void sapi_cli_register_variables(zval *track_vars_array) /* {{{ */
{
size_t len;
char *docroot = "";
/* In CGI mode, we consider the environment to be a part of the server
* variables
*/
php_import_environment_variables(track_vars_array);
/* Build the special-case PHP_SELF variable for the CLI version */
len = strlen(cli_script);
if (sapi_module.input_filter(PARSE_SERVER, "PHP_SELF", &cli_script, len, &len)) {
php_register_variable("PHP_SELF", cli_script, track_vars_array);
}
if (sapi_module.input_filter(PARSE_SERVER, "SCRIPT_NAME", &cli_script, len, &len)) {
php_register_variable("SCRIPT_NAME", cli_script, track_vars_array);
}
/* filenames are empty for stdin */
if (sapi_module.input_filter(PARSE_SERVER, "SCRIPT_FILENAME", &cli_script, len, &len)) {
php_register_variable("SCRIPT_FILENAME", cli_script, track_vars_array);
}
if (sapi_module.input_filter(PARSE_SERVER, "PATH_TRANSLATED", &cli_script, len, &len)) {
php_register_variable("PATH_TRANSLATED", cli_script, track_vars_array);
}
/* just make it available */
len = 0U;
if (sapi_module.input_filter(PARSE_SERVER, "DOCUMENT_ROOT", &docroot, len, &len)) {
php_register_variable("DOCUMENT_ROOT", docroot, track_vars_array);
}
}
/* }}} */
static void * execute_script_cli(void *arg) {
void *exit_status;
// The SAPI name "cli" is hardcoded into too many programs... let's usurp it.
php_embed_module.name = "cli";
php_embed_module.pretty_name = "PHP CLI embedded in FrankenPHP";
php_embed_module.register_server_variables = sapi_cli_register_variables;
php_embed_init(cli_argc, cli_argv);
cli_register_file_handles(false);
zend_first_try {
zend_file_handle file_handle;
zend_stream_init_filename(&file_handle, cli_script);
php_execute_script(&file_handle);
} zend_end_try();
exit_status = (void *) (intptr_t) EG(exit_status);
php_embed_shutdown();
return exit_status;
}
int frankenphp_execute_script_cli(char *script, int argc, char **argv) {
pthread_t thread;
int err;
void *exit_status;
cli_script = script;
cli_argc = argc;
cli_argv = argv;
// Start the script in a dedicated thread to prevent conflicts between Go and PHP signal handlers
err = pthread_create(&thread, NULL, execute_script_cli, NULL);
if (err != 0) {
return err;
}
err = pthread_join(thread, &exit_status);
if (err != 0) {
return err;
}
return (intptr_t) exit_status;
}

View File

@@ -666,3 +666,18 @@ func go_log(message *C.char, level C.int) {
l.Info(m, zap.Stringer("syslog_level", syslogLevel(level)))
}
}
// ExecuteScriptCLI executes the PHP script passed as parameter.
// It returns the exit status code of the script.
func ExecuteScriptCLI(script string, args []string) int {
cScript := C.CString(script)
defer C.free(unsafe.Pointer(cScript))
argc := C.int(len(args))
argv := make([]*C.char, argc)
for i, arg := range args {
argv[i] = C.CString(arg)
}
return int(C.frankenphp_execute_script_cli(cScript, argc, (**C.char)(unsafe.Pointer(&argv[0]))))
}

View File

@@ -53,4 +53,6 @@ int frankenphp_execute_script(const char *file_name);
uintptr_t frankenphp_request_shutdown();
void frankenphp_register_bulk_variables(char **variables, size_t size, zval *track_vars_array);
int frankenphp_execute_script_cli(char *script, int argc, char **argv);
#endif

View File

@@ -12,6 +12,7 @@ import (
"net/textproto"
"net/url"
"os"
"os/exec"
"strconv"
"strings"
"sync"
@@ -85,29 +86,6 @@ func runTest(t *testing.T, test func(func(http.ResponseWriter, *http.Request), *
wg.Wait()
}
func BenchmarkHelloWorld(b *testing.B) {
if err := frankenphp.Init(frankenphp.WithLogger(zap.NewNop())); err != nil {
panic(err)
}
defer frankenphp.Shutdown()
cwd, _ := os.Getwd()
testDataDir := cwd + "/testdata/"
handler := func(w http.ResponseWriter, r *http.Request) {
req := frankenphp.NewRequestWithContext(r, testDataDir, nil)
if err := frankenphp.ServeHTTP(w, req); err != nil {
panic(err)
}
}
req := httptest.NewRequest("GET", "http://example.com/index.php", nil)
w := httptest.NewRecorder()
for i := 0; i < b.N; i++ {
handler(w, req)
}
}
func TestHelloWorld_module(t *testing.T) { testHelloWorld(t, nil) }
func TestHelloWorld_worker(t *testing.T) {
testHelloWorld(t, &testOptions{workerScript: "index.php"})
@@ -557,21 +535,6 @@ func TestVersion(t *testing.T) {
assert.NotEmpty(t, v.Version, 0)
}
func ExampleServeHTTP() {
if err := frankenphp.Init(); err != nil {
panic(err)
}
defer frankenphp.Shutdown()
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
req := frankenphp.NewRequestWithContext(r, "/path/to/document/root", nil)
if err := frankenphp.ServeHTTP(w, req); err != nil {
panic(err)
}
})
log.Fatal(http.ListenAndServe(":8080", nil))
}
func TestFiberNoCgo_module(t *testing.T) { testFiberNoCgo(t, &testOptions{}) }
func TestFiberNonCgo_worker(t *testing.T) {
testFiberNoCgo(t, &testOptions{workerScript: "fiber-no-cgo.php"})
@@ -588,3 +551,70 @@ func testFiberNoCgo(t *testing.T, opts *testOptions) {
assert.Equal(t, string(body), fmt.Sprintf("Fiber %d", i))
}, opts)
}
func TestExecuteScriptCLI(t *testing.T) {
if _, err := os.Stat("internal/testcli/testcli"); err != nil {
t.Skip("internal/testcli/testcli has not been compiled, run `cd internal/testcli/ && go build`")
}
cmd := exec.Command("internal/testcli/testcli", "testdata/command.php", "foo", "bar")
stdoutStderr, err := cmd.CombinedOutput()
assert.Error(t, err)
if exitError, ok := err.(*exec.ExitError); ok {
assert.Equal(t, 3, exitError.ExitCode())
}
stdoutStderrStr := string(stdoutStderr)
assert.Contains(t, stdoutStderrStr, `"foo"`)
assert.Contains(t, stdoutStderrStr, `"bar"`)
assert.Contains(t, stdoutStderrStr, "From the CLI")
}
func ExampleServeHTTP() {
if err := frankenphp.Init(); err != nil {
panic(err)
}
defer frankenphp.Shutdown()
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
req := frankenphp.NewRequestWithContext(r, "/path/to/document/root", nil)
if err := frankenphp.ServeHTTP(w, req); err != nil {
panic(err)
}
})
log.Fatal(http.ListenAndServe(":8080", nil))
}
func ExampleExecuteScriptCLI() {
if len(os.Args) <= 1 {
log.Println("Usage: my-program script.php")
os.Exit(1)
}
os.Exit(frankenphp.ExecuteScriptCLI(os.Args[1], os.Args))
}
func BenchmarkHelloWorld(b *testing.B) {
if err := frankenphp.Init(frankenphp.WithLogger(zap.NewNop())); err != nil {
panic(err)
}
defer frankenphp.Shutdown()
cwd, _ := os.Getwd()
testDataDir := cwd + "/testdata/"
handler := func(w http.ResponseWriter, r *http.Request) {
req := frankenphp.NewRequestWithContext(r, testDataDir, nil)
if err := frankenphp.ServeHTTP(w, req); err != nil {
panic(err)
}
}
req := httptest.NewRequest("GET", "http://example.com/index.php", nil)
w := httptest.NewRecorder()
for i := 0; i < b.N; i++ {
handler(w, req)
}
}

17
internal/testcli/main.go Normal file
View File

@@ -0,0 +1,17 @@
package main
import (
"log"
"os"
"github.com/dunglas/frankenphp"
)
func main() {
if len(os.Args) <= 1 {
log.Println("Usage: testcli script.php")
os.Exit(1)
}
os.Exit(frankenphp.ExecuteScriptCLI(os.Args[1], os.Args))
}

6
testdata/command.php vendored Normal file
View File

@@ -0,0 +1,6 @@
<?php
var_dump($argv, $_SERVER);
echo "From the CLI\n";
exit(3);