Browse Source

feat: embed PHP apps into the FrankenPHP binary

Kévin Dunglas 1 year ago
parent
commit
6509cddd2a
10 changed files with 208 additions and 36 deletions
  1. 1 0
      .dockerignore
  2. 1 1
      .github/workflows/docker.yaml
  3. 11 2
      .github/workflows/static.yaml
  4. 1 0
      Dockerfile
  5. 1 0
      alpine.Dockerfile
  6. 48 26
      build-static.sh
  7. 30 4
      caddy/caddy.go
  8. 10 0
      caddy/php-server.go
  9. 14 3
      docs/static.md
  10. 91 0
      embed.go

+ 1 - 0
.dockerignore

@@ -10,3 +10,4 @@
 !testdata/*.php
 !testdata/*.txt
 !build-static.sh
+!embed/*

+ 1 - 1
.github/workflows/docker.yaml

@@ -84,7 +84,7 @@ jobs:
       -
         name: Build
         id: build
-        uses: docker/bake-action@v3
+        uses: docker/bake-action@v4
         with:
           pull: true
           load: ${{!fromJson(needs.prepare.outputs.push)}}

+ 11 - 2
.github/workflows/static.yaml

@@ -35,21 +35,30 @@ jobs:
         uses: docker/setup-buildx-action@v3
         with:
           version: latest
+      -
+        name: Login to DockerHub
+        if: ${{toJson(startsWith(github.ref, 'refs/tags/') || (github.ref == 'refs/heads/main' && github.event_name != 'pull_request'))}}
+        uses: docker/login-action@v3
+        with:
+          username: ${{secrets.REGISTRY_USERNAME}}
+          password: ${{secrets.REGISTRY_PASSWORD}}    
       -
         name: Build
         id: build
-        uses: docker/bake-action@v3
+        uses: docker/bake-action@v4
         with:
           pull: true
           load: true
+          push: ${{toJson(startsWith(github.ref, 'refs/tags/') || (github.ref == 'refs/heads/main' && github.event_name != 'pull_request'))}}
           targets: static-builder
           set: |
             *.cache-from=type=gha,scope=${{github.ref}}-static-builder
             *.cache-from=type=gha,scope=refs/heads/main-static-builder
             *.cache-to=type=gha,scope=${{github.ref}}-static-builder
         env:
-          VERSION: ${{github.ref_type == 'tag' && github.ref_name || github.sha}}
+          LATEST: '1' # TODO: unset this variable when releasing the first stable version
           SHA: ${{github.sha}}
+          VERSION: ${{github.ref_type == 'tag' && github.ref_name || github.sha}}
           GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
       -
         name: Copy binary

+ 1 - 0
Dockerfile

@@ -80,6 +80,7 @@ WORKDIR /go/src/app
 COPY --link *.* ./
 COPY --link caddy caddy
 COPY --link C-Thread-Pool C-Thread-Pool
+COPY --link embed embed
 COPY --link internal internal
 COPY --link testdata testdata
 

+ 1 - 0
alpine.Dockerfile

@@ -77,6 +77,7 @@ WORKDIR /go/src/app
 COPY --link *.* ./
 COPY --link caddy caddy
 COPY --link C-Thread-Pool C-Thread-Pool
+COPY --link embed embed
 COPY --link internal internal
 COPY --link testdata testdata
 

+ 48 - 26
build-static.sh

@@ -1,7 +1,6 @@
 #!/bin/sh
 
 set -o errexit
-set -o xtrace
 
 if ! type "git" > /dev/null; then
     echo "The \"git\" command must be installed."
@@ -48,37 +47,47 @@ fi
 
 bin="frankenphp-$os-$arch"
 
-mkdir -p dist/
-cd dist/
+if [ "$CLEAN" ]; then
+    rm -Rf dist/
+    go clean -cache
+fi
 
-if [ -d "static-php-cli/" ]; then
-    cd static-php-cli/
-    git pull
+# Build libphp if ncessary
+if [ -f "dist/static-php-cli/buildroot/lib/libphp.a" ]; then
+    cd dist/static-php-cli    
 else
-    git clone --depth 1 https://github.com/crazywhalecc/static-php-cli
-    cd static-php-cli/
-fi
+    mkdir -p dist/
+    cd dist/
 
-composer install --no-dev -a
+    if [ -d "static-php-cli/" ]; then
+        cd static-php-cli/
+        git pull
+    else
+        git clone --depth 1 https://github.com/crazywhalecc/static-php-cli
+        cd static-php-cli/
+    fi
 
+    if type "brew" > /dev/null; then
+        packages="composer"
+        if [ "$RELEASE" ]; then
+            packages="$packages gh"
+        fi
 
-if type "brew" > /dev/null; then
-    packages="composer"
-    if [ "$RELEASE" ]; then
-        packages="$packages gh"
+        brew install --formula --quiet "$packages"
     fi
 
-    brew install --formula --quiet "$packages"
-fi
+    composer install --no-dev -a
+
+    if [ "$os" = "linux" ]; then
+        extraOpts="--disable-opcache-jit"
+    fi
 
-if [ "$os" = "linux" ]; then
-    extraOpts="--disable-opcache-jit"
+    ./bin/spc doctor
+    ./bin/spc fetch --with-php="$PHP_VERSION" --for-extensions="$PHP_EXTENSIONS"
+    # shellcheck disable=SC2086
+    ./bin/spc build --enable-zts --build-embed $extraOpts "$PHP_EXTENSIONS" --with-libs="$PHP_EXTENSION_LIBS"
 fi
 
-./bin/spc doctor
-./bin/spc fetch --with-php="$PHP_VERSION" --for-extensions="$PHP_EXTENSIONS"
-# shellcheck disable=SC2086
-./bin/spc build --enable-zts --build-embed $extraOpts "$PHP_EXTENSIONS" --with-libs="$PHP_EXTENSION_LIBS"
 CGO_CFLAGS="-DFRANKENPHP_VERSION=$FRANKENPHP_VERSION $(./buildroot/bin/php-config --includes | sed s#-I/#-I"$PWD"/buildroot/#g)"
 export CGO_CFLAGS
 
@@ -92,15 +101,28 @@ export CGO_LDFLAGS
 LIBPHP_VERSION="$(./buildroot/bin/php-config --version)"
 export LIBPHP_VERSION
 
-cd ../../caddy/frankenphp/
+cd ../..
+
+# Embed PHP app, if any
+if [ -d "$EMBED" ]; then
+    mv embed embed.bak
+    cp -R "$EMBED" embed
+fi
+
+cd caddy/frankenphp/
 go env
 go build -buildmode=pie -tags "cgo netgo osusergo static_build" -ldflags "-linkmode=external -extldflags -static-pie -w -s -X 'github.com/caddyserver/caddy/v2.CustomVersion=FrankenPHP $FRANKENPHP_VERSION PHP $LIBPHP_VERSION Caddy'" -o "../../dist/$bin"
+cd ../..
+
+if [ -d "$EMBED" ]; then
+    rm -Rf embed
+    mv embed.bak embed
+fi
 
-cd ../../dist/
-"./$bin" version
+"dist/$bin" version
 
 if [ "$RELEASE" ]; then
-    gh release upload "$FRANKENPHP_VERSION" "$bin" --repo dunglas/frankenphp --clobber
+    gh release upload "$FRANKENPHP_VERSION" "dist/$bin" --repo dunglas/frankenphp --clobber
 fi
 
 if [ "$CURRENT_REF" ]; then

+ 30 - 4
caddy/caddy.go

@@ -7,6 +7,7 @@ import (
 	"encoding/json"
 	"errors"
 	"net/http"
+	"path/filepath"
 	"strconv"
 
 	"github.com/caddyserver/caddy/v2"
@@ -20,6 +21,8 @@ import (
 	"go.uber.org/zap"
 )
 
+const defaultDocumentRoot = "public"
+
 func init() {
 	caddy.RegisterModule(FrankenPHPApp{})
 	caddy.RegisterModule(FrankenPHPModule{})
@@ -93,8 +96,6 @@ func (f *FrankenPHPApp) Start() error {
 		}
 	}
 
-	logger.Info("FrankenPHP started 🐘", zap.String("php_version", frankenphp.Version().Version))
-
 	return nil
 }
 
@@ -169,6 +170,10 @@ func (f *FrankenPHPApp) UnmarshalCaddyfile(d *caddyfile.Dispenser) error {
 					if wc.FileName == "" {
 						return errors.New(`The "file" argument must be specified`)
 					}
+
+					if frankenphp.EmbeddedAppPath != "" && filepath.IsLocal(wc.FileName) {
+						wc.FileName = filepath.Join(frankenphp.EmbeddedAppPath, wc.FileName)
+					}
 				}
 
 				f.Workers = append(f.Workers, wc)
@@ -193,7 +198,7 @@ func parseGlobalOption(d *caddyfile.Dispenser, _ interface{}) (interface{}, erro
 }
 
 type FrankenPHPModule struct {
-	// Root sets the root folder to the site. Default: `root` directive.
+	// Root sets the root folder to the site. Default: `root` directive, or the path of the public directory of the embed app it exists.
 	Root string `json:"root,omitempty"`
 	// SplitPath sets the substrings for splitting the URI into two parts. The first matching substring will be used to split the "path info" from the path. The first piece is suffixed with the matching substring and will be assumed as the actual resource (CGI script) name. The second piece will be set to PATH_INFO for the CGI script to use. Default: `.php`.
 	SplitPath []string `json:"split_path,omitempty"`
@@ -217,8 +222,18 @@ func (f *FrankenPHPModule) Provision(ctx caddy.Context) error {
 	f.logger = ctx.Logger(f)
 
 	if f.Root == "" {
-		f.Root = "{http.vars.root}"
+		if frankenphp.EmbeddedAppPath == "" {
+			f.Root = "{http.vars.root}"
+		} else {
+			f.Root = filepath.Join(frankenphp.EmbeddedAppPath, defaultDocumentRoot)
+			f.ResolveRootSymlink = false
+		}
+	} else {
+		if frankenphp.EmbeddedAppPath != "" && filepath.IsLocal(f.Root) {
+			f.Root = filepath.Join(frankenphp.EmbeddedAppPath, f.Root)
+		}
 	}
+
 	if len(f.SplitPath) == 0 {
 		f.SplitPath = []string{".php"}
 	}
@@ -425,6 +440,17 @@ func parsePhpServer(h httpcaddyfile.Helper) ([]httpcaddyfile.ConfigValue, error)
 	// unmarshaler can read it from the start
 	dispenser.Reset()
 
+	if frankenphp.EmbeddedAppPath != "" {
+		if phpsrv.Root == "" {
+			phpsrv.Root = filepath.Join(frankenphp.EmbeddedAppPath, defaultDocumentRoot)
+			fsrv.Root = phpsrv.Root
+			phpsrv.ResolveRootSymlink = false
+		} else if filepath.IsLocal(fsrv.Root) {
+			phpsrv.Root = filepath.Join(frankenphp.EmbeddedAppPath, phpsrv.Root)
+			fsrv.Root = phpsrv.Root
+		}
+	}
+
 	// set up a route list that we'll append to
 	routes := caddyhttp.RouteList{}
 

+ 10 - 0
caddy/php-server.go

@@ -4,6 +4,7 @@ import (
 	"encoding/json"
 	"log"
 	"net/http"
+	"path/filepath"
 	"strconv"
 	"time"
 
@@ -15,6 +16,7 @@ import (
 	"github.com/caddyserver/caddy/v2/modules/caddyhttp/fileserver"
 	"github.com/caddyserver/caddy/v2/modules/caddyhttp/rewrite"
 	"github.com/caddyserver/certmagic"
+	"github.com/dunglas/frankenphp"
 	"go.uber.org/zap"
 
 	"github.com/spf13/cobra"
@@ -60,6 +62,14 @@ func cmdPHPServer(fs caddycmd.Flags) (int, error) {
 	debug := fs.Bool("debug")
 	compress := !fs.Bool("no-compress")
 
+	if frankenphp.EmbeddedAppPath != "" {
+		if root == "" {
+			root = filepath.Join(frankenphp.EmbeddedAppPath, defaultDocumentRoot)
+		} else if filepath.IsLocal(root) {
+			root = filepath.Join(frankenphp.EmbeddedAppPath, root)
+		}
+	}
+
 	const indexFile = "index.php"
 	extensions := []string{"php"}
 	tryFiles := []string{"{http.request.uri.path}", "{http.request.uri.path}/" + indexFile, indexFile}

+ 14 - 3
docs/static.md

@@ -31,8 +31,6 @@ docker buildx bake --load --set static-builder.args.PHP_EXTENSIONS=opcache,pdo_s
 # ...
 ```
 
-See [the list of supported extensions](https://static-php.dev/en/guide/extensions.html).
-
 To add libraries enabling additional functionality to the extensions you've enabled, you can pass use the `PHP_EXTENSION_LIBS` Docker ARG:
 
 ```console
@@ -43,6 +41,8 @@ docker buildx bake \
   static-builder
 ```
 
+See also: [customizing the build](#customizing-the-build)
+
 ### GitHub Token
 
 If you hit the GitHub API rate limit, set a GitHub Personal Access Token in an environment variable named `GITHUB_TOKEN`:
@@ -64,4 +64,15 @@ cd frankenphp
 
 Note: this script also works on Linux (and probably on other Unixes), and is used internally by the Docker based static builder we provide.
 
-See [the list of supported extensions](https://static-php.dev/en/guide/extensions.html).
+## Customizing The Build
+
+The following environment variables can be passed to `docker build` and to the `build-static.sh`
+script to customize the static build:
+
+* `FRANKENPHP_VERSION`: the version of FrankenPHP to use
+* `PHP_VERSION`: the version of PHP to use
+* `PHP_EXTENSIONS`: the PHP extensions to build ([list of supported extensions](https://static-php.dev/en/guide/extensions.html))
+* `PHP_EXTENSION_LIBS`: extra libraries to build that add extra features to the extensions
+* `EMBED`: path of the PHP application to embed in the binary
+* `CLEAN`: when set, libphp and all its dependencies are built from scratch (no cache)
+* `RELEASE`: (maintainers only) when set, the resulting binary will be uploaded on GitHub

+ 91 - 0
embed.go

@@ -0,0 +1,91 @@
+package frankenphp
+
+import (
+	"crypto/md5"
+	"embed"
+	_ "embed"
+	"encoding/hex"
+	"io/fs"
+	"os"
+	"path/filepath"
+	"strings"
+)
+
+const embedDir = "embed"
+
+// The path of the embedded PHP application (empty if none)
+var EmbeddedAppPath string
+
+//go:embed all:embed
+var embeddedApp embed.FS
+
+func init() {
+	entries, err := embeddedApp.ReadDir(embedDir)
+	if err != nil {
+		panic(err)
+	}
+
+	if len(entries) == 1 && entries[0].Name() == ".gitignore" {
+		//no embedded app
+		return
+	}
+
+	e, err := os.Executable()
+	if err != nil {
+		panic(err)
+	}
+
+	e, err = filepath.EvalSymlinks(e)
+	if err != nil {
+		panic(err)
+	}
+
+	// TODO: use XXH3 instead of MD5
+	h := md5.Sum([]byte(e))
+	appPath := filepath.Join(os.TempDir(), "frankenphp_"+hex.EncodeToString(h[:]))
+
+	if err := os.RemoveAll(appPath); err != nil {
+		panic(err)
+	}
+	if err := copyToDisk(appPath, embedDir, entries); err != nil {
+		os.RemoveAll(appPath)
+		panic(err)
+	}
+
+	EmbeddedAppPath = appPath
+}
+
+func copyToDisk(appPath string, currentDir string, entries []fs.DirEntry) error {
+	if err := os.Mkdir(appPath+strings.TrimPrefix(currentDir, embedDir), 0700); err != nil {
+		return err
+	}
+
+	for _, entry := range entries {
+		name := entry.Name()
+
+		if entry.IsDir() {
+			entries, err := embeddedApp.ReadDir(currentDir + "/" + name)
+			if err != nil {
+				return err
+			}
+
+			if err := copyToDisk(appPath, currentDir+"/"+name, entries); err != nil {
+				return err
+			}
+
+			continue
+		}
+
+		data, err := embeddedApp.ReadFile(currentDir + "/" + name)
+		if err != nil {
+			return err
+		}
+
+		f := appPath + "/" + strings.TrimPrefix(currentDir, embedDir) + "/" + name
+		if err := os.WriteFile(f, data, 0500); err != nil {
+			return err
+		}
+	}
+
+	return nil
+}

Some files were not shown because too many files changed in this diff