Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add CLI support #239

Merged
merged 8 commits into from
Oct 9, 2023
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 ./...
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
/caddy/frankenphp/frankenphp
/internal/testserver/testserver
internal/testcli/testcli
.idea/
.vscode/
__debug_bin
Expand Down
37 changes: 37 additions & 0 deletions caddy/php-cli.go
Original file line number Diff line number Diff line change
@@ -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")
}
Comment on lines +29 to +31
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Makes me think, is there value in making php-cli -i work for example? (Could be useful to check enabled features). Some people use php -a and php -l on occasion too. But 🤷‍♂️

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it would be great if ./frankenphp php <args> behaved identical to php <args> with php-cli installed.

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That would be nice, but it's a bit more work. The best option would be to allow the embedding of the official PHP SAPI (this requires a patch on PHP). I suggest merging this patch as is and trying to improve it later.


status := frankenphp.ExecuteScriptCLI(args[0], args)
os.Exit(status)

return status, nil
Comment on lines +34 to +36
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm. This return will never be reached. I think returning will let Caddy call os.Exit anyway, so I don't think you need to do that yourself.

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This doesn't work (the returned status code is always 1 in case of error, even if set explicitly in PHP with exit).

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤔 then that's possibly a bug in Caddy. Good to know.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Opened caddyserver/caddy#5874 to fix that. The status code wasn't being carried through since we enabled Cobra

}
2 changes: 1 addition & 1 deletion caddy/command.go → caddy/php-server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
131 changes: 131 additions & 0 deletions frankenphp.c
Original file line number Diff line number Diff line change
Expand Up @@ -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>

Expand Down Expand Up @@ -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);
dunglas marked this conversation as resolved.
Show resolved Hide resolved

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;
}
15 changes: 15 additions & 0 deletions frankenphp.go
Original file line number Diff line number Diff line change
Expand Up @@ -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]))))
}
2 changes: 2 additions & 0 deletions frankenphp.h
Original file line number Diff line number Diff line change
Expand Up @@ -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
100 changes: 65 additions & 35 deletions frankenphp_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"net/textproto"
"net/url"
"os"
"os/exec"
"strconv"
"strings"
"sync"
Expand Down Expand Up @@ -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"})
Expand Down Expand Up @@ -557,6 +535,43 @@ func TestVersion(t *testing.T) {
assert.NotEmpty(t, v.Version, 0)
}

func TestFiberNoCgo_module(t *testing.T) { testFiberNoCgo(t, &testOptions{}) }
func TestFiberNonCgo_worker(t *testing.T) {
testFiberNoCgo(t, &testOptions{workerScript: "fiber-no-cgo.php"})
}
func testFiberNoCgo(t *testing.T, opts *testOptions) {
runTest(t, func(handler func(http.ResponseWriter, *http.Request), _ *httptest.Server, i int) {
req := httptest.NewRequest("GET", fmt.Sprintf("http://example.com/fiber-no-cgo.php?i=%d", i), nil)
w := httptest.NewRecorder()
handler(w, req)

resp := w.Result()
body, _ := io.ReadAll(resp.Body)

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)
Expand All @@ -572,19 +587,34 @@ func ExampleServeHTTP() {
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"})
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 testFiberNoCgo(t *testing.T, opts *testOptions) {
runTest(t, func(handler func(http.ResponseWriter, *http.Request), _ *httptest.Server, i int) {
req := httptest.NewRequest("GET", fmt.Sprintf("http://example.com/fiber-no-cgo.php?i=%d", i), nil)
w := httptest.NewRecorder()
handler(w, req)

resp := w.Result()
body, _ := io.ReadAll(resp.Body)
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/"

assert.Equal(t, string(body), fmt.Sprintf("Fiber %d", i))
}, opts)
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 changes: 17 additions & 0 deletions internal/testcli/main.go
Original file line number Diff line number Diff line change
@@ -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 changes: 6 additions & 0 deletions testdata/command.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
<?php

var_dump($argv, $_SERVER);
echo "From the CLI\n";

exit(3);