Browse Source

feat: use tar to embed apps (#333)

Kévin Dunglas 1 year ago
parent
commit
537f899939
7 changed files with 148 additions and 55 deletions
  1. 1 1
      .dockerignore
  2. 0 1
      Dockerfile
  3. 0 1
      alpine.Dockerfile
  4. 0 0
      app.tar
  5. 2 4
      build-static.sh
  6. 145 47
      embed.go
  7. 0 1
      static-builder.Dockerfile

+ 1 - 1
.dockerignore

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

+ 0 - 1
Dockerfile

@@ -80,7 +80,6 @@ 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
 

+ 0 - 1
alpine.Dockerfile

@@ -77,7 +77,6 @@ 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
 

+ 0 - 0
embed/.gitignore → app.tar


+ 2 - 4
build-static.sh

@@ -105,8 +105,7 @@ cd ../..
 
 # Embed PHP app, if any
 if [ -d "$EMBED" ]; then
-    mv embed embed.bak
-    cp -R "$EMBED" embed
+    tar -cf app.tar -C "$EMBED" .
 fi
 
 cd caddy/frankenphp/
@@ -115,8 +114,7 @@ go build -buildmode=pie -tags "cgo netgo osusergo static_build" -ldflags "-linkm
 cd ../..
 
 if [ -d "$EMBED" ]; then
-    rm -Rf embed
-    mv embed.bak embed
+    truncate -s 0 app.tar
 fi
 
 "dist/$bin" version

+ 145 - 47
embed.go

@@ -1,53 +1,43 @@
 package frankenphp
 
 import (
+	"archive/tar"
+	"bytes"
 	"crypto/md5"
-	"embed"
 	_ "embed"
 	"encoding/hex"
+	"errors"
+	"fmt"
+	"io"
 	"io/fs"
+	"log"
 	"os"
+	"path"
 	"path/filepath"
+	"runtime"
 	"strings"
+	"time"
 )
 
-const embedDir = "embed"
-
 // The path of the embedded PHP application (empty if none)
 var EmbeddedAppPath string
 
-//go:embed all:embed
-var embeddedApp embed.FS
+//go:embed app.tar
+var embeddedApp []byte
 
 func init() {
-	entries, err := embeddedApp.ReadDir(embedDir)
-	if err != nil {
-		panic(err)
-	}
-
-	if len(entries) == 1 && entries[0].Name() == ".gitignore" {
-		//no embedded app
+	if len(embeddedApp) == 0 {
+		// 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))
+	h := md5.Sum(embeddedApp)
 	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 {
+	if err := untar(appPath); err != nil {
 		os.RemoveAll(appPath)
 		panic(err)
 	}
@@ -55,37 +45,145 @@ func init() {
 	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)
+// untar reads the tar file from r and writes it into dir.
+//
+// Adapted from https://github.com/golang/build/blob/master/cmd/buildlet/buildlet.go
+func untar(dir string) (err error) {
+	t0 := time.Now()
+	nFiles := 0
+	madeDir := map[string]bool{}
+
+	tr := tar.NewReader(bytes.NewReader(embeddedApp))
+	loggedChtimesError := false
+	for {
+		f, err := tr.Next()
+		if err == io.EOF {
+			break
+		}
+		if err != nil {
+			return fmt.Errorf("tar error: %w", err)
+		}
+		if f.Typeflag == tar.TypeXGlobalHeader {
+			// golang.org/issue/22748: git archive exports
+			// a global header ('g') which after Go 1.9
+			// (for a bit?) contained an empty filename.
+			// Ignore it.
+			continue
+		}
+		rel, err := nativeRelPath(f.Name)
+		if err != nil {
+			return fmt.Errorf("tar file contained invalid name %q: %v", f.Name, err)
+		}
+		abs := filepath.Join(dir, rel)
+
+		fi := f.FileInfo()
+		mode := fi.Mode()
+		switch {
+		case mode.IsRegular():
+			// Make the directory. This is redundant because it should
+			// already be made by a directory entry in the tar
+			// beforehand. Thus, don't check for errors; the next
+			// write will fail with the same error.
+			dir := filepath.Dir(abs)
+			if !madeDir[dir] {
+				if err := os.MkdirAll(filepath.Dir(abs), mode.Perm()); err != nil {
+					return err
+				}
+				madeDir[dir] = true
+			}
+			if runtime.GOOS == "darwin" && mode&0111 != 0 {
+				// See comment in writeFile.
+				err := os.Remove(abs)
+				if err != nil && !errors.Is(err, fs.ErrNotExist) {
+					return err
+				}
+			}
+			wf, err := os.OpenFile(abs, os.O_RDWR|os.O_CREATE|os.O_TRUNC, mode.Perm())
 			if err != nil {
 				return err
 			}
-
-			if err := copyToDisk(appPath, currentDir+"/"+name, entries); err != nil {
+			n, err := io.Copy(wf, tr)
+			if closeErr := wf.Close(); closeErr != nil && err == nil {
+				err = closeErr
+			}
+			if err != nil {
+				return fmt.Errorf("error writing to %s: %v", abs, err)
+			}
+			if n != f.Size {
+				return fmt.Errorf("only wrote %d bytes to %s; expected %d", n, abs, f.Size)
+			}
+			modTime := f.ModTime
+			if modTime.After(t0) {
+				// Clamp modtimes at system time. See
+				// golang.org/issue/19062 when clock on
+				// buildlet was behind the gitmirror server
+				// doing the git-archive.
+				modTime = t0
+			}
+			if !modTime.IsZero() {
+				if err := os.Chtimes(abs, modTime, modTime); err != nil && !loggedChtimesError {
+					// benign error. Gerrit doesn't even set the
+					// modtime in these, and we don't end up relying
+					// on it anywhere (the gomote push command relies
+					// on digests only), so this is a little pointless
+					// for now.
+					log.Printf("error changing modtime: %v (further Chtimes errors suppressed)", err)
+					loggedChtimesError = true // once is enough
+				}
+			}
+			nFiles++
+		case mode.IsDir():
+			if err := os.MkdirAll(abs, mode.Perm()); err != nil {
 				return err
 			}
-
-			continue
+			madeDir[abs] = true
+		case mode&os.ModeSymlink != 0:
+			// TODO: ignore these for now. They were breaking x/build tests.
+			// Implement these if/when we ever have a test that needs them.
+			// But maybe we'd have to skip creating them on Windows for some builders
+			// without permissions.
+		default:
+			return fmt.Errorf("tar file entry %s contained unsupported file type %v", f.Name, mode)
 		}
+	}
+	return nil
+}
 
-		data, err := embeddedApp.ReadFile(currentDir + "/" + name)
-		if err != nil {
-			return err
-		}
+// nativeRelPath verifies that p is a non-empty relative path
+// using either slashes or the buildlet's native path separator,
+// and returns it canonicalized to the native path separator.
+func nativeRelPath(p string) (string, error) {
+	if p == "" {
+		return "", errors.New("path not provided")
+	}
 
-		f := appPath + "/" + strings.TrimPrefix(currentDir, embedDir) + "/" + name
-		if err := os.WriteFile(f, data, 0500); err != nil {
-			return err
+	if filepath.Separator != '/' && strings.Contains(p, string(filepath.Separator)) {
+		clean := filepath.Clean(p)
+		if filepath.IsAbs(clean) {
+			return "", fmt.Errorf("path %q is not relative", p)
+		}
+		if clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) {
+			return "", fmt.Errorf("path %q refers to a parent directory", p)
 		}
+		if strings.HasPrefix(p, string(filepath.Separator)) || filepath.VolumeName(clean) != "" {
+			// On Windows, this catches semi-relative paths like "C:" (meaning “the
+			// current working directory on volume C:”) and "\windows" (meaning “the
+			// windows subdirectory of the current drive letter”).
+			return "", fmt.Errorf("path %q is relative to volume", p)
+		}
+		return p, nil
 	}
 
-	return nil
+	clean := path.Clean(p)
+	if path.IsAbs(clean) {
+		return "", fmt.Errorf("path %q is not relative", p)
+	}
+	if clean == ".." || strings.HasPrefix(clean, "../") {
+		return "", fmt.Errorf("path %q refers to a parent directory", p)
+	}
+	canon := filepath.FromSlash(p)
+	if filepath.VolumeName(canon) != "" {
+		return "", fmt.Errorf("path %q begins with a native volume name", p)
+	}
+	return canon, nil
 }

+ 0 - 1
static-builder.Dockerfile

@@ -70,6 +70,5 @@ WORKDIR /go/src/app
 COPY *.* ./
 COPY caddy caddy
 COPY C-Thread-Pool C-Thread-Pool
-COPY embed embed
 
 RUN --mount=type=secret,id=github-token GITHUB_TOKEN=$(cat /run/secrets/github-token) ./build-static.sh