diff --git a/cmd/auto_regenerate_test.go b/cmd/auto_regenerate_test.go index 7aba4e9..a1a07c9 100644 --- a/cmd/auto_regenerate_test.go +++ b/cmd/auto_regenerate_test.go @@ -209,6 +209,30 @@ func TestAutoRegenerate(t *testing.T) { wantRegen: true, wantBuild: true, }, + { + name: "missing base.Dockerfile forces structural regen + build", + seedYAML: yamlFrankenphpBase, + mutate: func(t *testing.T, dir string) string { + // Pre-split project: monolithic Dockerfile present, base absent. + // Version + hash both match, so only the structural trigger fires. + os.Remove(filepath.Join(dir, ".frank", "base.Dockerfile")) + return "1.0.0" + }, + wantRegen: true, + wantBuild: true, // base.Dockerfile missing → dockerfileChanged fail-safe + }, + { + name: "edited base.Dockerfile forces build on regen", + seedYAML: yamlFrankenphpBase, + mutate: func(t *testing.T, dir string) string { + // base.Dockerfile drifted from template; dev fires Tier 1 regen, + // dockerfileChanged then sees the mismatch and forces --build. + os.WriteFile(filepath.Join(dir, ".frank", "base.Dockerfile"), []byte("FROM scratch\n"), 0644) + return "dev" + }, + wantRegen: true, + wantBuild: true, + }, { name: "malformed yaml skips gracefully", seedYAML: yamlFrankenphpBase, @@ -264,6 +288,38 @@ func TestAutoRegenerate_QueueRepro(t *testing.T) { } } +// TestDockerfileChanged_BaseDockerfile proves base.Dockerfile is part of the +// diff set: a freshly generated project reports no change, while deleting or +// editing base.Dockerfile (leaving the primary Dockerfile intact) flips it true. +func TestDockerfileChanged_BaseDockerfile(t *testing.T) { + dir := seedFrankProject(t, yamlFrankenphpBase, "1.0.0") + cfg, err := config.Load(dir) + if err != nil { + t.Fatalf("config.Load: %v", err) + } + + if dockerfileChanged(dir, cfg) { + t.Fatal("freshly generated project should report no dockerfile change") + } + + // Edit base.Dockerfile only → must report changed. + base := filepath.Join(dir, ".frank", "base.Dockerfile") + if err := os.WriteFile(base, []byte("FROM scratch\n"), 0644); err != nil { + t.Fatal(err) + } + if !dockerfileChanged(dir, cfg) { + t.Error("edited base.Dockerfile should report changed") + } + + // Delete base.Dockerfile → must report changed. + if err := os.Remove(base); err != nil { + t.Fatal(err) + } + if !dockerfileChanged(dir, cfg) { + t.Error("missing base.Dockerfile should report changed") + } +} + // TestFrankConfigHash_Deterministic ensures the hash is stable across calls so // it doesn't churn or cause spurious regenerations. func TestFrankConfigHash_Deterministic(t *testing.T) { diff --git a/cmd/baseimage_test.go b/cmd/baseimage_test.go new file mode 100644 index 0000000..c1a4d94 --- /dev/null +++ b/cmd/baseimage_test.go @@ -0,0 +1,28 @@ +package cmd + +import "testing" + +func TestComposeSubcmdBuilds(t *testing.T) { + cases := []struct { + name string + args []string + want bool + }{ + {"build", []string{"build"}, true}, + {"up detached", []string{"up", "-d"}, true}, + {"run", []string{"run", "laravel.test", "bash"}, true}, + {"create", []string{"create"}, true}, + {"ps", []string{"ps", "-a"}, false}, + {"logs follow", []string{"logs", "-f"}, false}, + {"down", []string{"down"}, false}, + {"leading file flag then build", []string{"-f", "x", "build"}, true}, + {"empty", []string{}, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := composeSubcmdBuilds(tc.args); got != tc.want { + t.Errorf("composeSubcmdBuilds(%v) = %v, want %v", tc.args, got, tc.want) + } + }) + } +} diff --git a/cmd/compose.go b/cmd/compose.go index 96f9e2c..c10d148 100644 --- a/cmd/compose.go +++ b/cmd/compose.go @@ -45,5 +45,14 @@ func runCompose(cmd *cobra.Command, args []string) error { composeArgs = composeArgs[1:] } + // Build-capable subcommands (build/up/run/create) need the shared base + // image present, since the thin .frank/Dockerfile is `FROM frank/runtime`. + // Read-only subcommands (ps/logs/down/…) skip this. + if composeSubcmdBuilds(composeArgs) { + if err := ensureBaseImage(dir); err != nil { + return err + } + } + return docker.New(dir).Run(composeArgs...) } diff --git a/cmd/down.go b/cmd/down.go index b4f2108..62d64b1 100644 --- a/cmd/down.go +++ b/cmd/down.go @@ -43,6 +43,9 @@ var downCmd = &cobra.Command{ } } + // `docker compose down` runs without --rmi, so the shared base + // (frank/runtime:*) is NOT removed — other Frank projects depend on it. + // Do not add --rmi here. region := output.Region("Stopping containers") err := client.RunStream(region, "down") region.Stop(err) diff --git a/cmd/eject.go b/cmd/eject.go index 6af9adc..a21b908 100644 --- a/cmd/eject.go +++ b/cmd/eject.go @@ -2,12 +2,15 @@ package cmd import ( "fmt" + "path/filepath" "strings" + "github.com/phlisg/frank/internal/baseimage" "github.com/phlisg/frank/internal/compose" "github.com/phlisg/frank/internal/config" "github.com/phlisg/frank/internal/docker" "github.com/phlisg/frank/internal/output" + "github.com/phlisg/frank/internal/template" "github.com/spf13/cobra" ) @@ -74,6 +77,54 @@ func runEject(cmd *cobra.Command, args []string) error { output.Warning(fmt.Sprintf("could not restore phpunit.xml: %v", err)) } + // Flatten .frank/Dockerfile from the thin `FROM frank/runtime:` form + // back to a self-contained Dockerfile. Sail can't rebuild FROM frank/runtime + // (it has no notion of Frank's shared base), so an ejected project must be + // fully Frank-independent. Non-fatal: the project still works against any + // already-built image. + if err := flattenDockerfile(dir, cfg); err != nil { + output.Warning(fmt.Sprintf("could not flatten .frank/Dockerfile: %v", err)) + } else { + output.Detail("flattened .frank/Dockerfile to self-contained form") + } + fmt.Println(" eject complete — run ./vendor/bin/sail up to start containers") return nil } + +// caddyfileBlock is the laravel.test-specific COPY layer the base template omits. +// In the pre-split monolithic Dockerfile it sat between the user-setup block and +// the entrypoint heredoc — flattening reinserts it there so the byte output +// matches that original single-file form. +const caddyfileBlock = "# Copy Caddyfile (generated at .frank/Caddyfile; build context is project root)\n" + + "COPY .frank/Caddyfile /etc/caddy/Caddyfile\n\n" + +// entrypointMarker is the comment line that opens the entrypoint heredoc section +// in both base templates. The Caddyfile block is reinserted immediately before it. +const entrypointMarker = "# Entrypoint (heredoc" + +// flattenDockerfile rewrites .frank/Dockerfile from the thin +// `FROM frank/runtime:` form into a self-contained Dockerfile, so the +// ejected project no longer depends on Frank's shared base image. +// +// It renders the base Dockerfile (whose FROM is dunglas/frankenphp:… for +// frankenphp, ubuntu:24.04 for fpm) and — for frankenphp only — reinserts the +// app-specific Caddyfile COPY layer that the base template omits, restoring the +// original monolithic byte-for-byte layout. +func flattenDockerfile(dir string, cfg *config.Config) error { + engine := template.New(TemplateFS) + + body, err := baseimage.Render(engine, cfg) + if err != nil { + return fmt.Errorf("render base Dockerfile: %w", err) + } + + if cfg.PHP.Runtime == "frankenphp" { + body = strings.Replace(body, entrypointMarker, caddyfileBlock+entrypointMarker, 1) + } + + if err := writeFile(filepath.Join(dir, ".frank", "Dockerfile"), body); err != nil { + return err + } + return nil +} diff --git a/cmd/eject_test.go b/cmd/eject_test.go new file mode 100644 index 0000000..7a79747 --- /dev/null +++ b/cmd/eject_test.go @@ -0,0 +1,79 @@ +package cmd + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/phlisg/frank/internal/config" +) + +// TestFlattenDockerfile asserts that eject's flatten step rewrites +// .frank/Dockerfile into a self-contained form: no FROM frank/runtime, the +// runtime's real base FROM, and (frankenphp only) the Caddyfile COPY layer. +func TestFlattenDockerfile(t *testing.T) { + tests := []struct { + name string + cfg *config.Config + wantFrom string + wantCaddyCopy bool + }{ + { + name: "frankenphp", + cfg: &config.Config{ + PHP: config.PHP{Version: "8.5", Runtime: "frankenphp"}, + Services: []string{"pgsql", "mailpit"}, + }, + wantFrom: "FROM dunglas/frankenphp", + wantCaddyCopy: true, + }, + { + name: "fpm", + cfg: &config.Config{ + PHP: config.PHP{Version: "8.4", Runtime: "fpm"}, + Services: []string{"mysql", "redis"}, + }, + wantFrom: "FROM ubuntu:24.04", + wantCaddyCopy: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dir := t.TempDir() + frankDir := filepath.Join(dir, ".frank") + if err := os.MkdirAll(frankDir, 0755); err != nil { + t.Fatal(err) + } + // Placeholder Caddyfile + thin Dockerfile to be overwritten. + if err := os.WriteFile(filepath.Join(frankDir, "Caddyfile"), []byte("# placeholder\n"), 0644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(frankDir, "Dockerfile"), []byte("FROM frank/runtime:thin\n"), 0644); err != nil { + t.Fatal(err) + } + + if err := flattenDockerfile(dir, tt.cfg); err != nil { + t.Fatalf("flattenDockerfile: %v", err) + } + + out, err := os.ReadFile(filepath.Join(frankDir, "Dockerfile")) + if err != nil { + t.Fatal(err) + } + got := string(out) + + if strings.Contains(got, "FROM frank/runtime") { + t.Errorf("flattened Dockerfile still contains FROM frank/runtime") + } + if !strings.Contains(got, tt.wantFrom) { + t.Errorf("flattened Dockerfile missing %q\n---\n%s", tt.wantFrom, got) + } + hasCaddy := strings.Contains(got, "COPY .frank/Caddyfile") + if hasCaddy != tt.wantCaddyCopy { + t.Errorf("Caddyfile COPY present=%v, want %v", hasCaddy, tt.wantCaddyCopy) + } + }) + } +} diff --git a/cmd/generate.go b/cmd/generate.go index 6d152ae..8a5da5f 100644 --- a/cmd/generate.go +++ b/cmd/generate.go @@ -9,6 +9,7 @@ import ( "path/filepath" "strings" + "github.com/phlisg/frank/internal/baseimage" "github.com/phlisg/frank/internal/cert" "github.com/phlisg/frank/internal/compose" "github.com/phlisg/frank/internal/config" @@ -162,6 +163,18 @@ func generate(cfg *config.Config, dir, version string) error { } output.Detail("wrote .frank/Dockerfile") + // Render the shared base Dockerfile (project-invariant; built once and reused + // by the thin .frank/Dockerfile via FROM frank/runtime:). One render for + // both runtimes — baseimage.Render selects the template by cfg.PHP.Runtime. + baseDockerfile, err := baseimage.Render(engine, cfg) + if err != nil { + return fmt.Errorf("render base.Dockerfile: %w", err) + } + if err := writeFile(filepath.Join(frankDir, "base.Dockerfile"), baseDockerfile); err != nil { + return err + } + output.Detail("wrote .frank/base.Dockerfile") + switch cfg.PHP.Runtime { case "frankenphp": caddyfile, err := engine.RenderRuntime("frankenphp", "Caddyfile.tmpl", data) diff --git a/cmd/generate_test.go b/cmd/generate_test.go index 2a3ae94..e701073 100644 --- a/cmd/generate_test.go +++ b/cmd/generate_test.go @@ -37,7 +37,7 @@ var integrationFixtures = []integrationFixture{ Laravel: config.Laravel{Version: "13.x"}, Services: []string{"pgsql", "mailpit"}, }, - files: []string{".frank/compose.yaml", ".env", ".env.example", ".frank/Dockerfile", ".frank/Caddyfile", ".frank/vite-server.js", ".mcp.json"}, + files: []string{".frank/compose.yaml", ".env", ".env.example", ".frank/Dockerfile", ".frank/base.Dockerfile", ".frank/Caddyfile", ".frank/vite-server.js", ".mcp.json"}, }, { name: "fpm-mysql-redis", @@ -46,7 +46,7 @@ var integrationFixtures = []integrationFixture{ Laravel: config.Laravel{Version: "12.x"}, Services: []string{"mysql", "redis"}, }, - files: []string{".frank/compose.yaml", ".env", ".env.example", ".frank/Dockerfile", ".frank/nginx.conf", ".frank/nginx.Dockerfile", ".frank/vite-server.js", ".mcp.json"}, + files: []string{".frank/compose.yaml", ".env", ".env.example", ".frank/Dockerfile", ".frank/base.Dockerfile", ".frank/nginx.conf", ".frank/nginx.Dockerfile", ".frank/vite-server.js", ".mcp.json"}, }, { name: "frankenphp-sqlite", @@ -55,7 +55,7 @@ var integrationFixtures = []integrationFixture{ Laravel: config.Laravel{Version: "13.x"}, Services: []string{"sqlite"}, }, - files: []string{".frank/compose.yaml", ".env", ".env.example", ".frank/Dockerfile", ".frank/Caddyfile", ".frank/vite-server.js", ".mcp.json"}, + files: []string{".frank/compose.yaml", ".env", ".env.example", ".frank/Dockerfile", ".frank/base.Dockerfile", ".frank/Caddyfile", ".frank/vite-server.js", ".mcp.json"}, }, { name: "frankenphp-pgsql-pnpm", @@ -65,7 +65,7 @@ var integrationFixtures = []integrationFixture{ Services: []string{"pgsql", "mailpit"}, Node: config.Node{PackageManager: "pnpm"}, }, - files: []string{".frank/compose.yaml", ".env", ".env.example", ".frank/Dockerfile", ".frank/Caddyfile", ".frank/vite-server.js", ".mcp.json"}, + files: []string{".frank/compose.yaml", ".env", ".env.example", ".frank/Dockerfile", ".frank/base.Dockerfile", ".frank/Caddyfile", ".frank/vite-server.js", ".mcp.json"}, }, { name: "frankenphp-pgsql-workers", @@ -80,7 +80,7 @@ var integrationFixtures = []integrationFixture{ }, }, }, - files: []string{".frank/compose.yaml", ".env", ".env.example", ".frank/Dockerfile", ".frank/Caddyfile", ".frank/vite-server.js", ".mcp.json"}, + files: []string{".frank/compose.yaml", ".env", ".env.example", ".frank/Dockerfile", ".frank/base.Dockerfile", ".frank/Caddyfile", ".frank/vite-server.js", ".mcp.json"}, }, { name: "fpm-mysql-redis-workers", @@ -96,7 +96,7 @@ var integrationFixtures = []integrationFixture{ }, }, }, - files: []string{".frank/compose.yaml", ".env", ".env.example", ".frank/Dockerfile", ".frank/nginx.conf", ".frank/nginx.Dockerfile", ".frank/vite-server.js", ".mcp.json"}, + files: []string{".frank/compose.yaml", ".env", ".env.example", ".frank/Dockerfile", ".frank/base.Dockerfile", ".frank/nginx.conf", ".frank/nginx.Dockerfile", ".frank/vite-server.js", ".mcp.json"}, }, { name: "frankenphp-pgsql-no-https", @@ -106,7 +106,7 @@ var integrationFixtures = []integrationFixture{ Services: []string{"pgsql", "mailpit"}, Server: config.Server{HTTPS: new(bool)}, }, - files: []string{".frank/compose.yaml", ".env", ".env.example", ".frank/Dockerfile", ".frank/Caddyfile", ".frank/vite-server.js", ".mcp.json"}, + files: []string{".frank/compose.yaml", ".env", ".env.example", ".frank/Dockerfile", ".frank/base.Dockerfile", ".frank/Caddyfile", ".frank/vite-server.js", ".mcp.json"}, }, } @@ -234,6 +234,40 @@ func readTestFile(t *testing.T, dir, name string) string { return string(data) } +// TestGenerate_BaseDockerfile verifies generate() emits both a thin +// .frank/Dockerfile (FROM frank/runtime:) and a self-contained +// .frank/base.Dockerfile (FROM dunglas/frankenphp, no frank/runtime ref). +// Uses the real generate() pipeline in a tempdir, matching the integration +// harness above. +func TestGenerate_BaseDockerfile(t *testing.T) { + dir := filepath.Join(t.TempDir(), "frankenphp-base") + if err := os.MkdirAll(dir, 0755); err != nil { + t.Fatalf("mkdir: %v", err) + } + + cfg := &config.Config{ + PHP: config.PHP{Version: "8.5", Runtime: "frankenphp"}, + Laravel: config.Laravel{Version: "13.x"}, + Services: []string{"pgsql", "mailpit"}, + } + if err := generate(cfg, dir, "dev"); err != nil { + t.Fatalf("generate: %v", err) + } + + base := readTestFile(t, dir, ".frank/base.Dockerfile") + if !strings.Contains(base, "FROM dunglas/frankenphp") { + t.Errorf("base.Dockerfile must build from upstream image, got:\n%s", base) + } + if strings.Contains(base, "FROM frank/runtime") { + t.Error("base.Dockerfile must not reference frank/runtime (it IS the base)") + } + + thin := readTestFile(t, dir, ".frank/Dockerfile") + if !strings.Contains(thin, "FROM frank/runtime") { + t.Errorf("thin Dockerfile must derive from frank/runtime, got:\n%s", thin) + } +} + func TestWriteMCPConfig(t *testing.T) { t.Run("create_new", func(t *testing.T) { dir := t.TempDir() diff --git a/cmd/testdata/fpm-mysql-redis-workers/.frank/Dockerfile b/cmd/testdata/fpm-mysql-redis-workers/.frank/Dockerfile index 2250f3a..e799bc1 100644 --- a/cmd/testdata/fpm-mysql-redis-workers/.frank/Dockerfile +++ b/cmd/testdata/fpm-mysql-redis-workers/.frank/Dockerfile @@ -1,117 +1,2 @@ # Generated by Frank — edit frank.yaml, not this file -FROM ubuntu:24.04 - -ARG WWWGROUP=1000 -ARG NODE_VERSION=24 -ARG POSTGRES_VERSION=17 -ARG MYSQL_CLIENT=mysql-client - -ENV DEBIAN_FRONTEND=noninteractive -ENV TZ=UTC - -RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone - -# Base system tools -RUN mkdir -p /etc/apt/keyrings \ - && apt-get update && apt-get install -y --no-install-recommends \ - gnupg gosu curl ca-certificates zip unzip git supervisor \ - sqlite3 libgd3 python3 dnsutils \ - librsvg2-bin fswatch ffmpeg nano \ - && rm -rf /usr/share/man /usr/share/doc \ - && apt-get clean && rm -rf /var/lib/apt/lists/* - -# PHP via ondrej PPA -RUN curl -sS 'https://keyserver.ubuntu.com/pks/lookup?op=get&search=0xb8dc7e53946656efbce4c1dd71daeaab4ad4cab6' \ - | gpg --dearmor | tee /etc/apt/keyrings/ppa_ondrej_php.gpg > /dev/null \ - && echo "deb [signed-by=/etc/apt/keyrings/ppa_ondrej_php.gpg] https://ppa.launchpadcontent.net/ondrej/php/ubuntu noble main" \ - > /etc/apt/sources.list.d/ppa_ondrej_php.list \ - && apt-get update \ - && apt-get install -y --no-install-recommends \ - php8.4-fpm php8.4-cli \ - php8.4-pgsql php8.4-sqlite3 php8.4-gd \ - php8.4-curl php8.4-mongodb php8.4-imap \ - php8.4-mysql php8.4-mbstring php8.4-xml \ - php8.4-zip php8.4-bcmath php8.4-soap \ - php8.4-intl php8.4-readline php8.4-ldap \ - php8.4-msgpack php8.4-igbinary php8.4-redis \ - php8.4-memcached php8.4-pcov php8.4-imagick \ - php8.4-xdebug \ - && rm -rf /usr/share/man /usr/share/doc \ - && apt-get clean && rm -rf /var/lib/apt/lists/* - -# Configure FPM: TCP port 9000, log to stderr, workers run as sail. -# php-fpm master runs as root (required for PID file, worker management). -# The pool handles the sail user drop — gosu is not used for php-fpm. -# Note: `daemonize = no` was removed — PHP 8.5 dropped this global directive. -# Foreground mode is enforced via the -F flag in CMD instead. -# Dev-friendly OPcache: recheck file timestamps every request (no stale bytecode) -RUN printf "opcache.revalidate_freq=0\nmemory_limit=1G\n" > /etc/php/8.4/fpm/conf.d/99-frank-dev.ini \ - && printf "opcache.revalidate_freq=0\nmemory_limit=1G\n" > /etc/php/8.4/cli/conf.d/99-frank-dev.ini - -RUN sed -i 's|error_log = .*|error_log = /dev/stderr|' /etc/php/8.4/fpm/php-fpm.conf \ - && sed -i 's|listen = .*|listen = 0.0.0.0:9000|' /etc/php/8.4/fpm/pool.d/www.conf \ - && sed -i 's|^user = .*|user = sail|' /etc/php/8.4/fpm/pool.d/www.conf \ - && sed -i 's|^group = .*|group = sail|' /etc/php/8.4/fpm/pool.d/www.conf - -# Node.js -RUN curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key \ - | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg \ - && echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_${NODE_VERSION}.x nodistro main" \ - > /etc/apt/sources.list.d/nodesource.list \ - && apt-get update \ - && apt-get install -y --no-install-recommends nodejs \ - && npm install -g bun \ - && corepack enable \ - && corepack prepare npm@latest pnpm@latest --activate \ - && apt-get clean && rm -rf /var/lib/apt/lists/* - -# Database clients -RUN curl -sS https://www.postgresql.org/media/keys/ACCC4CF8.asc \ - | gpg --dearmor | tee /etc/apt/keyrings/pgdg.gpg > /dev/null \ - && echo "deb [signed-by=/etc/apt/keyrings/pgdg.gpg] http://apt.postgresql.org/pub/repos/apt $(. /etc/os-release && echo $VERSION_CODENAME)-pgdg main" \ - > /etc/apt/sources.list.d/pgdg.list \ - && apt-get update \ - && apt-get install -y --no-install-recommends \ - $MYSQL_CLIENT postgresql-client-$POSTGRES_VERSION \ - && apt-get clean && rm -rf /var/lib/apt/lists/* - -# Composer -COPY --from=composer:latest /usr/bin/composer /usr/bin/composer - -# User setup: remove the default ubuntu user (frees GID 1000), then create sail. -# Using userdel+groupadd matches Sail's actual implementation and is simpler than -# a conditional groupmod — the ubuntu user is always present on ubuntu:24.04. -RUN userdel -r ubuntu \ - && groupadd --force -g $WWWGROUP sail \ - && useradd -ms /bin/bash --no-user-group -g $WWWGROUP -u 1337 sail \ - && git config --system --add safe.directory /var/www/html - -# Entrypoint (heredoc — single-quoted delimiter suppresses $VAR expansion in body) -RUN cat <<'SCRIPT' > /entrypoint.sh -#!/usr/bin/env bash -if [ -n "$WWWUSER" ]; then - usermod -u "$WWWUSER" sail -fi -mkdir -p /var/www/html/storage/psysh 2>/dev/null || true -if [ -d "/var/www/html/storage" ]; then - chown -R sail:sail /var/www/html/storage /var/www/html/bootstrap/cache 2>/dev/null || true - chmod -R 755 /var/www/html/storage /var/www/html/bootstrap/cache 2>/dev/null || true -fi -if [ -f "/var/www/html/.env.example" ] && [ ! -f "/var/www/html/.env" ]; then - cp /var/www/html/.env.example /var/www/html/.env - php artisan key:generate --no-interaction 2>/dev/null || true -fi -if [[ "$1" == php-fpm* ]]; then - exec "$@" -else - exec gosu sail "$@" -fi -SCRIPT -RUN chmod +x /entrypoint.sh - -WORKDIR /var/www/html -EXPOSE 9000 - -USER root -ENTRYPOINT ["/entrypoint.sh"] -CMD ["php-fpm8.4", "-F"] +FROM frank/runtime:8.4-fpm-node24-pg17 diff --git a/cmd/testdata/fpm-mysql-redis-workers/.frank/base.Dockerfile b/cmd/testdata/fpm-mysql-redis-workers/.frank/base.Dockerfile new file mode 100644 index 0000000..2250f3a --- /dev/null +++ b/cmd/testdata/fpm-mysql-redis-workers/.frank/base.Dockerfile @@ -0,0 +1,117 @@ +# Generated by Frank — edit frank.yaml, not this file +FROM ubuntu:24.04 + +ARG WWWGROUP=1000 +ARG NODE_VERSION=24 +ARG POSTGRES_VERSION=17 +ARG MYSQL_CLIENT=mysql-client + +ENV DEBIAN_FRONTEND=noninteractive +ENV TZ=UTC + +RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone + +# Base system tools +RUN mkdir -p /etc/apt/keyrings \ + && apt-get update && apt-get install -y --no-install-recommends \ + gnupg gosu curl ca-certificates zip unzip git supervisor \ + sqlite3 libgd3 python3 dnsutils \ + librsvg2-bin fswatch ffmpeg nano \ + && rm -rf /usr/share/man /usr/share/doc \ + && apt-get clean && rm -rf /var/lib/apt/lists/* + +# PHP via ondrej PPA +RUN curl -sS 'https://keyserver.ubuntu.com/pks/lookup?op=get&search=0xb8dc7e53946656efbce4c1dd71daeaab4ad4cab6' \ + | gpg --dearmor | tee /etc/apt/keyrings/ppa_ondrej_php.gpg > /dev/null \ + && echo "deb [signed-by=/etc/apt/keyrings/ppa_ondrej_php.gpg] https://ppa.launchpadcontent.net/ondrej/php/ubuntu noble main" \ + > /etc/apt/sources.list.d/ppa_ondrej_php.list \ + && apt-get update \ + && apt-get install -y --no-install-recommends \ + php8.4-fpm php8.4-cli \ + php8.4-pgsql php8.4-sqlite3 php8.4-gd \ + php8.4-curl php8.4-mongodb php8.4-imap \ + php8.4-mysql php8.4-mbstring php8.4-xml \ + php8.4-zip php8.4-bcmath php8.4-soap \ + php8.4-intl php8.4-readline php8.4-ldap \ + php8.4-msgpack php8.4-igbinary php8.4-redis \ + php8.4-memcached php8.4-pcov php8.4-imagick \ + php8.4-xdebug \ + && rm -rf /usr/share/man /usr/share/doc \ + && apt-get clean && rm -rf /var/lib/apt/lists/* + +# Configure FPM: TCP port 9000, log to stderr, workers run as sail. +# php-fpm master runs as root (required for PID file, worker management). +# The pool handles the sail user drop — gosu is not used for php-fpm. +# Note: `daemonize = no` was removed — PHP 8.5 dropped this global directive. +# Foreground mode is enforced via the -F flag in CMD instead. +# Dev-friendly OPcache: recheck file timestamps every request (no stale bytecode) +RUN printf "opcache.revalidate_freq=0\nmemory_limit=1G\n" > /etc/php/8.4/fpm/conf.d/99-frank-dev.ini \ + && printf "opcache.revalidate_freq=0\nmemory_limit=1G\n" > /etc/php/8.4/cli/conf.d/99-frank-dev.ini + +RUN sed -i 's|error_log = .*|error_log = /dev/stderr|' /etc/php/8.4/fpm/php-fpm.conf \ + && sed -i 's|listen = .*|listen = 0.0.0.0:9000|' /etc/php/8.4/fpm/pool.d/www.conf \ + && sed -i 's|^user = .*|user = sail|' /etc/php/8.4/fpm/pool.d/www.conf \ + && sed -i 's|^group = .*|group = sail|' /etc/php/8.4/fpm/pool.d/www.conf + +# Node.js +RUN curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key \ + | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg \ + && echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_${NODE_VERSION}.x nodistro main" \ + > /etc/apt/sources.list.d/nodesource.list \ + && apt-get update \ + && apt-get install -y --no-install-recommends nodejs \ + && npm install -g bun \ + && corepack enable \ + && corepack prepare npm@latest pnpm@latest --activate \ + && apt-get clean && rm -rf /var/lib/apt/lists/* + +# Database clients +RUN curl -sS https://www.postgresql.org/media/keys/ACCC4CF8.asc \ + | gpg --dearmor | tee /etc/apt/keyrings/pgdg.gpg > /dev/null \ + && echo "deb [signed-by=/etc/apt/keyrings/pgdg.gpg] http://apt.postgresql.org/pub/repos/apt $(. /etc/os-release && echo $VERSION_CODENAME)-pgdg main" \ + > /etc/apt/sources.list.d/pgdg.list \ + && apt-get update \ + && apt-get install -y --no-install-recommends \ + $MYSQL_CLIENT postgresql-client-$POSTGRES_VERSION \ + && apt-get clean && rm -rf /var/lib/apt/lists/* + +# Composer +COPY --from=composer:latest /usr/bin/composer /usr/bin/composer + +# User setup: remove the default ubuntu user (frees GID 1000), then create sail. +# Using userdel+groupadd matches Sail's actual implementation and is simpler than +# a conditional groupmod — the ubuntu user is always present on ubuntu:24.04. +RUN userdel -r ubuntu \ + && groupadd --force -g $WWWGROUP sail \ + && useradd -ms /bin/bash --no-user-group -g $WWWGROUP -u 1337 sail \ + && git config --system --add safe.directory /var/www/html + +# Entrypoint (heredoc — single-quoted delimiter suppresses $VAR expansion in body) +RUN cat <<'SCRIPT' > /entrypoint.sh +#!/usr/bin/env bash +if [ -n "$WWWUSER" ]; then + usermod -u "$WWWUSER" sail +fi +mkdir -p /var/www/html/storage/psysh 2>/dev/null || true +if [ -d "/var/www/html/storage" ]; then + chown -R sail:sail /var/www/html/storage /var/www/html/bootstrap/cache 2>/dev/null || true + chmod -R 755 /var/www/html/storage /var/www/html/bootstrap/cache 2>/dev/null || true +fi +if [ -f "/var/www/html/.env.example" ] && [ ! -f "/var/www/html/.env" ]; then + cp /var/www/html/.env.example /var/www/html/.env + php artisan key:generate --no-interaction 2>/dev/null || true +fi +if [[ "$1" == php-fpm* ]]; then + exec "$@" +else + exec gosu sail "$@" +fi +SCRIPT +RUN chmod +x /entrypoint.sh + +WORKDIR /var/www/html +EXPOSE 9000 + +USER root +ENTRYPOINT ["/entrypoint.sh"] +CMD ["php-fpm8.4", "-F"] diff --git a/cmd/testdata/fpm-mysql-redis/.frank/Dockerfile b/cmd/testdata/fpm-mysql-redis/.frank/Dockerfile index 2250f3a..e799bc1 100644 --- a/cmd/testdata/fpm-mysql-redis/.frank/Dockerfile +++ b/cmd/testdata/fpm-mysql-redis/.frank/Dockerfile @@ -1,117 +1,2 @@ # Generated by Frank — edit frank.yaml, not this file -FROM ubuntu:24.04 - -ARG WWWGROUP=1000 -ARG NODE_VERSION=24 -ARG POSTGRES_VERSION=17 -ARG MYSQL_CLIENT=mysql-client - -ENV DEBIAN_FRONTEND=noninteractive -ENV TZ=UTC - -RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone - -# Base system tools -RUN mkdir -p /etc/apt/keyrings \ - && apt-get update && apt-get install -y --no-install-recommends \ - gnupg gosu curl ca-certificates zip unzip git supervisor \ - sqlite3 libgd3 python3 dnsutils \ - librsvg2-bin fswatch ffmpeg nano \ - && rm -rf /usr/share/man /usr/share/doc \ - && apt-get clean && rm -rf /var/lib/apt/lists/* - -# PHP via ondrej PPA -RUN curl -sS 'https://keyserver.ubuntu.com/pks/lookup?op=get&search=0xb8dc7e53946656efbce4c1dd71daeaab4ad4cab6' \ - | gpg --dearmor | tee /etc/apt/keyrings/ppa_ondrej_php.gpg > /dev/null \ - && echo "deb [signed-by=/etc/apt/keyrings/ppa_ondrej_php.gpg] https://ppa.launchpadcontent.net/ondrej/php/ubuntu noble main" \ - > /etc/apt/sources.list.d/ppa_ondrej_php.list \ - && apt-get update \ - && apt-get install -y --no-install-recommends \ - php8.4-fpm php8.4-cli \ - php8.4-pgsql php8.4-sqlite3 php8.4-gd \ - php8.4-curl php8.4-mongodb php8.4-imap \ - php8.4-mysql php8.4-mbstring php8.4-xml \ - php8.4-zip php8.4-bcmath php8.4-soap \ - php8.4-intl php8.4-readline php8.4-ldap \ - php8.4-msgpack php8.4-igbinary php8.4-redis \ - php8.4-memcached php8.4-pcov php8.4-imagick \ - php8.4-xdebug \ - && rm -rf /usr/share/man /usr/share/doc \ - && apt-get clean && rm -rf /var/lib/apt/lists/* - -# Configure FPM: TCP port 9000, log to stderr, workers run as sail. -# php-fpm master runs as root (required for PID file, worker management). -# The pool handles the sail user drop — gosu is not used for php-fpm. -# Note: `daemonize = no` was removed — PHP 8.5 dropped this global directive. -# Foreground mode is enforced via the -F flag in CMD instead. -# Dev-friendly OPcache: recheck file timestamps every request (no stale bytecode) -RUN printf "opcache.revalidate_freq=0\nmemory_limit=1G\n" > /etc/php/8.4/fpm/conf.d/99-frank-dev.ini \ - && printf "opcache.revalidate_freq=0\nmemory_limit=1G\n" > /etc/php/8.4/cli/conf.d/99-frank-dev.ini - -RUN sed -i 's|error_log = .*|error_log = /dev/stderr|' /etc/php/8.4/fpm/php-fpm.conf \ - && sed -i 's|listen = .*|listen = 0.0.0.0:9000|' /etc/php/8.4/fpm/pool.d/www.conf \ - && sed -i 's|^user = .*|user = sail|' /etc/php/8.4/fpm/pool.d/www.conf \ - && sed -i 's|^group = .*|group = sail|' /etc/php/8.4/fpm/pool.d/www.conf - -# Node.js -RUN curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key \ - | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg \ - && echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_${NODE_VERSION}.x nodistro main" \ - > /etc/apt/sources.list.d/nodesource.list \ - && apt-get update \ - && apt-get install -y --no-install-recommends nodejs \ - && npm install -g bun \ - && corepack enable \ - && corepack prepare npm@latest pnpm@latest --activate \ - && apt-get clean && rm -rf /var/lib/apt/lists/* - -# Database clients -RUN curl -sS https://www.postgresql.org/media/keys/ACCC4CF8.asc \ - | gpg --dearmor | tee /etc/apt/keyrings/pgdg.gpg > /dev/null \ - && echo "deb [signed-by=/etc/apt/keyrings/pgdg.gpg] http://apt.postgresql.org/pub/repos/apt $(. /etc/os-release && echo $VERSION_CODENAME)-pgdg main" \ - > /etc/apt/sources.list.d/pgdg.list \ - && apt-get update \ - && apt-get install -y --no-install-recommends \ - $MYSQL_CLIENT postgresql-client-$POSTGRES_VERSION \ - && apt-get clean && rm -rf /var/lib/apt/lists/* - -# Composer -COPY --from=composer:latest /usr/bin/composer /usr/bin/composer - -# User setup: remove the default ubuntu user (frees GID 1000), then create sail. -# Using userdel+groupadd matches Sail's actual implementation and is simpler than -# a conditional groupmod — the ubuntu user is always present on ubuntu:24.04. -RUN userdel -r ubuntu \ - && groupadd --force -g $WWWGROUP sail \ - && useradd -ms /bin/bash --no-user-group -g $WWWGROUP -u 1337 sail \ - && git config --system --add safe.directory /var/www/html - -# Entrypoint (heredoc — single-quoted delimiter suppresses $VAR expansion in body) -RUN cat <<'SCRIPT' > /entrypoint.sh -#!/usr/bin/env bash -if [ -n "$WWWUSER" ]; then - usermod -u "$WWWUSER" sail -fi -mkdir -p /var/www/html/storage/psysh 2>/dev/null || true -if [ -d "/var/www/html/storage" ]; then - chown -R sail:sail /var/www/html/storage /var/www/html/bootstrap/cache 2>/dev/null || true - chmod -R 755 /var/www/html/storage /var/www/html/bootstrap/cache 2>/dev/null || true -fi -if [ -f "/var/www/html/.env.example" ] && [ ! -f "/var/www/html/.env" ]; then - cp /var/www/html/.env.example /var/www/html/.env - php artisan key:generate --no-interaction 2>/dev/null || true -fi -if [[ "$1" == php-fpm* ]]; then - exec "$@" -else - exec gosu sail "$@" -fi -SCRIPT -RUN chmod +x /entrypoint.sh - -WORKDIR /var/www/html -EXPOSE 9000 - -USER root -ENTRYPOINT ["/entrypoint.sh"] -CMD ["php-fpm8.4", "-F"] +FROM frank/runtime:8.4-fpm-node24-pg17 diff --git a/cmd/testdata/fpm-mysql-redis/.frank/base.Dockerfile b/cmd/testdata/fpm-mysql-redis/.frank/base.Dockerfile new file mode 100644 index 0000000..2250f3a --- /dev/null +++ b/cmd/testdata/fpm-mysql-redis/.frank/base.Dockerfile @@ -0,0 +1,117 @@ +# Generated by Frank — edit frank.yaml, not this file +FROM ubuntu:24.04 + +ARG WWWGROUP=1000 +ARG NODE_VERSION=24 +ARG POSTGRES_VERSION=17 +ARG MYSQL_CLIENT=mysql-client + +ENV DEBIAN_FRONTEND=noninteractive +ENV TZ=UTC + +RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone + +# Base system tools +RUN mkdir -p /etc/apt/keyrings \ + && apt-get update && apt-get install -y --no-install-recommends \ + gnupg gosu curl ca-certificates zip unzip git supervisor \ + sqlite3 libgd3 python3 dnsutils \ + librsvg2-bin fswatch ffmpeg nano \ + && rm -rf /usr/share/man /usr/share/doc \ + && apt-get clean && rm -rf /var/lib/apt/lists/* + +# PHP via ondrej PPA +RUN curl -sS 'https://keyserver.ubuntu.com/pks/lookup?op=get&search=0xb8dc7e53946656efbce4c1dd71daeaab4ad4cab6' \ + | gpg --dearmor | tee /etc/apt/keyrings/ppa_ondrej_php.gpg > /dev/null \ + && echo "deb [signed-by=/etc/apt/keyrings/ppa_ondrej_php.gpg] https://ppa.launchpadcontent.net/ondrej/php/ubuntu noble main" \ + > /etc/apt/sources.list.d/ppa_ondrej_php.list \ + && apt-get update \ + && apt-get install -y --no-install-recommends \ + php8.4-fpm php8.4-cli \ + php8.4-pgsql php8.4-sqlite3 php8.4-gd \ + php8.4-curl php8.4-mongodb php8.4-imap \ + php8.4-mysql php8.4-mbstring php8.4-xml \ + php8.4-zip php8.4-bcmath php8.4-soap \ + php8.4-intl php8.4-readline php8.4-ldap \ + php8.4-msgpack php8.4-igbinary php8.4-redis \ + php8.4-memcached php8.4-pcov php8.4-imagick \ + php8.4-xdebug \ + && rm -rf /usr/share/man /usr/share/doc \ + && apt-get clean && rm -rf /var/lib/apt/lists/* + +# Configure FPM: TCP port 9000, log to stderr, workers run as sail. +# php-fpm master runs as root (required for PID file, worker management). +# The pool handles the sail user drop — gosu is not used for php-fpm. +# Note: `daemonize = no` was removed — PHP 8.5 dropped this global directive. +# Foreground mode is enforced via the -F flag in CMD instead. +# Dev-friendly OPcache: recheck file timestamps every request (no stale bytecode) +RUN printf "opcache.revalidate_freq=0\nmemory_limit=1G\n" > /etc/php/8.4/fpm/conf.d/99-frank-dev.ini \ + && printf "opcache.revalidate_freq=0\nmemory_limit=1G\n" > /etc/php/8.4/cli/conf.d/99-frank-dev.ini + +RUN sed -i 's|error_log = .*|error_log = /dev/stderr|' /etc/php/8.4/fpm/php-fpm.conf \ + && sed -i 's|listen = .*|listen = 0.0.0.0:9000|' /etc/php/8.4/fpm/pool.d/www.conf \ + && sed -i 's|^user = .*|user = sail|' /etc/php/8.4/fpm/pool.d/www.conf \ + && sed -i 's|^group = .*|group = sail|' /etc/php/8.4/fpm/pool.d/www.conf + +# Node.js +RUN curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key \ + | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg \ + && echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_${NODE_VERSION}.x nodistro main" \ + > /etc/apt/sources.list.d/nodesource.list \ + && apt-get update \ + && apt-get install -y --no-install-recommends nodejs \ + && npm install -g bun \ + && corepack enable \ + && corepack prepare npm@latest pnpm@latest --activate \ + && apt-get clean && rm -rf /var/lib/apt/lists/* + +# Database clients +RUN curl -sS https://www.postgresql.org/media/keys/ACCC4CF8.asc \ + | gpg --dearmor | tee /etc/apt/keyrings/pgdg.gpg > /dev/null \ + && echo "deb [signed-by=/etc/apt/keyrings/pgdg.gpg] http://apt.postgresql.org/pub/repos/apt $(. /etc/os-release && echo $VERSION_CODENAME)-pgdg main" \ + > /etc/apt/sources.list.d/pgdg.list \ + && apt-get update \ + && apt-get install -y --no-install-recommends \ + $MYSQL_CLIENT postgresql-client-$POSTGRES_VERSION \ + && apt-get clean && rm -rf /var/lib/apt/lists/* + +# Composer +COPY --from=composer:latest /usr/bin/composer /usr/bin/composer + +# User setup: remove the default ubuntu user (frees GID 1000), then create sail. +# Using userdel+groupadd matches Sail's actual implementation and is simpler than +# a conditional groupmod — the ubuntu user is always present on ubuntu:24.04. +RUN userdel -r ubuntu \ + && groupadd --force -g $WWWGROUP sail \ + && useradd -ms /bin/bash --no-user-group -g $WWWGROUP -u 1337 sail \ + && git config --system --add safe.directory /var/www/html + +# Entrypoint (heredoc — single-quoted delimiter suppresses $VAR expansion in body) +RUN cat <<'SCRIPT' > /entrypoint.sh +#!/usr/bin/env bash +if [ -n "$WWWUSER" ]; then + usermod -u "$WWWUSER" sail +fi +mkdir -p /var/www/html/storage/psysh 2>/dev/null || true +if [ -d "/var/www/html/storage" ]; then + chown -R sail:sail /var/www/html/storage /var/www/html/bootstrap/cache 2>/dev/null || true + chmod -R 755 /var/www/html/storage /var/www/html/bootstrap/cache 2>/dev/null || true +fi +if [ -f "/var/www/html/.env.example" ] && [ ! -f "/var/www/html/.env" ]; then + cp /var/www/html/.env.example /var/www/html/.env + php artisan key:generate --no-interaction 2>/dev/null || true +fi +if [[ "$1" == php-fpm* ]]; then + exec "$@" +else + exec gosu sail "$@" +fi +SCRIPT +RUN chmod +x /entrypoint.sh + +WORKDIR /var/www/html +EXPOSE 9000 + +USER root +ENTRYPOINT ["/entrypoint.sh"] +CMD ["php-fpm8.4", "-F"] diff --git a/cmd/testdata/frankenphp-pgsql-mailpit/.frank/Dockerfile b/cmd/testdata/frankenphp-pgsql-mailpit/.frank/Dockerfile index 097c0b9..39e4d87 100644 --- a/cmd/testdata/frankenphp-pgsql-mailpit/.frank/Dockerfile +++ b/cmd/testdata/frankenphp-pgsql-mailpit/.frank/Dockerfile @@ -1,129 +1,5 @@ # Generated by Frank — edit frank.yaml, not this file - -# ── Builder: compile PHP extensions ────────────────────────────────────────── -FROM dunglas/frankenphp:1-php8.5 AS builder - -# Dev libs required to compile extensions (not present in final image) -RUN apt-get update && apt-get install -y --no-install-recommends \ - libmemcached-dev libmagickwand-dev libkrb5-dev libreadline-dev \ - libldap-dev libsqlite3-dev libxml2-dev libzip-dev \ - libpng-dev libonig-dev libicu-dev libpq-dev \ - && rm -rf /var/lib/apt/lists/* - -# Bundled extensions (compiled into PHP, enabled here). -# Note: imap is not installed — libc-client-dev was dropped from Debian trixie (the FrankenPHP -# base OS) and the extension cannot be compiled. Use the fpm runtime if imap is required. -# Note: pdo_sqlite, sqlite3, readline, and mbstring are already statically compiled into the -# FrankenPHP binary — omitting them here avoids a mid-run source tree cleanup that breaks -# subsequent extensions. -RUN docker-php-ext-install \ - soap ldap \ - pdo_mysql pdo_pgsql pgsql exif pcntl bcmath gd intl zip - -# PECL extensions — install igbinary/msgpack first as other extensions can link against them -RUN pecl install igbinary \ - && pecl install msgpack \ - && pecl install redis \ - && pecl install memcached \ - && pecl install mongodb \ - && pecl install imagick \ - && pecl install xdebug \ - && pecl install pcov \ - && docker-php-ext-enable igbinary msgpack redis memcached mongodb imagick xdebug pcov \ - && docker-php-source delete \ - && pecl clear-cache \ - && find /usr/local/lib/php/extensions -name '*.so' -exec strip --strip-all {} + - -# ── Final image ─────────────────────────────────────────────────────────────── -FROM dunglas/frankenphp:1-php8.5 AS final - -# Copy compiled extensions from builder. -# The .so files live in a versioned ABI subdirectory (e.g. no-debug-zts-20240924/). -# Both stages share the same base so the ABI path is identical — copy the whole tree. -COPY --from=builder /usr/local/lib/php/extensions/ /usr/local/lib/php/extensions/ -COPY --from=builder /usr/local/etc/php/conf.d/ /usr/local/etc/php/conf.d/ - -# Dev-friendly OPcache: recheck file timestamps every request (no stale bytecode) -RUN printf "opcache.revalidate_freq=0\nmemory_limit=1G\n" > /usr/local/etc/php/conf.d/zz-frank-dev.ini - -ARG WWWGROUP=1000 -ARG NODE_VERSION=24 -ARG POSTGRES_VERSION=17 -ARG MYSQL_CLIENT=default-mysql-client - -ENV DEBIAN_FRONTEND=noninteractive -ENV TZ=UTC - -RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone - -# Runtime libs for compiled extensions + system tools -# Runtime lib names are Debian trixie-specific (the FrankenPHP base OS as of 2026-04). -RUN mkdir -p /etc/apt/keyrings \ - && apt-get update && apt-get install -y --no-install-recommends \ - libmagickwand-7.q16-10 libmemcached11t64 libldap2 libkrb5-3 \ - libreadline8 libsqlite3-0 libicu76 libzip5 \ - gosu supervisor sqlite3 python3 dnsutils librsvg2-bin \ - fswatch ffmpeg nano git curl ca-certificates gnupg zip unzip \ - && rm -rf /usr/share/man /usr/share/doc \ - && apt-get clean && rm -rf /var/lib/apt/lists/* - -# Node.js -RUN curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key \ - | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg \ - && echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_${NODE_VERSION}.x nodistro main" \ - > /etc/apt/sources.list.d/nodesource.list \ - && apt-get update \ - && apt-get install -y --no-install-recommends nodejs \ - && npm install -g bun \ - && corepack enable \ - && corepack prepare npm@latest pnpm@latest --activate \ - && apt-get clean && rm -rf /var/lib/apt/lists/* - -# Database clients (pgdg repo — detect Debian codename dynamically) -RUN curl -sS https://www.postgresql.org/media/keys/ACCC4CF8.asc \ - | gpg --dearmor | tee /etc/apt/keyrings/pgdg.gpg > /dev/null \ - && echo "deb [signed-by=/etc/apt/keyrings/pgdg.gpg] http://apt.postgresql.org/pub/repos/apt $(. /etc/os-release && echo $VERSION_CODENAME)-pgdg main" \ - > /etc/apt/sources.list.d/pgdg.list \ - && apt-get update \ - && apt-get install -y --no-install-recommends \ - $MYSQL_CLIENT postgresql-client-$POSTGRES_VERSION \ - && apt-get clean && rm -rf /var/lib/apt/lists/* - -# Composer -COPY --from=composer:latest /usr/bin/composer /usr/bin/composer - -# User setup -RUN groupadd --force -g $WWWGROUP sail \ - && useradd -ms /bin/bash --no-user-group -g $WWWGROUP -u 1337 sail \ - && git config --system --add safe.directory /var/www/html +FROM frank/runtime:8.5-frankenphp-node24-pg17 # Copy Caddyfile (generated at .frank/Caddyfile; build context is project root) COPY .frank/Caddyfile /etc/caddy/Caddyfile - -# Entrypoint (heredoc — single-quoted delimiter suppresses $VAR expansion in body) -RUN cat <<'SCRIPT' > /entrypoint.sh -#!/usr/bin/env bash -if [ -n "$WWWUSER" ]; then - usermod -u "$WWWUSER" sail -fi -mkdir -p /var/www/html/storage/psysh 2>/dev/null || true -if [ -d "/var/www/html/storage" ]; then - chown -R sail:sail /var/www/html/storage /var/www/html/bootstrap/cache 2>/dev/null || true - chmod -R 755 /var/www/html/storage /var/www/html/bootstrap/cache 2>/dev/null || true -fi -if [ -f "/var/www/html/.env.example" ] && [ ! -f "/var/www/html/.env" ]; then - cp /var/www/html/.env.example /var/www/html/.env - php artisan key:generate --no-interaction 2>/dev/null || true -fi -mkdir -p /config/caddy /data/caddy -chown -R sail:sail /config/caddy /data/caddy -exec gosu sail "$@" -SCRIPT -RUN chmod +x /entrypoint.sh - -WORKDIR /var/www/html -EXPOSE 80 443 5173 - -USER root -ENTRYPOINT ["/entrypoint.sh"] -CMD ["frankenphp", "run", "--config", "/etc/caddy/Caddyfile"] diff --git a/cmd/testdata/frankenphp-pgsql-mailpit/.frank/base.Dockerfile b/cmd/testdata/frankenphp-pgsql-mailpit/.frank/base.Dockerfile new file mode 100644 index 0000000..68db2f6 --- /dev/null +++ b/cmd/testdata/frankenphp-pgsql-mailpit/.frank/base.Dockerfile @@ -0,0 +1,126 @@ +# Generated by Frank — edit frank.yaml, not this file + +# ── Builder: compile PHP extensions ────────────────────────────────────────── +FROM dunglas/frankenphp:1-php8.5 AS builder + +# Dev libs required to compile extensions (not present in final image) +RUN apt-get update && apt-get install -y --no-install-recommends \ + libmemcached-dev libmagickwand-dev libkrb5-dev libreadline-dev \ + libldap-dev libsqlite3-dev libxml2-dev libzip-dev \ + libpng-dev libonig-dev libicu-dev libpq-dev \ + && rm -rf /var/lib/apt/lists/* + +# Bundled extensions (compiled into PHP, enabled here). +# Note: imap is not installed — libc-client-dev was dropped from Debian trixie (the FrankenPHP +# base OS) and the extension cannot be compiled. Use the fpm runtime if imap is required. +# Note: pdo_sqlite, sqlite3, readline, and mbstring are already statically compiled into the +# FrankenPHP binary — omitting them here avoids a mid-run source tree cleanup that breaks +# subsequent extensions. +RUN docker-php-ext-install \ + soap ldap \ + pdo_mysql pdo_pgsql pgsql exif pcntl bcmath gd intl zip + +# PECL extensions — install igbinary/msgpack first as other extensions can link against them +RUN pecl install igbinary \ + && pecl install msgpack \ + && pecl install redis \ + && pecl install memcached \ + && pecl install mongodb \ + && pecl install imagick \ + && pecl install xdebug \ + && pecl install pcov \ + && docker-php-ext-enable igbinary msgpack redis memcached mongodb imagick xdebug pcov \ + && docker-php-source delete \ + && pecl clear-cache \ + && find /usr/local/lib/php/extensions -name '*.so' -exec strip --strip-all {} + + +# ── Final image ─────────────────────────────────────────────────────────────── +FROM dunglas/frankenphp:1-php8.5 AS final + +# Copy compiled extensions from builder. +# The .so files live in a versioned ABI subdirectory (e.g. no-debug-zts-20240924/). +# Both stages share the same base so the ABI path is identical — copy the whole tree. +COPY --from=builder /usr/local/lib/php/extensions/ /usr/local/lib/php/extensions/ +COPY --from=builder /usr/local/etc/php/conf.d/ /usr/local/etc/php/conf.d/ + +# Dev-friendly OPcache: recheck file timestamps every request (no stale bytecode) +RUN printf "opcache.revalidate_freq=0\nmemory_limit=1G\n" > /usr/local/etc/php/conf.d/zz-frank-dev.ini + +ARG WWWGROUP=1000 +ARG NODE_VERSION=24 +ARG POSTGRES_VERSION=17 +ARG MYSQL_CLIENT=default-mysql-client + +ENV DEBIAN_FRONTEND=noninteractive +ENV TZ=UTC + +RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone + +# Runtime libs for compiled extensions + system tools +# Runtime lib names are Debian trixie-specific (the FrankenPHP base OS as of 2026-04). +RUN mkdir -p /etc/apt/keyrings \ + && apt-get update && apt-get install -y --no-install-recommends \ + libmagickwand-7.q16-10 libmemcached11t64 libldap2 libkrb5-3 \ + libreadline8 libsqlite3-0 libicu76 libzip5 \ + gosu supervisor sqlite3 python3 dnsutils librsvg2-bin \ + fswatch ffmpeg nano git curl ca-certificates gnupg zip unzip \ + && rm -rf /usr/share/man /usr/share/doc \ + && apt-get clean && rm -rf /var/lib/apt/lists/* + +# Node.js +RUN curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key \ + | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg \ + && echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_${NODE_VERSION}.x nodistro main" \ + > /etc/apt/sources.list.d/nodesource.list \ + && apt-get update \ + && apt-get install -y --no-install-recommends nodejs \ + && npm install -g bun \ + && corepack enable \ + && corepack prepare npm@latest pnpm@latest --activate \ + && apt-get clean && rm -rf /var/lib/apt/lists/* + +# Database clients (pgdg repo — detect Debian codename dynamically) +RUN curl -sS https://www.postgresql.org/media/keys/ACCC4CF8.asc \ + | gpg --dearmor | tee /etc/apt/keyrings/pgdg.gpg > /dev/null \ + && echo "deb [signed-by=/etc/apt/keyrings/pgdg.gpg] http://apt.postgresql.org/pub/repos/apt $(. /etc/os-release && echo $VERSION_CODENAME)-pgdg main" \ + > /etc/apt/sources.list.d/pgdg.list \ + && apt-get update \ + && apt-get install -y --no-install-recommends \ + $MYSQL_CLIENT postgresql-client-$POSTGRES_VERSION \ + && apt-get clean && rm -rf /var/lib/apt/lists/* + +# Composer +COPY --from=composer:latest /usr/bin/composer /usr/bin/composer + +# User setup +RUN groupadd --force -g $WWWGROUP sail \ + && useradd -ms /bin/bash --no-user-group -g $WWWGROUP -u 1337 sail \ + && git config --system --add safe.directory /var/www/html + +# Entrypoint (heredoc — single-quoted delimiter suppresses $VAR expansion in body) +RUN cat <<'SCRIPT' > /entrypoint.sh +#!/usr/bin/env bash +if [ -n "$WWWUSER" ]; then + usermod -u "$WWWUSER" sail +fi +mkdir -p /var/www/html/storage/psysh 2>/dev/null || true +if [ -d "/var/www/html/storage" ]; then + chown -R sail:sail /var/www/html/storage /var/www/html/bootstrap/cache 2>/dev/null || true + chmod -R 755 /var/www/html/storage /var/www/html/bootstrap/cache 2>/dev/null || true +fi +if [ -f "/var/www/html/.env.example" ] && [ ! -f "/var/www/html/.env" ]; then + cp /var/www/html/.env.example /var/www/html/.env + php artisan key:generate --no-interaction 2>/dev/null || true +fi +mkdir -p /config/caddy /data/caddy +chown -R sail:sail /config/caddy /data/caddy +exec gosu sail "$@" +SCRIPT +RUN chmod +x /entrypoint.sh + +WORKDIR /var/www/html +EXPOSE 80 443 5173 + +USER root +ENTRYPOINT ["/entrypoint.sh"] +CMD ["frankenphp", "run", "--config", "/etc/caddy/Caddyfile"] diff --git a/cmd/testdata/frankenphp-pgsql-no-https/.frank/Dockerfile b/cmd/testdata/frankenphp-pgsql-no-https/.frank/Dockerfile index 097c0b9..39e4d87 100644 --- a/cmd/testdata/frankenphp-pgsql-no-https/.frank/Dockerfile +++ b/cmd/testdata/frankenphp-pgsql-no-https/.frank/Dockerfile @@ -1,129 +1,5 @@ # Generated by Frank — edit frank.yaml, not this file - -# ── Builder: compile PHP extensions ────────────────────────────────────────── -FROM dunglas/frankenphp:1-php8.5 AS builder - -# Dev libs required to compile extensions (not present in final image) -RUN apt-get update && apt-get install -y --no-install-recommends \ - libmemcached-dev libmagickwand-dev libkrb5-dev libreadline-dev \ - libldap-dev libsqlite3-dev libxml2-dev libzip-dev \ - libpng-dev libonig-dev libicu-dev libpq-dev \ - && rm -rf /var/lib/apt/lists/* - -# Bundled extensions (compiled into PHP, enabled here). -# Note: imap is not installed — libc-client-dev was dropped from Debian trixie (the FrankenPHP -# base OS) and the extension cannot be compiled. Use the fpm runtime if imap is required. -# Note: pdo_sqlite, sqlite3, readline, and mbstring are already statically compiled into the -# FrankenPHP binary — omitting them here avoids a mid-run source tree cleanup that breaks -# subsequent extensions. -RUN docker-php-ext-install \ - soap ldap \ - pdo_mysql pdo_pgsql pgsql exif pcntl bcmath gd intl zip - -# PECL extensions — install igbinary/msgpack first as other extensions can link against them -RUN pecl install igbinary \ - && pecl install msgpack \ - && pecl install redis \ - && pecl install memcached \ - && pecl install mongodb \ - && pecl install imagick \ - && pecl install xdebug \ - && pecl install pcov \ - && docker-php-ext-enable igbinary msgpack redis memcached mongodb imagick xdebug pcov \ - && docker-php-source delete \ - && pecl clear-cache \ - && find /usr/local/lib/php/extensions -name '*.so' -exec strip --strip-all {} + - -# ── Final image ─────────────────────────────────────────────────────────────── -FROM dunglas/frankenphp:1-php8.5 AS final - -# Copy compiled extensions from builder. -# The .so files live in a versioned ABI subdirectory (e.g. no-debug-zts-20240924/). -# Both stages share the same base so the ABI path is identical — copy the whole tree. -COPY --from=builder /usr/local/lib/php/extensions/ /usr/local/lib/php/extensions/ -COPY --from=builder /usr/local/etc/php/conf.d/ /usr/local/etc/php/conf.d/ - -# Dev-friendly OPcache: recheck file timestamps every request (no stale bytecode) -RUN printf "opcache.revalidate_freq=0\nmemory_limit=1G\n" > /usr/local/etc/php/conf.d/zz-frank-dev.ini - -ARG WWWGROUP=1000 -ARG NODE_VERSION=24 -ARG POSTGRES_VERSION=17 -ARG MYSQL_CLIENT=default-mysql-client - -ENV DEBIAN_FRONTEND=noninteractive -ENV TZ=UTC - -RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone - -# Runtime libs for compiled extensions + system tools -# Runtime lib names are Debian trixie-specific (the FrankenPHP base OS as of 2026-04). -RUN mkdir -p /etc/apt/keyrings \ - && apt-get update && apt-get install -y --no-install-recommends \ - libmagickwand-7.q16-10 libmemcached11t64 libldap2 libkrb5-3 \ - libreadline8 libsqlite3-0 libicu76 libzip5 \ - gosu supervisor sqlite3 python3 dnsutils librsvg2-bin \ - fswatch ffmpeg nano git curl ca-certificates gnupg zip unzip \ - && rm -rf /usr/share/man /usr/share/doc \ - && apt-get clean && rm -rf /var/lib/apt/lists/* - -# Node.js -RUN curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key \ - | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg \ - && echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_${NODE_VERSION}.x nodistro main" \ - > /etc/apt/sources.list.d/nodesource.list \ - && apt-get update \ - && apt-get install -y --no-install-recommends nodejs \ - && npm install -g bun \ - && corepack enable \ - && corepack prepare npm@latest pnpm@latest --activate \ - && apt-get clean && rm -rf /var/lib/apt/lists/* - -# Database clients (pgdg repo — detect Debian codename dynamically) -RUN curl -sS https://www.postgresql.org/media/keys/ACCC4CF8.asc \ - | gpg --dearmor | tee /etc/apt/keyrings/pgdg.gpg > /dev/null \ - && echo "deb [signed-by=/etc/apt/keyrings/pgdg.gpg] http://apt.postgresql.org/pub/repos/apt $(. /etc/os-release && echo $VERSION_CODENAME)-pgdg main" \ - > /etc/apt/sources.list.d/pgdg.list \ - && apt-get update \ - && apt-get install -y --no-install-recommends \ - $MYSQL_CLIENT postgresql-client-$POSTGRES_VERSION \ - && apt-get clean && rm -rf /var/lib/apt/lists/* - -# Composer -COPY --from=composer:latest /usr/bin/composer /usr/bin/composer - -# User setup -RUN groupadd --force -g $WWWGROUP sail \ - && useradd -ms /bin/bash --no-user-group -g $WWWGROUP -u 1337 sail \ - && git config --system --add safe.directory /var/www/html +FROM frank/runtime:8.5-frankenphp-node24-pg17 # Copy Caddyfile (generated at .frank/Caddyfile; build context is project root) COPY .frank/Caddyfile /etc/caddy/Caddyfile - -# Entrypoint (heredoc — single-quoted delimiter suppresses $VAR expansion in body) -RUN cat <<'SCRIPT' > /entrypoint.sh -#!/usr/bin/env bash -if [ -n "$WWWUSER" ]; then - usermod -u "$WWWUSER" sail -fi -mkdir -p /var/www/html/storage/psysh 2>/dev/null || true -if [ -d "/var/www/html/storage" ]; then - chown -R sail:sail /var/www/html/storage /var/www/html/bootstrap/cache 2>/dev/null || true - chmod -R 755 /var/www/html/storage /var/www/html/bootstrap/cache 2>/dev/null || true -fi -if [ -f "/var/www/html/.env.example" ] && [ ! -f "/var/www/html/.env" ]; then - cp /var/www/html/.env.example /var/www/html/.env - php artisan key:generate --no-interaction 2>/dev/null || true -fi -mkdir -p /config/caddy /data/caddy -chown -R sail:sail /config/caddy /data/caddy -exec gosu sail "$@" -SCRIPT -RUN chmod +x /entrypoint.sh - -WORKDIR /var/www/html -EXPOSE 80 443 5173 - -USER root -ENTRYPOINT ["/entrypoint.sh"] -CMD ["frankenphp", "run", "--config", "/etc/caddy/Caddyfile"] diff --git a/cmd/testdata/frankenphp-pgsql-no-https/.frank/base.Dockerfile b/cmd/testdata/frankenphp-pgsql-no-https/.frank/base.Dockerfile new file mode 100644 index 0000000..68db2f6 --- /dev/null +++ b/cmd/testdata/frankenphp-pgsql-no-https/.frank/base.Dockerfile @@ -0,0 +1,126 @@ +# Generated by Frank — edit frank.yaml, not this file + +# ── Builder: compile PHP extensions ────────────────────────────────────────── +FROM dunglas/frankenphp:1-php8.5 AS builder + +# Dev libs required to compile extensions (not present in final image) +RUN apt-get update && apt-get install -y --no-install-recommends \ + libmemcached-dev libmagickwand-dev libkrb5-dev libreadline-dev \ + libldap-dev libsqlite3-dev libxml2-dev libzip-dev \ + libpng-dev libonig-dev libicu-dev libpq-dev \ + && rm -rf /var/lib/apt/lists/* + +# Bundled extensions (compiled into PHP, enabled here). +# Note: imap is not installed — libc-client-dev was dropped from Debian trixie (the FrankenPHP +# base OS) and the extension cannot be compiled. Use the fpm runtime if imap is required. +# Note: pdo_sqlite, sqlite3, readline, and mbstring are already statically compiled into the +# FrankenPHP binary — omitting them here avoids a mid-run source tree cleanup that breaks +# subsequent extensions. +RUN docker-php-ext-install \ + soap ldap \ + pdo_mysql pdo_pgsql pgsql exif pcntl bcmath gd intl zip + +# PECL extensions — install igbinary/msgpack first as other extensions can link against them +RUN pecl install igbinary \ + && pecl install msgpack \ + && pecl install redis \ + && pecl install memcached \ + && pecl install mongodb \ + && pecl install imagick \ + && pecl install xdebug \ + && pecl install pcov \ + && docker-php-ext-enable igbinary msgpack redis memcached mongodb imagick xdebug pcov \ + && docker-php-source delete \ + && pecl clear-cache \ + && find /usr/local/lib/php/extensions -name '*.so' -exec strip --strip-all {} + + +# ── Final image ─────────────────────────────────────────────────────────────── +FROM dunglas/frankenphp:1-php8.5 AS final + +# Copy compiled extensions from builder. +# The .so files live in a versioned ABI subdirectory (e.g. no-debug-zts-20240924/). +# Both stages share the same base so the ABI path is identical — copy the whole tree. +COPY --from=builder /usr/local/lib/php/extensions/ /usr/local/lib/php/extensions/ +COPY --from=builder /usr/local/etc/php/conf.d/ /usr/local/etc/php/conf.d/ + +# Dev-friendly OPcache: recheck file timestamps every request (no stale bytecode) +RUN printf "opcache.revalidate_freq=0\nmemory_limit=1G\n" > /usr/local/etc/php/conf.d/zz-frank-dev.ini + +ARG WWWGROUP=1000 +ARG NODE_VERSION=24 +ARG POSTGRES_VERSION=17 +ARG MYSQL_CLIENT=default-mysql-client + +ENV DEBIAN_FRONTEND=noninteractive +ENV TZ=UTC + +RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone + +# Runtime libs for compiled extensions + system tools +# Runtime lib names are Debian trixie-specific (the FrankenPHP base OS as of 2026-04). +RUN mkdir -p /etc/apt/keyrings \ + && apt-get update && apt-get install -y --no-install-recommends \ + libmagickwand-7.q16-10 libmemcached11t64 libldap2 libkrb5-3 \ + libreadline8 libsqlite3-0 libicu76 libzip5 \ + gosu supervisor sqlite3 python3 dnsutils librsvg2-bin \ + fswatch ffmpeg nano git curl ca-certificates gnupg zip unzip \ + && rm -rf /usr/share/man /usr/share/doc \ + && apt-get clean && rm -rf /var/lib/apt/lists/* + +# Node.js +RUN curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key \ + | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg \ + && echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_${NODE_VERSION}.x nodistro main" \ + > /etc/apt/sources.list.d/nodesource.list \ + && apt-get update \ + && apt-get install -y --no-install-recommends nodejs \ + && npm install -g bun \ + && corepack enable \ + && corepack prepare npm@latest pnpm@latest --activate \ + && apt-get clean && rm -rf /var/lib/apt/lists/* + +# Database clients (pgdg repo — detect Debian codename dynamically) +RUN curl -sS https://www.postgresql.org/media/keys/ACCC4CF8.asc \ + | gpg --dearmor | tee /etc/apt/keyrings/pgdg.gpg > /dev/null \ + && echo "deb [signed-by=/etc/apt/keyrings/pgdg.gpg] http://apt.postgresql.org/pub/repos/apt $(. /etc/os-release && echo $VERSION_CODENAME)-pgdg main" \ + > /etc/apt/sources.list.d/pgdg.list \ + && apt-get update \ + && apt-get install -y --no-install-recommends \ + $MYSQL_CLIENT postgresql-client-$POSTGRES_VERSION \ + && apt-get clean && rm -rf /var/lib/apt/lists/* + +# Composer +COPY --from=composer:latest /usr/bin/composer /usr/bin/composer + +# User setup +RUN groupadd --force -g $WWWGROUP sail \ + && useradd -ms /bin/bash --no-user-group -g $WWWGROUP -u 1337 sail \ + && git config --system --add safe.directory /var/www/html + +# Entrypoint (heredoc — single-quoted delimiter suppresses $VAR expansion in body) +RUN cat <<'SCRIPT' > /entrypoint.sh +#!/usr/bin/env bash +if [ -n "$WWWUSER" ]; then + usermod -u "$WWWUSER" sail +fi +mkdir -p /var/www/html/storage/psysh 2>/dev/null || true +if [ -d "/var/www/html/storage" ]; then + chown -R sail:sail /var/www/html/storage /var/www/html/bootstrap/cache 2>/dev/null || true + chmod -R 755 /var/www/html/storage /var/www/html/bootstrap/cache 2>/dev/null || true +fi +if [ -f "/var/www/html/.env.example" ] && [ ! -f "/var/www/html/.env" ]; then + cp /var/www/html/.env.example /var/www/html/.env + php artisan key:generate --no-interaction 2>/dev/null || true +fi +mkdir -p /config/caddy /data/caddy +chown -R sail:sail /config/caddy /data/caddy +exec gosu sail "$@" +SCRIPT +RUN chmod +x /entrypoint.sh + +WORKDIR /var/www/html +EXPOSE 80 443 5173 + +USER root +ENTRYPOINT ["/entrypoint.sh"] +CMD ["frankenphp", "run", "--config", "/etc/caddy/Caddyfile"] diff --git a/cmd/testdata/frankenphp-pgsql-pnpm/.frank/Dockerfile b/cmd/testdata/frankenphp-pgsql-pnpm/.frank/Dockerfile index 097c0b9..39e4d87 100644 --- a/cmd/testdata/frankenphp-pgsql-pnpm/.frank/Dockerfile +++ b/cmd/testdata/frankenphp-pgsql-pnpm/.frank/Dockerfile @@ -1,129 +1,5 @@ # Generated by Frank — edit frank.yaml, not this file - -# ── Builder: compile PHP extensions ────────────────────────────────────────── -FROM dunglas/frankenphp:1-php8.5 AS builder - -# Dev libs required to compile extensions (not present in final image) -RUN apt-get update && apt-get install -y --no-install-recommends \ - libmemcached-dev libmagickwand-dev libkrb5-dev libreadline-dev \ - libldap-dev libsqlite3-dev libxml2-dev libzip-dev \ - libpng-dev libonig-dev libicu-dev libpq-dev \ - && rm -rf /var/lib/apt/lists/* - -# Bundled extensions (compiled into PHP, enabled here). -# Note: imap is not installed — libc-client-dev was dropped from Debian trixie (the FrankenPHP -# base OS) and the extension cannot be compiled. Use the fpm runtime if imap is required. -# Note: pdo_sqlite, sqlite3, readline, and mbstring are already statically compiled into the -# FrankenPHP binary — omitting them here avoids a mid-run source tree cleanup that breaks -# subsequent extensions. -RUN docker-php-ext-install \ - soap ldap \ - pdo_mysql pdo_pgsql pgsql exif pcntl bcmath gd intl zip - -# PECL extensions — install igbinary/msgpack first as other extensions can link against them -RUN pecl install igbinary \ - && pecl install msgpack \ - && pecl install redis \ - && pecl install memcached \ - && pecl install mongodb \ - && pecl install imagick \ - && pecl install xdebug \ - && pecl install pcov \ - && docker-php-ext-enable igbinary msgpack redis memcached mongodb imagick xdebug pcov \ - && docker-php-source delete \ - && pecl clear-cache \ - && find /usr/local/lib/php/extensions -name '*.so' -exec strip --strip-all {} + - -# ── Final image ─────────────────────────────────────────────────────────────── -FROM dunglas/frankenphp:1-php8.5 AS final - -# Copy compiled extensions from builder. -# The .so files live in a versioned ABI subdirectory (e.g. no-debug-zts-20240924/). -# Both stages share the same base so the ABI path is identical — copy the whole tree. -COPY --from=builder /usr/local/lib/php/extensions/ /usr/local/lib/php/extensions/ -COPY --from=builder /usr/local/etc/php/conf.d/ /usr/local/etc/php/conf.d/ - -# Dev-friendly OPcache: recheck file timestamps every request (no stale bytecode) -RUN printf "opcache.revalidate_freq=0\nmemory_limit=1G\n" > /usr/local/etc/php/conf.d/zz-frank-dev.ini - -ARG WWWGROUP=1000 -ARG NODE_VERSION=24 -ARG POSTGRES_VERSION=17 -ARG MYSQL_CLIENT=default-mysql-client - -ENV DEBIAN_FRONTEND=noninteractive -ENV TZ=UTC - -RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone - -# Runtime libs for compiled extensions + system tools -# Runtime lib names are Debian trixie-specific (the FrankenPHP base OS as of 2026-04). -RUN mkdir -p /etc/apt/keyrings \ - && apt-get update && apt-get install -y --no-install-recommends \ - libmagickwand-7.q16-10 libmemcached11t64 libldap2 libkrb5-3 \ - libreadline8 libsqlite3-0 libicu76 libzip5 \ - gosu supervisor sqlite3 python3 dnsutils librsvg2-bin \ - fswatch ffmpeg nano git curl ca-certificates gnupg zip unzip \ - && rm -rf /usr/share/man /usr/share/doc \ - && apt-get clean && rm -rf /var/lib/apt/lists/* - -# Node.js -RUN curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key \ - | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg \ - && echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_${NODE_VERSION}.x nodistro main" \ - > /etc/apt/sources.list.d/nodesource.list \ - && apt-get update \ - && apt-get install -y --no-install-recommends nodejs \ - && npm install -g bun \ - && corepack enable \ - && corepack prepare npm@latest pnpm@latest --activate \ - && apt-get clean && rm -rf /var/lib/apt/lists/* - -# Database clients (pgdg repo — detect Debian codename dynamically) -RUN curl -sS https://www.postgresql.org/media/keys/ACCC4CF8.asc \ - | gpg --dearmor | tee /etc/apt/keyrings/pgdg.gpg > /dev/null \ - && echo "deb [signed-by=/etc/apt/keyrings/pgdg.gpg] http://apt.postgresql.org/pub/repos/apt $(. /etc/os-release && echo $VERSION_CODENAME)-pgdg main" \ - > /etc/apt/sources.list.d/pgdg.list \ - && apt-get update \ - && apt-get install -y --no-install-recommends \ - $MYSQL_CLIENT postgresql-client-$POSTGRES_VERSION \ - && apt-get clean && rm -rf /var/lib/apt/lists/* - -# Composer -COPY --from=composer:latest /usr/bin/composer /usr/bin/composer - -# User setup -RUN groupadd --force -g $WWWGROUP sail \ - && useradd -ms /bin/bash --no-user-group -g $WWWGROUP -u 1337 sail \ - && git config --system --add safe.directory /var/www/html +FROM frank/runtime:8.5-frankenphp-node24-pg17 # Copy Caddyfile (generated at .frank/Caddyfile; build context is project root) COPY .frank/Caddyfile /etc/caddy/Caddyfile - -# Entrypoint (heredoc — single-quoted delimiter suppresses $VAR expansion in body) -RUN cat <<'SCRIPT' > /entrypoint.sh -#!/usr/bin/env bash -if [ -n "$WWWUSER" ]; then - usermod -u "$WWWUSER" sail -fi -mkdir -p /var/www/html/storage/psysh 2>/dev/null || true -if [ -d "/var/www/html/storage" ]; then - chown -R sail:sail /var/www/html/storage /var/www/html/bootstrap/cache 2>/dev/null || true - chmod -R 755 /var/www/html/storage /var/www/html/bootstrap/cache 2>/dev/null || true -fi -if [ -f "/var/www/html/.env.example" ] && [ ! -f "/var/www/html/.env" ]; then - cp /var/www/html/.env.example /var/www/html/.env - php artisan key:generate --no-interaction 2>/dev/null || true -fi -mkdir -p /config/caddy /data/caddy -chown -R sail:sail /config/caddy /data/caddy -exec gosu sail "$@" -SCRIPT -RUN chmod +x /entrypoint.sh - -WORKDIR /var/www/html -EXPOSE 80 443 5173 - -USER root -ENTRYPOINT ["/entrypoint.sh"] -CMD ["frankenphp", "run", "--config", "/etc/caddy/Caddyfile"] diff --git a/cmd/testdata/frankenphp-pgsql-pnpm/.frank/base.Dockerfile b/cmd/testdata/frankenphp-pgsql-pnpm/.frank/base.Dockerfile new file mode 100644 index 0000000..68db2f6 --- /dev/null +++ b/cmd/testdata/frankenphp-pgsql-pnpm/.frank/base.Dockerfile @@ -0,0 +1,126 @@ +# Generated by Frank — edit frank.yaml, not this file + +# ── Builder: compile PHP extensions ────────────────────────────────────────── +FROM dunglas/frankenphp:1-php8.5 AS builder + +# Dev libs required to compile extensions (not present in final image) +RUN apt-get update && apt-get install -y --no-install-recommends \ + libmemcached-dev libmagickwand-dev libkrb5-dev libreadline-dev \ + libldap-dev libsqlite3-dev libxml2-dev libzip-dev \ + libpng-dev libonig-dev libicu-dev libpq-dev \ + && rm -rf /var/lib/apt/lists/* + +# Bundled extensions (compiled into PHP, enabled here). +# Note: imap is not installed — libc-client-dev was dropped from Debian trixie (the FrankenPHP +# base OS) and the extension cannot be compiled. Use the fpm runtime if imap is required. +# Note: pdo_sqlite, sqlite3, readline, and mbstring are already statically compiled into the +# FrankenPHP binary — omitting them here avoids a mid-run source tree cleanup that breaks +# subsequent extensions. +RUN docker-php-ext-install \ + soap ldap \ + pdo_mysql pdo_pgsql pgsql exif pcntl bcmath gd intl zip + +# PECL extensions — install igbinary/msgpack first as other extensions can link against them +RUN pecl install igbinary \ + && pecl install msgpack \ + && pecl install redis \ + && pecl install memcached \ + && pecl install mongodb \ + && pecl install imagick \ + && pecl install xdebug \ + && pecl install pcov \ + && docker-php-ext-enable igbinary msgpack redis memcached mongodb imagick xdebug pcov \ + && docker-php-source delete \ + && pecl clear-cache \ + && find /usr/local/lib/php/extensions -name '*.so' -exec strip --strip-all {} + + +# ── Final image ─────────────────────────────────────────────────────────────── +FROM dunglas/frankenphp:1-php8.5 AS final + +# Copy compiled extensions from builder. +# The .so files live in a versioned ABI subdirectory (e.g. no-debug-zts-20240924/). +# Both stages share the same base so the ABI path is identical — copy the whole tree. +COPY --from=builder /usr/local/lib/php/extensions/ /usr/local/lib/php/extensions/ +COPY --from=builder /usr/local/etc/php/conf.d/ /usr/local/etc/php/conf.d/ + +# Dev-friendly OPcache: recheck file timestamps every request (no stale bytecode) +RUN printf "opcache.revalidate_freq=0\nmemory_limit=1G\n" > /usr/local/etc/php/conf.d/zz-frank-dev.ini + +ARG WWWGROUP=1000 +ARG NODE_VERSION=24 +ARG POSTGRES_VERSION=17 +ARG MYSQL_CLIENT=default-mysql-client + +ENV DEBIAN_FRONTEND=noninteractive +ENV TZ=UTC + +RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone + +# Runtime libs for compiled extensions + system tools +# Runtime lib names are Debian trixie-specific (the FrankenPHP base OS as of 2026-04). +RUN mkdir -p /etc/apt/keyrings \ + && apt-get update && apt-get install -y --no-install-recommends \ + libmagickwand-7.q16-10 libmemcached11t64 libldap2 libkrb5-3 \ + libreadline8 libsqlite3-0 libicu76 libzip5 \ + gosu supervisor sqlite3 python3 dnsutils librsvg2-bin \ + fswatch ffmpeg nano git curl ca-certificates gnupg zip unzip \ + && rm -rf /usr/share/man /usr/share/doc \ + && apt-get clean && rm -rf /var/lib/apt/lists/* + +# Node.js +RUN curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key \ + | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg \ + && echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_${NODE_VERSION}.x nodistro main" \ + > /etc/apt/sources.list.d/nodesource.list \ + && apt-get update \ + && apt-get install -y --no-install-recommends nodejs \ + && npm install -g bun \ + && corepack enable \ + && corepack prepare npm@latest pnpm@latest --activate \ + && apt-get clean && rm -rf /var/lib/apt/lists/* + +# Database clients (pgdg repo — detect Debian codename dynamically) +RUN curl -sS https://www.postgresql.org/media/keys/ACCC4CF8.asc \ + | gpg --dearmor | tee /etc/apt/keyrings/pgdg.gpg > /dev/null \ + && echo "deb [signed-by=/etc/apt/keyrings/pgdg.gpg] http://apt.postgresql.org/pub/repos/apt $(. /etc/os-release && echo $VERSION_CODENAME)-pgdg main" \ + > /etc/apt/sources.list.d/pgdg.list \ + && apt-get update \ + && apt-get install -y --no-install-recommends \ + $MYSQL_CLIENT postgresql-client-$POSTGRES_VERSION \ + && apt-get clean && rm -rf /var/lib/apt/lists/* + +# Composer +COPY --from=composer:latest /usr/bin/composer /usr/bin/composer + +# User setup +RUN groupadd --force -g $WWWGROUP sail \ + && useradd -ms /bin/bash --no-user-group -g $WWWGROUP -u 1337 sail \ + && git config --system --add safe.directory /var/www/html + +# Entrypoint (heredoc — single-quoted delimiter suppresses $VAR expansion in body) +RUN cat <<'SCRIPT' > /entrypoint.sh +#!/usr/bin/env bash +if [ -n "$WWWUSER" ]; then + usermod -u "$WWWUSER" sail +fi +mkdir -p /var/www/html/storage/psysh 2>/dev/null || true +if [ -d "/var/www/html/storage" ]; then + chown -R sail:sail /var/www/html/storage /var/www/html/bootstrap/cache 2>/dev/null || true + chmod -R 755 /var/www/html/storage /var/www/html/bootstrap/cache 2>/dev/null || true +fi +if [ -f "/var/www/html/.env.example" ] && [ ! -f "/var/www/html/.env" ]; then + cp /var/www/html/.env.example /var/www/html/.env + php artisan key:generate --no-interaction 2>/dev/null || true +fi +mkdir -p /config/caddy /data/caddy +chown -R sail:sail /config/caddy /data/caddy +exec gosu sail "$@" +SCRIPT +RUN chmod +x /entrypoint.sh + +WORKDIR /var/www/html +EXPOSE 80 443 5173 + +USER root +ENTRYPOINT ["/entrypoint.sh"] +CMD ["frankenphp", "run", "--config", "/etc/caddy/Caddyfile"] diff --git a/cmd/testdata/frankenphp-pgsql-workers/.frank/Dockerfile b/cmd/testdata/frankenphp-pgsql-workers/.frank/Dockerfile index 097c0b9..39e4d87 100644 --- a/cmd/testdata/frankenphp-pgsql-workers/.frank/Dockerfile +++ b/cmd/testdata/frankenphp-pgsql-workers/.frank/Dockerfile @@ -1,129 +1,5 @@ # Generated by Frank — edit frank.yaml, not this file - -# ── Builder: compile PHP extensions ────────────────────────────────────────── -FROM dunglas/frankenphp:1-php8.5 AS builder - -# Dev libs required to compile extensions (not present in final image) -RUN apt-get update && apt-get install -y --no-install-recommends \ - libmemcached-dev libmagickwand-dev libkrb5-dev libreadline-dev \ - libldap-dev libsqlite3-dev libxml2-dev libzip-dev \ - libpng-dev libonig-dev libicu-dev libpq-dev \ - && rm -rf /var/lib/apt/lists/* - -# Bundled extensions (compiled into PHP, enabled here). -# Note: imap is not installed — libc-client-dev was dropped from Debian trixie (the FrankenPHP -# base OS) and the extension cannot be compiled. Use the fpm runtime if imap is required. -# Note: pdo_sqlite, sqlite3, readline, and mbstring are already statically compiled into the -# FrankenPHP binary — omitting them here avoids a mid-run source tree cleanup that breaks -# subsequent extensions. -RUN docker-php-ext-install \ - soap ldap \ - pdo_mysql pdo_pgsql pgsql exif pcntl bcmath gd intl zip - -# PECL extensions — install igbinary/msgpack first as other extensions can link against them -RUN pecl install igbinary \ - && pecl install msgpack \ - && pecl install redis \ - && pecl install memcached \ - && pecl install mongodb \ - && pecl install imagick \ - && pecl install xdebug \ - && pecl install pcov \ - && docker-php-ext-enable igbinary msgpack redis memcached mongodb imagick xdebug pcov \ - && docker-php-source delete \ - && pecl clear-cache \ - && find /usr/local/lib/php/extensions -name '*.so' -exec strip --strip-all {} + - -# ── Final image ─────────────────────────────────────────────────────────────── -FROM dunglas/frankenphp:1-php8.5 AS final - -# Copy compiled extensions from builder. -# The .so files live in a versioned ABI subdirectory (e.g. no-debug-zts-20240924/). -# Both stages share the same base so the ABI path is identical — copy the whole tree. -COPY --from=builder /usr/local/lib/php/extensions/ /usr/local/lib/php/extensions/ -COPY --from=builder /usr/local/etc/php/conf.d/ /usr/local/etc/php/conf.d/ - -# Dev-friendly OPcache: recheck file timestamps every request (no stale bytecode) -RUN printf "opcache.revalidate_freq=0\nmemory_limit=1G\n" > /usr/local/etc/php/conf.d/zz-frank-dev.ini - -ARG WWWGROUP=1000 -ARG NODE_VERSION=24 -ARG POSTGRES_VERSION=17 -ARG MYSQL_CLIENT=default-mysql-client - -ENV DEBIAN_FRONTEND=noninteractive -ENV TZ=UTC - -RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone - -# Runtime libs for compiled extensions + system tools -# Runtime lib names are Debian trixie-specific (the FrankenPHP base OS as of 2026-04). -RUN mkdir -p /etc/apt/keyrings \ - && apt-get update && apt-get install -y --no-install-recommends \ - libmagickwand-7.q16-10 libmemcached11t64 libldap2 libkrb5-3 \ - libreadline8 libsqlite3-0 libicu76 libzip5 \ - gosu supervisor sqlite3 python3 dnsutils librsvg2-bin \ - fswatch ffmpeg nano git curl ca-certificates gnupg zip unzip \ - && rm -rf /usr/share/man /usr/share/doc \ - && apt-get clean && rm -rf /var/lib/apt/lists/* - -# Node.js -RUN curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key \ - | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg \ - && echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_${NODE_VERSION}.x nodistro main" \ - > /etc/apt/sources.list.d/nodesource.list \ - && apt-get update \ - && apt-get install -y --no-install-recommends nodejs \ - && npm install -g bun \ - && corepack enable \ - && corepack prepare npm@latest pnpm@latest --activate \ - && apt-get clean && rm -rf /var/lib/apt/lists/* - -# Database clients (pgdg repo — detect Debian codename dynamically) -RUN curl -sS https://www.postgresql.org/media/keys/ACCC4CF8.asc \ - | gpg --dearmor | tee /etc/apt/keyrings/pgdg.gpg > /dev/null \ - && echo "deb [signed-by=/etc/apt/keyrings/pgdg.gpg] http://apt.postgresql.org/pub/repos/apt $(. /etc/os-release && echo $VERSION_CODENAME)-pgdg main" \ - > /etc/apt/sources.list.d/pgdg.list \ - && apt-get update \ - && apt-get install -y --no-install-recommends \ - $MYSQL_CLIENT postgresql-client-$POSTGRES_VERSION \ - && apt-get clean && rm -rf /var/lib/apt/lists/* - -# Composer -COPY --from=composer:latest /usr/bin/composer /usr/bin/composer - -# User setup -RUN groupadd --force -g $WWWGROUP sail \ - && useradd -ms /bin/bash --no-user-group -g $WWWGROUP -u 1337 sail \ - && git config --system --add safe.directory /var/www/html +FROM frank/runtime:8.5-frankenphp-node24-pg17 # Copy Caddyfile (generated at .frank/Caddyfile; build context is project root) COPY .frank/Caddyfile /etc/caddy/Caddyfile - -# Entrypoint (heredoc — single-quoted delimiter suppresses $VAR expansion in body) -RUN cat <<'SCRIPT' > /entrypoint.sh -#!/usr/bin/env bash -if [ -n "$WWWUSER" ]; then - usermod -u "$WWWUSER" sail -fi -mkdir -p /var/www/html/storage/psysh 2>/dev/null || true -if [ -d "/var/www/html/storage" ]; then - chown -R sail:sail /var/www/html/storage /var/www/html/bootstrap/cache 2>/dev/null || true - chmod -R 755 /var/www/html/storage /var/www/html/bootstrap/cache 2>/dev/null || true -fi -if [ -f "/var/www/html/.env.example" ] && [ ! -f "/var/www/html/.env" ]; then - cp /var/www/html/.env.example /var/www/html/.env - php artisan key:generate --no-interaction 2>/dev/null || true -fi -mkdir -p /config/caddy /data/caddy -chown -R sail:sail /config/caddy /data/caddy -exec gosu sail "$@" -SCRIPT -RUN chmod +x /entrypoint.sh - -WORKDIR /var/www/html -EXPOSE 80 443 5173 - -USER root -ENTRYPOINT ["/entrypoint.sh"] -CMD ["frankenphp", "run", "--config", "/etc/caddy/Caddyfile"] diff --git a/cmd/testdata/frankenphp-pgsql-workers/.frank/base.Dockerfile b/cmd/testdata/frankenphp-pgsql-workers/.frank/base.Dockerfile new file mode 100644 index 0000000..68db2f6 --- /dev/null +++ b/cmd/testdata/frankenphp-pgsql-workers/.frank/base.Dockerfile @@ -0,0 +1,126 @@ +# Generated by Frank — edit frank.yaml, not this file + +# ── Builder: compile PHP extensions ────────────────────────────────────────── +FROM dunglas/frankenphp:1-php8.5 AS builder + +# Dev libs required to compile extensions (not present in final image) +RUN apt-get update && apt-get install -y --no-install-recommends \ + libmemcached-dev libmagickwand-dev libkrb5-dev libreadline-dev \ + libldap-dev libsqlite3-dev libxml2-dev libzip-dev \ + libpng-dev libonig-dev libicu-dev libpq-dev \ + && rm -rf /var/lib/apt/lists/* + +# Bundled extensions (compiled into PHP, enabled here). +# Note: imap is not installed — libc-client-dev was dropped from Debian trixie (the FrankenPHP +# base OS) and the extension cannot be compiled. Use the fpm runtime if imap is required. +# Note: pdo_sqlite, sqlite3, readline, and mbstring are already statically compiled into the +# FrankenPHP binary — omitting them here avoids a mid-run source tree cleanup that breaks +# subsequent extensions. +RUN docker-php-ext-install \ + soap ldap \ + pdo_mysql pdo_pgsql pgsql exif pcntl bcmath gd intl zip + +# PECL extensions — install igbinary/msgpack first as other extensions can link against them +RUN pecl install igbinary \ + && pecl install msgpack \ + && pecl install redis \ + && pecl install memcached \ + && pecl install mongodb \ + && pecl install imagick \ + && pecl install xdebug \ + && pecl install pcov \ + && docker-php-ext-enable igbinary msgpack redis memcached mongodb imagick xdebug pcov \ + && docker-php-source delete \ + && pecl clear-cache \ + && find /usr/local/lib/php/extensions -name '*.so' -exec strip --strip-all {} + + +# ── Final image ─────────────────────────────────────────────────────────────── +FROM dunglas/frankenphp:1-php8.5 AS final + +# Copy compiled extensions from builder. +# The .so files live in a versioned ABI subdirectory (e.g. no-debug-zts-20240924/). +# Both stages share the same base so the ABI path is identical — copy the whole tree. +COPY --from=builder /usr/local/lib/php/extensions/ /usr/local/lib/php/extensions/ +COPY --from=builder /usr/local/etc/php/conf.d/ /usr/local/etc/php/conf.d/ + +# Dev-friendly OPcache: recheck file timestamps every request (no stale bytecode) +RUN printf "opcache.revalidate_freq=0\nmemory_limit=1G\n" > /usr/local/etc/php/conf.d/zz-frank-dev.ini + +ARG WWWGROUP=1000 +ARG NODE_VERSION=24 +ARG POSTGRES_VERSION=17 +ARG MYSQL_CLIENT=default-mysql-client + +ENV DEBIAN_FRONTEND=noninteractive +ENV TZ=UTC + +RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone + +# Runtime libs for compiled extensions + system tools +# Runtime lib names are Debian trixie-specific (the FrankenPHP base OS as of 2026-04). +RUN mkdir -p /etc/apt/keyrings \ + && apt-get update && apt-get install -y --no-install-recommends \ + libmagickwand-7.q16-10 libmemcached11t64 libldap2 libkrb5-3 \ + libreadline8 libsqlite3-0 libicu76 libzip5 \ + gosu supervisor sqlite3 python3 dnsutils librsvg2-bin \ + fswatch ffmpeg nano git curl ca-certificates gnupg zip unzip \ + && rm -rf /usr/share/man /usr/share/doc \ + && apt-get clean && rm -rf /var/lib/apt/lists/* + +# Node.js +RUN curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key \ + | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg \ + && echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_${NODE_VERSION}.x nodistro main" \ + > /etc/apt/sources.list.d/nodesource.list \ + && apt-get update \ + && apt-get install -y --no-install-recommends nodejs \ + && npm install -g bun \ + && corepack enable \ + && corepack prepare npm@latest pnpm@latest --activate \ + && apt-get clean && rm -rf /var/lib/apt/lists/* + +# Database clients (pgdg repo — detect Debian codename dynamically) +RUN curl -sS https://www.postgresql.org/media/keys/ACCC4CF8.asc \ + | gpg --dearmor | tee /etc/apt/keyrings/pgdg.gpg > /dev/null \ + && echo "deb [signed-by=/etc/apt/keyrings/pgdg.gpg] http://apt.postgresql.org/pub/repos/apt $(. /etc/os-release && echo $VERSION_CODENAME)-pgdg main" \ + > /etc/apt/sources.list.d/pgdg.list \ + && apt-get update \ + && apt-get install -y --no-install-recommends \ + $MYSQL_CLIENT postgresql-client-$POSTGRES_VERSION \ + && apt-get clean && rm -rf /var/lib/apt/lists/* + +# Composer +COPY --from=composer:latest /usr/bin/composer /usr/bin/composer + +# User setup +RUN groupadd --force -g $WWWGROUP sail \ + && useradd -ms /bin/bash --no-user-group -g $WWWGROUP -u 1337 sail \ + && git config --system --add safe.directory /var/www/html + +# Entrypoint (heredoc — single-quoted delimiter suppresses $VAR expansion in body) +RUN cat <<'SCRIPT' > /entrypoint.sh +#!/usr/bin/env bash +if [ -n "$WWWUSER" ]; then + usermod -u "$WWWUSER" sail +fi +mkdir -p /var/www/html/storage/psysh 2>/dev/null || true +if [ -d "/var/www/html/storage" ]; then + chown -R sail:sail /var/www/html/storage /var/www/html/bootstrap/cache 2>/dev/null || true + chmod -R 755 /var/www/html/storage /var/www/html/bootstrap/cache 2>/dev/null || true +fi +if [ -f "/var/www/html/.env.example" ] && [ ! -f "/var/www/html/.env" ]; then + cp /var/www/html/.env.example /var/www/html/.env + php artisan key:generate --no-interaction 2>/dev/null || true +fi +mkdir -p /config/caddy /data/caddy +chown -R sail:sail /config/caddy /data/caddy +exec gosu sail "$@" +SCRIPT +RUN chmod +x /entrypoint.sh + +WORKDIR /var/www/html +EXPOSE 80 443 5173 + +USER root +ENTRYPOINT ["/entrypoint.sh"] +CMD ["frankenphp", "run", "--config", "/etc/caddy/Caddyfile"] diff --git a/cmd/testdata/frankenphp-sqlite/.frank/Dockerfile b/cmd/testdata/frankenphp-sqlite/.frank/Dockerfile index 097c0b9..39e4d87 100644 --- a/cmd/testdata/frankenphp-sqlite/.frank/Dockerfile +++ b/cmd/testdata/frankenphp-sqlite/.frank/Dockerfile @@ -1,129 +1,5 @@ # Generated by Frank — edit frank.yaml, not this file - -# ── Builder: compile PHP extensions ────────────────────────────────────────── -FROM dunglas/frankenphp:1-php8.5 AS builder - -# Dev libs required to compile extensions (not present in final image) -RUN apt-get update && apt-get install -y --no-install-recommends \ - libmemcached-dev libmagickwand-dev libkrb5-dev libreadline-dev \ - libldap-dev libsqlite3-dev libxml2-dev libzip-dev \ - libpng-dev libonig-dev libicu-dev libpq-dev \ - && rm -rf /var/lib/apt/lists/* - -# Bundled extensions (compiled into PHP, enabled here). -# Note: imap is not installed — libc-client-dev was dropped from Debian trixie (the FrankenPHP -# base OS) and the extension cannot be compiled. Use the fpm runtime if imap is required. -# Note: pdo_sqlite, sqlite3, readline, and mbstring are already statically compiled into the -# FrankenPHP binary — omitting them here avoids a mid-run source tree cleanup that breaks -# subsequent extensions. -RUN docker-php-ext-install \ - soap ldap \ - pdo_mysql pdo_pgsql pgsql exif pcntl bcmath gd intl zip - -# PECL extensions — install igbinary/msgpack first as other extensions can link against them -RUN pecl install igbinary \ - && pecl install msgpack \ - && pecl install redis \ - && pecl install memcached \ - && pecl install mongodb \ - && pecl install imagick \ - && pecl install xdebug \ - && pecl install pcov \ - && docker-php-ext-enable igbinary msgpack redis memcached mongodb imagick xdebug pcov \ - && docker-php-source delete \ - && pecl clear-cache \ - && find /usr/local/lib/php/extensions -name '*.so' -exec strip --strip-all {} + - -# ── Final image ─────────────────────────────────────────────────────────────── -FROM dunglas/frankenphp:1-php8.5 AS final - -# Copy compiled extensions from builder. -# The .so files live in a versioned ABI subdirectory (e.g. no-debug-zts-20240924/). -# Both stages share the same base so the ABI path is identical — copy the whole tree. -COPY --from=builder /usr/local/lib/php/extensions/ /usr/local/lib/php/extensions/ -COPY --from=builder /usr/local/etc/php/conf.d/ /usr/local/etc/php/conf.d/ - -# Dev-friendly OPcache: recheck file timestamps every request (no stale bytecode) -RUN printf "opcache.revalidate_freq=0\nmemory_limit=1G\n" > /usr/local/etc/php/conf.d/zz-frank-dev.ini - -ARG WWWGROUP=1000 -ARG NODE_VERSION=24 -ARG POSTGRES_VERSION=17 -ARG MYSQL_CLIENT=default-mysql-client - -ENV DEBIAN_FRONTEND=noninteractive -ENV TZ=UTC - -RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone - -# Runtime libs for compiled extensions + system tools -# Runtime lib names are Debian trixie-specific (the FrankenPHP base OS as of 2026-04). -RUN mkdir -p /etc/apt/keyrings \ - && apt-get update && apt-get install -y --no-install-recommends \ - libmagickwand-7.q16-10 libmemcached11t64 libldap2 libkrb5-3 \ - libreadline8 libsqlite3-0 libicu76 libzip5 \ - gosu supervisor sqlite3 python3 dnsutils librsvg2-bin \ - fswatch ffmpeg nano git curl ca-certificates gnupg zip unzip \ - && rm -rf /usr/share/man /usr/share/doc \ - && apt-get clean && rm -rf /var/lib/apt/lists/* - -# Node.js -RUN curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key \ - | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg \ - && echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_${NODE_VERSION}.x nodistro main" \ - > /etc/apt/sources.list.d/nodesource.list \ - && apt-get update \ - && apt-get install -y --no-install-recommends nodejs \ - && npm install -g bun \ - && corepack enable \ - && corepack prepare npm@latest pnpm@latest --activate \ - && apt-get clean && rm -rf /var/lib/apt/lists/* - -# Database clients (pgdg repo — detect Debian codename dynamically) -RUN curl -sS https://www.postgresql.org/media/keys/ACCC4CF8.asc \ - | gpg --dearmor | tee /etc/apt/keyrings/pgdg.gpg > /dev/null \ - && echo "deb [signed-by=/etc/apt/keyrings/pgdg.gpg] http://apt.postgresql.org/pub/repos/apt $(. /etc/os-release && echo $VERSION_CODENAME)-pgdg main" \ - > /etc/apt/sources.list.d/pgdg.list \ - && apt-get update \ - && apt-get install -y --no-install-recommends \ - $MYSQL_CLIENT postgresql-client-$POSTGRES_VERSION \ - && apt-get clean && rm -rf /var/lib/apt/lists/* - -# Composer -COPY --from=composer:latest /usr/bin/composer /usr/bin/composer - -# User setup -RUN groupadd --force -g $WWWGROUP sail \ - && useradd -ms /bin/bash --no-user-group -g $WWWGROUP -u 1337 sail \ - && git config --system --add safe.directory /var/www/html +FROM frank/runtime:8.5-frankenphp-node24-pg17 # Copy Caddyfile (generated at .frank/Caddyfile; build context is project root) COPY .frank/Caddyfile /etc/caddy/Caddyfile - -# Entrypoint (heredoc — single-quoted delimiter suppresses $VAR expansion in body) -RUN cat <<'SCRIPT' > /entrypoint.sh -#!/usr/bin/env bash -if [ -n "$WWWUSER" ]; then - usermod -u "$WWWUSER" sail -fi -mkdir -p /var/www/html/storage/psysh 2>/dev/null || true -if [ -d "/var/www/html/storage" ]; then - chown -R sail:sail /var/www/html/storage /var/www/html/bootstrap/cache 2>/dev/null || true - chmod -R 755 /var/www/html/storage /var/www/html/bootstrap/cache 2>/dev/null || true -fi -if [ -f "/var/www/html/.env.example" ] && [ ! -f "/var/www/html/.env" ]; then - cp /var/www/html/.env.example /var/www/html/.env - php artisan key:generate --no-interaction 2>/dev/null || true -fi -mkdir -p /config/caddy /data/caddy -chown -R sail:sail /config/caddy /data/caddy -exec gosu sail "$@" -SCRIPT -RUN chmod +x /entrypoint.sh - -WORKDIR /var/www/html -EXPOSE 80 443 5173 - -USER root -ENTRYPOINT ["/entrypoint.sh"] -CMD ["frankenphp", "run", "--config", "/etc/caddy/Caddyfile"] diff --git a/cmd/testdata/frankenphp-sqlite/.frank/base.Dockerfile b/cmd/testdata/frankenphp-sqlite/.frank/base.Dockerfile new file mode 100644 index 0000000..68db2f6 --- /dev/null +++ b/cmd/testdata/frankenphp-sqlite/.frank/base.Dockerfile @@ -0,0 +1,126 @@ +# Generated by Frank — edit frank.yaml, not this file + +# ── Builder: compile PHP extensions ────────────────────────────────────────── +FROM dunglas/frankenphp:1-php8.5 AS builder + +# Dev libs required to compile extensions (not present in final image) +RUN apt-get update && apt-get install -y --no-install-recommends \ + libmemcached-dev libmagickwand-dev libkrb5-dev libreadline-dev \ + libldap-dev libsqlite3-dev libxml2-dev libzip-dev \ + libpng-dev libonig-dev libicu-dev libpq-dev \ + && rm -rf /var/lib/apt/lists/* + +# Bundled extensions (compiled into PHP, enabled here). +# Note: imap is not installed — libc-client-dev was dropped from Debian trixie (the FrankenPHP +# base OS) and the extension cannot be compiled. Use the fpm runtime if imap is required. +# Note: pdo_sqlite, sqlite3, readline, and mbstring are already statically compiled into the +# FrankenPHP binary — omitting them here avoids a mid-run source tree cleanup that breaks +# subsequent extensions. +RUN docker-php-ext-install \ + soap ldap \ + pdo_mysql pdo_pgsql pgsql exif pcntl bcmath gd intl zip + +# PECL extensions — install igbinary/msgpack first as other extensions can link against them +RUN pecl install igbinary \ + && pecl install msgpack \ + && pecl install redis \ + && pecl install memcached \ + && pecl install mongodb \ + && pecl install imagick \ + && pecl install xdebug \ + && pecl install pcov \ + && docker-php-ext-enable igbinary msgpack redis memcached mongodb imagick xdebug pcov \ + && docker-php-source delete \ + && pecl clear-cache \ + && find /usr/local/lib/php/extensions -name '*.so' -exec strip --strip-all {} + + +# ── Final image ─────────────────────────────────────────────────────────────── +FROM dunglas/frankenphp:1-php8.5 AS final + +# Copy compiled extensions from builder. +# The .so files live in a versioned ABI subdirectory (e.g. no-debug-zts-20240924/). +# Both stages share the same base so the ABI path is identical — copy the whole tree. +COPY --from=builder /usr/local/lib/php/extensions/ /usr/local/lib/php/extensions/ +COPY --from=builder /usr/local/etc/php/conf.d/ /usr/local/etc/php/conf.d/ + +# Dev-friendly OPcache: recheck file timestamps every request (no stale bytecode) +RUN printf "opcache.revalidate_freq=0\nmemory_limit=1G\n" > /usr/local/etc/php/conf.d/zz-frank-dev.ini + +ARG WWWGROUP=1000 +ARG NODE_VERSION=24 +ARG POSTGRES_VERSION=17 +ARG MYSQL_CLIENT=default-mysql-client + +ENV DEBIAN_FRONTEND=noninteractive +ENV TZ=UTC + +RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone + +# Runtime libs for compiled extensions + system tools +# Runtime lib names are Debian trixie-specific (the FrankenPHP base OS as of 2026-04). +RUN mkdir -p /etc/apt/keyrings \ + && apt-get update && apt-get install -y --no-install-recommends \ + libmagickwand-7.q16-10 libmemcached11t64 libldap2 libkrb5-3 \ + libreadline8 libsqlite3-0 libicu76 libzip5 \ + gosu supervisor sqlite3 python3 dnsutils librsvg2-bin \ + fswatch ffmpeg nano git curl ca-certificates gnupg zip unzip \ + && rm -rf /usr/share/man /usr/share/doc \ + && apt-get clean && rm -rf /var/lib/apt/lists/* + +# Node.js +RUN curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key \ + | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg \ + && echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_${NODE_VERSION}.x nodistro main" \ + > /etc/apt/sources.list.d/nodesource.list \ + && apt-get update \ + && apt-get install -y --no-install-recommends nodejs \ + && npm install -g bun \ + && corepack enable \ + && corepack prepare npm@latest pnpm@latest --activate \ + && apt-get clean && rm -rf /var/lib/apt/lists/* + +# Database clients (pgdg repo — detect Debian codename dynamically) +RUN curl -sS https://www.postgresql.org/media/keys/ACCC4CF8.asc \ + | gpg --dearmor | tee /etc/apt/keyrings/pgdg.gpg > /dev/null \ + && echo "deb [signed-by=/etc/apt/keyrings/pgdg.gpg] http://apt.postgresql.org/pub/repos/apt $(. /etc/os-release && echo $VERSION_CODENAME)-pgdg main" \ + > /etc/apt/sources.list.d/pgdg.list \ + && apt-get update \ + && apt-get install -y --no-install-recommends \ + $MYSQL_CLIENT postgresql-client-$POSTGRES_VERSION \ + && apt-get clean && rm -rf /var/lib/apt/lists/* + +# Composer +COPY --from=composer:latest /usr/bin/composer /usr/bin/composer + +# User setup +RUN groupadd --force -g $WWWGROUP sail \ + && useradd -ms /bin/bash --no-user-group -g $WWWGROUP -u 1337 sail \ + && git config --system --add safe.directory /var/www/html + +# Entrypoint (heredoc — single-quoted delimiter suppresses $VAR expansion in body) +RUN cat <<'SCRIPT' > /entrypoint.sh +#!/usr/bin/env bash +if [ -n "$WWWUSER" ]; then + usermod -u "$WWWUSER" sail +fi +mkdir -p /var/www/html/storage/psysh 2>/dev/null || true +if [ -d "/var/www/html/storage" ]; then + chown -R sail:sail /var/www/html/storage /var/www/html/bootstrap/cache 2>/dev/null || true + chmod -R 755 /var/www/html/storage /var/www/html/bootstrap/cache 2>/dev/null || true +fi +if [ -f "/var/www/html/.env.example" ] && [ ! -f "/var/www/html/.env" ]; then + cp /var/www/html/.env.example /var/www/html/.env + php artisan key:generate --no-interaction 2>/dev/null || true +fi +mkdir -p /config/caddy /data/caddy +chown -R sail:sail /config/caddy /data/caddy +exec gosu sail "$@" +SCRIPT +RUN chmod +x /entrypoint.sh + +WORKDIR /var/www/html +EXPOSE 80 443 5173 + +USER root +ENTRYPOINT ["/entrypoint.sh"] +CMD ["frankenphp", "run", "--config", "/etc/caddy/Caddyfile"] diff --git a/cmd/up.go b/cmd/up.go index 8d516f5..799dba1 100644 --- a/cmd/up.go +++ b/cmd/up.go @@ -14,6 +14,7 @@ import ( "syscall" "time" + "github.com/phlisg/frank/internal/baseimage" "github.com/phlisg/frank/internal/cert" "github.com/phlisg/frank/internal/config" "github.com/phlisg/frank/internal/docker" @@ -105,6 +106,15 @@ func doUp(dir string, detach, quick bool, passthrough []string, showNextSteps bo quick = false } + // Pre-flight: build/refresh the shared frank/runtime base image. The thin + // .frank/Dockerfile is `FROM frank/runtime:`, so compose would try to + // pull that base from docker.io and fail without it. Placed AFTER + // autoRegenerate because regen can change the base templates, requiring a + // fresh base. Fatal — if the base can't build, compose will certainly fail. + if err := ensureBaseImage(dir); err != nil { + return err + } + // Pre-flight: generate APP_KEY before containers start so docker's // env_file picks it up at container creation time. if needsAppKey(dir) { @@ -211,6 +221,57 @@ func doUp(dir string, detach, quick bool, passthrough []string, showNextSteps bo return nil } +// ensureBaseImage builds/refreshes the shared frank/runtime base for dir's +// config before any compose build. Graceful skip on config-load failure +// (mirrors autoRegenerate) so a missing/broken frank.yaml doesn't block — the +// normal flow surfaces the config error instead. When the image is already +// present and fresh this is an instant inspect+label compare, so it is safe to +// call before every up / compose-build / worker launch. +func ensureBaseImage(dir string) error { + cfg, err := config.Load(dir) + if err != nil { + return nil // let the normal flow surface the config error + } + engine := template.New(TemplateFS) + return baseimage.EnsureBase(engine, cfg) +} + +// composeSubcmdBuilds reports whether a `frank compose` passthrough argv will +// trigger an image build (and therefore needs the shared base image present). +// It skips leading flags (anything starting with "-", plus the value of a +// space-separated flag like `-f file`) to find the first subcommand, then +// checks it against the build-capable set {build, up, run, create}. Read-only +// subcommands (ps, logs, down, …) return false. +func composeSubcmdBuilds(args []string) bool { + for i := 0; i < len(args); i++ { + a := args[i] + if a == "--" { + continue + } + if strings.HasPrefix(a, "-") { + // `-f file` / `--file file` style: skip the value too when the + // flag has no "=" and isn't a bundled short flag. We can't know + // every compose flag's arity, but the build-detection only needs + // to land on the first non-flag token; treating a following + // non-flag token as the flag's value risks misclassifying. The + // common pre-subcommand flag here is `-f `, so honor that. + if (a == "-f" || a == "--file" || a == "-p" || a == "--project-name" || + a == "--project-directory" || a == "--profile" || a == "--env-file") && + i+1 < len(args) { + i++ + } + continue + } + switch a { + case "build", "up", "run", "create": + return true + default: + return false + } + } + return false +} + // autoRegenerate detects a stale .frank/ and regenerates it. Two tiers: // // Tier 1 (should we regenerate at all?): stale if .state is missing/corrupt, @@ -269,6 +330,16 @@ func autoRegenerate(dir, currentVersion string) (regenerated, needsBuild bool, e } } + // Structural migration trigger: a pre-split project has a monolithic + // .frank/Dockerfile but no .frank/base.Dockerfile. Regenerate so the split + // templates (+ shared base) materialize, even if version arithmetic didn't fire. + if !stale { + if _, statErr := os.Stat(filepath.Join(dir, ".frank", "base.Dockerfile")); os.IsNotExist(statErr) { + stale = true + reason = ".frank/base.Dockerfile missing" + } + } + if !stale { return false, false, nil } @@ -310,7 +381,10 @@ func dockerfileChanged(dir string, cfg *config.Config) bool { data := dockerfileData(cfg, config.ProjectName(dir)) frankDir := filepath.Join(dir, ".frank") - dockerfiles := []struct{ tmpl, file string }{{"Dockerfile.tmpl", "Dockerfile"}} + dockerfiles := []struct{ tmpl, file string }{ + {"Dockerfile.tmpl", "Dockerfile"}, + {"base.Dockerfile.tmpl", "base.Dockerfile"}, + } if cfg.PHP.Runtime == "fpm" { dockerfiles = append(dockerfiles, struct{ tmpl, file string }{"nginx.Dockerfile.tmpl", "nginx.Dockerfile"}) } diff --git a/cmd/worker.go b/cmd/worker.go index 4d1a528..4ce4737 100644 --- a/cmd/worker.go +++ b/cmd/worker.go @@ -147,6 +147,12 @@ func runWorkerQueue(cmd *cobra.Command, args []string) error { return fmt.Errorf("--count must be >= 1") } + // Ad-hoc workers reuse the thin laravel.test image (`FROM frank/runtime`), + // so the shared base must exist before `docker compose run` launches them. + if err := ensureBaseImage(dir); err != nil { + return err + } + passthrough := splitPassthrough(cmd, args) epoch := time.Now().Unix() @@ -197,6 +203,12 @@ func runWorkerSchedule(cmd *cobra.Command, args []string) error { } } + // Ad-hoc schedule reuses the thin laravel.test image (`FROM frank/runtime`), + // so the shared base must exist before `docker compose run` launches it. + if err := ensureBaseImage(dir); err != nil { + return err + } + epoch := time.Now().Unix() name := adhocScheduleName(epoch) labels := map[string]string{ diff --git a/internal/baseimage/baseimage.go b/internal/baseimage/baseimage.go new file mode 100644 index 0000000..d6ff559 --- /dev/null +++ b/internal/baseimage/baseimage.go @@ -0,0 +1,52 @@ +// Package baseimage derives the identity of Frank's shared base runtime image: +// its tag, the rendered base Dockerfile, and a content hash of that render. +// The tag encodes the (php, runtime, node, pg) tuple so distinct tuples never +// clobber each other; the hash catches changes to the base template body within +// a single tuple. Together they back the frank.base.hash staleness check used by +// ensure-base. +package baseimage + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + + "github.com/phlisg/frank/internal/config" + "github.com/phlisg/frank/internal/template" +) + +const ( + // NodeVersion and PostgresVersion mirror the base Dockerfile ARG defaults + // (NODE_VERSION=24, POSTGRES_VERSION=17) and the thin FROM tag suffix + // (node24-pg17). They are part of the tag, so keep them in lockstep with + // both templates. + NodeVersion = "24" + PostgresVersion = "17" +) + +// Tag returns the base image tag for cfg, e.g. +// "frank/runtime:8.5-frankenphp-node24-pg17". The format is byte-for-byte +// identical to the FROM line in the thin runtime Dockerfile templates. +func Tag(cfg *config.Config) string { + return fmt.Sprintf( + "frank/runtime:%s-%s-node%s-pg%s", + cfg.PHP.Version, cfg.PHP.Runtime, NodeVersion, PostgresVersion, + ) +} + +// Render renders the base Dockerfile for cfg's runtime. The base template only +// references PHPVersion, so no other fields are populated. +func Render(engine *template.Engine, cfg *config.Config) (string, error) { + return engine.RenderRuntime( + cfg.PHP.Runtime, + "base.Dockerfile.tmpl", + template.Data{PHPVersion: cfg.PHP.Version}, + ) +} + +// Hash returns the sha256 hex digest of the rendered base Dockerfile. This is +// the value of the frank.base.hash image label. +func Hash(rendered string) string { + sum := sha256.Sum256([]byte(rendered)) + return hex.EncodeToString(sum[:]) +} diff --git a/internal/baseimage/baseimage_test.go b/internal/baseimage/baseimage_test.go new file mode 100644 index 0000000..15fb1d8 --- /dev/null +++ b/internal/baseimage/baseimage_test.go @@ -0,0 +1,80 @@ +package baseimage + +import ( + "os" + "strings" + "testing" + + "github.com/phlisg/frank/internal/config" + "github.com/phlisg/frank/internal/template" +) + +func newTestEngine() *template.Engine { + // Engine prepends "templates/" internally, so root the FS at the frank root. + return template.New(os.DirFS("../..")) +} + +func TestTag(t *testing.T) { + cases := []struct { + name string + php string + runtime string + want string + }{ + {"frankenphp", "8.5", "frankenphp", "frank/runtime:8.5-frankenphp-node24-pg17"}, + {"fpm", "8.4", "fpm", "frank/runtime:8.4-fpm-node24-pg17"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + cfg := &config.Config{PHP: config.PHP{Version: tc.php, Runtime: tc.runtime}} + got := Tag(cfg) + if got != tc.want { + t.Fatalf("Tag = %q, want %q", got, tc.want) + } + }) + } +} + +func TestHashStableAndSensitive(t *testing.T) { + const in = "FROM dunglas/frankenphp:1-php8.5\nRUN echo hi\n" + + a := Hash(in) + b := Hash(in) + if a != b { + t.Fatalf("Hash not stable: %q != %q", a, b) + } + if len(a) != 64 { + t.Fatalf("Hash length = %d, want 64 hex chars", len(a)) + } + + if c := Hash(in + "x"); c == a { + t.Fatalf("Hash did not change for different input") + } +} + +func TestRender(t *testing.T) { + e := newTestEngine() + + cfg := &config.Config{PHP: config.PHP{Version: "8.5", Runtime: "frankenphp"}} + out, err := Render(e, cfg) + if err != nil { + t.Fatalf("Render(frankenphp) error: %v", err) + } + if !strings.Contains(out, "dunglas/frankenphp:1-php8.5") { + t.Fatalf("Render(frankenphp) missing PHP version interpolation:\n%s", out) + } + + fpmCfg := &config.Config{PHP: config.PHP{Version: "8.4", Runtime: "fpm"}} + fpmOut, err := Render(e, fpmCfg) + if err != nil { + t.Fatalf("Render(fpm) error: %v", err) + } + if !strings.Contains(fpmOut, "php8.4-fpm") { + t.Fatalf("Render(fpm) missing PHP version interpolation:\n%s", fpmOut) + } + + // Hash of a real render is deterministic and well-formed. + if h := Hash(out); len(h) != 64 { + t.Fatalf("Hash(render) length = %d, want 64", len(h)) + } +} diff --git a/internal/baseimage/ensure.go b/internal/baseimage/ensure.go new file mode 100644 index 0000000..1949d15 --- /dev/null +++ b/internal/baseimage/ensure.go @@ -0,0 +1,179 @@ +package baseimage + +import ( + "bytes" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "strings" + "syscall" + + "github.com/phlisg/frank/internal/config" + "github.com/phlisg/frank/internal/output" + "github.com/phlisg/frank/internal/template" +) + +// labelKey is the image label that carries the rendered base Dockerfile's +// content hash. ensure-base compares it against the freshly computed hash to +// decide whether the shared base image is stale. +const labelKey = "frank.base.hash" + +// needsBuild reports whether the base image must be (re)built. +// +// - present=false (image absent) -> true. +// - present=true but gotLabel != wantHash (template-body drift, or label +// missing/"") -> true. +// - present=true and gotLabel == wantHash -> false (skip, instant). +func needsBuild(present bool, gotLabel, wantHash string) bool { + if !present { + return true + } + gotLabel = strings.TrimSpace(gotLabel) + if gotLabel == "" || gotLabel == "" { + return true + } + return gotLabel != wantHash +} + +// EnsureBase guarantees the shared base runtime image for cfg exists and is +// fresh, rebuilding it once (under a host file lock so concurrent frank +// invocations serialize) when absent or drifted. +// +// This operates on the global docker daemon, NOT a compose project: the base +// image is shared across every Frank project on the host. It therefore shells +// out to `docker` directly rather than reusing docker.Client (which always +// wraps `docker compose -f .frank/compose.yaml`). +func EnsureBase(engine *template.Engine, cfg *config.Config) error { + rendered, err := Render(engine, cfg) + if err != nil { + return fmt.Errorf("render base Dockerfile: %w", err) + } + hash := Hash(rendered) + tag := Tag(cfg) + + // oldID from this pre-lock inspect is intentionally discarded: it is + // re-captured under the lock below, and only the locked value is used for + // the post-build prune (the pre-lock ID could be stale by then anyway). + present, gotLabel, _ := inspectBase(tag) + if !needsBuild(present, gotLabel, hash) { + output.Detail(fmt.Sprintf("base image %s up to date", tag)) + return nil + } + + // Serialize concurrent builds of the same tuple behind a host file lock so + // parallel `frank up` invocations don't race the same tag. + lockPath, err := baseLockPath(tag) + if err != nil { + return err + } + lockFile, err := os.OpenFile(lockPath, os.O_CREATE|os.O_RDWR, 0o644) + if err != nil { + return fmt.Errorf("open base lock %s: %w", lockPath, err) + } + defer lockFile.Close() + if err := syscall.Flock(int(lockFile.Fd()), syscall.LOCK_EX); err != nil { + return fmt.Errorf("lock base image build: %w", err) + } + defer syscall.Flock(int(lockFile.Fd()), syscall.LOCK_UN) + + // Re-check under the lock: another holder may have finished building while + // we waited, so we don't rebuild needlessly. + var oldID string + present, gotLabel, oldID = inspectBase(tag) + if !needsBuild(present, gotLabel, hash) { + output.Detail(fmt.Sprintf("base image %s up to date", tag)) + return nil + } + + region := output.Region(fmt.Sprintf("Building base image %s", tag)) + if err := buildBase(tag, hash, rendered, region); err != nil { + region.Stop(err) + return fmt.Errorf("build base image %s: %w", tag, err) + } + region.Stop(nil) + + // Best-effort prune of the prior base: an in-place rebuild leaves the old + // layers dangling as (~2GB), so reap it. Ignore errors ("image is + // in use" etc. are not fatal here). + if oldID != "" { + if _, newID, _ := inspectID(tag); newID != "" && newID != oldID { + pruneImage(oldID) + } + } + return nil +} + +// inspectBase inspects tag's frank.base.hash label. present is false when the +// image is absent (inspect exits non-zero). When present, gotLabel is the +// trimmed label value and oldID is the image ID (captured for later prune). +func inspectBase(tag string) (present bool, gotLabel, oldID string) { + cmd := exec.Command("docker", "image", "inspect", tag, + "--format", fmt.Sprintf(`{{ index .Config.Labels "%s" }}`, labelKey)) + var stdout bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = io.Discard + if err := cmd.Run(); err != nil { + return false, "", "" + } + gotLabel = strings.TrimSpace(stdout.String()) + _, oldID, _ = inspectID(tag) + return true, gotLabel, oldID +} + +// inspectID returns the image ID for tag. present is false when absent. +func inspectID(tag string) (present bool, id string, err error) { + cmd := exec.Command("docker", "image", "inspect", tag, "--format", "{{.Id}}") + var stdout bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = io.Discard + if err := cmd.Run(); err != nil { + return false, "", err + } + return true, strings.TrimSpace(stdout.String()), nil +} + +// buildBase builds the base image from rendered, with an empty build context: +// the Dockerfile is fed on stdin (`docker build ... -`). The empty context is +// load-bearing — it keeps the base byte-identical across every project so +// docker maximizes layer dedup. Non-verbose runs discard docker's output. +func buildBase(tag, hash, rendered string, w io.Writer) error { + cmd := exec.Command("docker", "build", + "--progress=plain", + "--label", labelKey+"="+hash, + "-t", tag, + "-") + cmd.Stdin = strings.NewReader(rendered) + cmd.Stdout = w + cmd.Stderr = w + return cmd.Run() +} + +// pruneImage removes an image by ID, best-effort (errors ignored). +func pruneImage(id string) { + cmd := exec.Command("docker", "rmi", id) + cmd.Stdout = io.Discard + cmd.Stderr = io.Discard + _ = cmd.Run() +} + +// baseLockPath returns the host lock-file path for tag, under the user cache +// dir's frank/ subdir, creating that dir as needed. Tag separators (/ :) are +// replaced so the name is filesystem-safe. +func baseLockPath(tag string) (string, error) { + cacheDir, err := os.UserCacheDir() + if err != nil { + return "", fmt.Errorf("resolve user cache dir: %w", err) + } + dir := filepath.Join(cacheDir, "frank") + if err := os.MkdirAll(dir, 0o755); err != nil { + return "", fmt.Errorf("create lock dir %s: %w", dir, err) + } + return filepath.Join(dir, "base-"+sanitizeTag(tag)+".lock"), nil +} + +// sanitizeTag replaces filesystem-unsafe characters in a docker tag with "-". +func sanitizeTag(tag string) string { + return strings.NewReplacer("/", "-", ":", "-").Replace(tag) +} diff --git a/internal/baseimage/ensure_test.go b/internal/baseimage/ensure_test.go new file mode 100644 index 0000000..f6685f9 --- /dev/null +++ b/internal/baseimage/ensure_test.go @@ -0,0 +1,29 @@ +package baseimage + +import "testing" + +func TestNeedsBuild(t *testing.T) { + const want = "abc123" + tests := []struct { + name string + present bool + gotLabel string + want bool + }{ + {"absent", false, "", true}, + {"label differs", true, "deadbeef", true}, + {"label empty", true, "", true}, + {"label no value", true, "", true}, + {"label no value padded", true, " ", true}, + {"label matches", true, want, false}, + {"label matches padded", true, " abc123 ", false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := needsBuild(tt.present, tt.gotLabel, want); got != tt.want { + t.Errorf("needsBuild(%v, %q, %q) = %v, want %v", + tt.present, tt.gotLabel, want, got, tt.want) + } + }) + } +} diff --git a/internal/shell/shell.go b/internal/shell/shell.go index 09ea6a3..7585cf4 100644 --- a/internal/shell/shell.go +++ b/internal/shell/shell.go @@ -9,6 +9,14 @@ import ( ) const ( + // dc is the raw docker compose prefix used by the shell aliases. These + // aliases bypass Frank entirely, so they do NOT run ensure-base: the thin + // .frank/Dockerfile is `FROM frank/runtime:`, which only Frank builds. + // A cold-start raw `dc up` (run before ANY frank command in the project) + // is therefore UNSUPPORTED — compose will try to pull frank/runtime from + // docker.io and fail. The shared base is materialized by any preceding + // Frank command (frank up / frank compose / frank + // worker), after which raw `dc` aliases work fine. dc = "docker compose --project-directory . -f .frank/compose.yaml" execSail = dc + " exec --user sail laravel.test" ) diff --git a/internal/template/template_test.go b/internal/template/template_test.go index f304fc3..f10de48 100644 --- a/internal/template/template_test.go +++ b/internal/template/template_test.go @@ -23,10 +23,35 @@ func newTestEngine(t *testing.T) *Engine { func TestRenderRuntimeDockerfile_FrankenPHP(t *testing.T) { e := newTestEngine(t) + + // Thin Dockerfile derives from the shared base image and adds only the + // project-specific Caddyfile COPY. All the invariant build layers live in + // base.Dockerfile (see TestRenderBaseDockerfile_FrankenPHP). out, err := e.RenderRuntime("frankenphp", "Dockerfile.tmpl", Data{PHPVersion: "8.4"}) if err != nil { t.Fatalf("RenderRuntime error: %v", err) } + thinChecks := []string{ + "FROM frank/runtime:8.4-frankenphp-node24-pg17", + "COPY .frank/Caddyfile /etc/caddy/Caddyfile", + "# Generated by Frank", + } + for _, want := range thinChecks { + if !strings.Contains(out, want) { + t.Errorf("expected %q in thin FrankenPHP Dockerfile, got:\n%s", want, out) + } + } + if strings.Contains(out, "dunglas/frankenphp") { + t.Error("thin Dockerfile must derive from frank/runtime, not the upstream image directly") + } +} + +func TestRenderBaseDockerfile_FrankenPHP(t *testing.T) { + e := newTestEngine(t) + out, err := e.RenderRuntime("frankenphp", "base.Dockerfile.tmpl", Data{PHPVersion: "8.4"}) + if err != nil { + t.Fatalf("RenderRuntime error: %v", err) + } checks := []string{ "dunglas/frankenphp:1-php8.4", "AS builder", @@ -42,13 +67,20 @@ func TestRenderRuntimeDockerfile_FrankenPHP(t *testing.T) { "/var/www/html", "EXPOSE 80 443 5173", "ENV TZ=UTC", - "# Generated by Frank", } for _, want := range checks { if !strings.Contains(out, want) { - t.Errorf("expected %q in FrankenPHP Dockerfile, got:\n%s", want, out) + t.Errorf("expected %q in FrankenPHP base Dockerfile, got:\n%s", want, out) } } + // The base image is what frank/runtime IS — it must not reference it. + if strings.Contains(out, "frank/runtime") { + t.Error("base Dockerfile must not reference frank/runtime (it is the base)") + } + // The Caddyfile COPY is project-specific; it belongs in the thin Dockerfile. + if strings.Contains(out, "COPY .frank/Caddyfile") { + t.Error("Caddyfile COPY belongs in the thin Dockerfile, not the base") + } if strings.Contains(out, `"/app`) { t.Error("old /app path must not appear; use /var/www/html throughout") } @@ -56,10 +88,33 @@ func TestRenderRuntimeDockerfile_FrankenPHP(t *testing.T) { func TestRenderRuntimeDockerfile_FPM(t *testing.T) { e := newTestEngine(t) + + // Thin FPM Dockerfile is just a FROM line — no Caddyfile (nginx is a + // separate container). All build invariants live in base.Dockerfile. out, err := e.RenderRuntime("fpm", "Dockerfile.tmpl", Data{PHPVersion: "8.3"}) if err != nil { t.Fatalf("RenderRuntime error: %v", err) } + if !strings.Contains(out, "FROM frank/runtime:8.3-fpm-node24-pg17") { + t.Errorf("expected FROM frank/runtime in thin FPM Dockerfile, got:\n%s", out) + } + if !strings.Contains(out, "# Generated by Frank") { + t.Errorf("expected Frank header in thin FPM Dockerfile, got:\n%s", out) + } + if strings.Contains(out, "ubuntu:24.04") { + t.Error("thin Dockerfile must derive from frank/runtime, not ubuntu directly") + } + if strings.Contains(out, "COPY .frank/Caddyfile") { + t.Error("FPM thin Dockerfile must not COPY a Caddyfile (nginx is separate)") + } +} + +func TestRenderBaseDockerfile_FPM(t *testing.T) { + e := newTestEngine(t) + out, err := e.RenderRuntime("fpm", "base.Dockerfile.tmpl", Data{PHPVersion: "8.3"}) + if err != nil { + t.Fatalf("RenderRuntime error: %v", err) + } // Uses PHPVersion "8.3" to stay independent of the golden fixture (which uses 8.4). // The template logic is the same for all supported versions. checks := []string{ @@ -75,14 +130,16 @@ func TestRenderRuntimeDockerfile_FPM(t *testing.T) { "php-fpm8.3", `"php-fpm8.3", "-F"`, "listen = 0.0.0.0:9000", - "# Generated by Frank", "EXPOSE 9000", } for _, want := range checks { if !strings.Contains(out, want) { - t.Errorf("expected %q in FPM Dockerfile, got:\n%s", want, out) + t.Errorf("expected %q in FPM base Dockerfile, got:\n%s", want, out) } } + if strings.Contains(out, "frank/runtime") { + t.Error("base Dockerfile must not reference frank/runtime (it is the base)") + } if strings.Contains(out, "php:8.3-fpm") { t.Error("old php:8.3-fpm base image must be removed") } diff --git a/templates/runtimes/fpm/Dockerfile.tmpl b/templates/runtimes/fpm/Dockerfile.tmpl index 73858b5..d961e82 100644 --- a/templates/runtimes/fpm/Dockerfile.tmpl +++ b/templates/runtimes/fpm/Dockerfile.tmpl @@ -1,117 +1,2 @@ # Generated by Frank — edit frank.yaml, not this file -FROM ubuntu:24.04 - -ARG WWWGROUP=1000 -ARG NODE_VERSION=24 -ARG POSTGRES_VERSION=17 -ARG MYSQL_CLIENT=mysql-client - -ENV DEBIAN_FRONTEND=noninteractive -ENV TZ=UTC - -RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone - -# Base system tools -RUN mkdir -p /etc/apt/keyrings \ - && apt-get update && apt-get install -y --no-install-recommends \ - gnupg gosu curl ca-certificates zip unzip git supervisor \ - sqlite3 libgd3 python3 dnsutils \ - librsvg2-bin fswatch ffmpeg nano \ - && rm -rf /usr/share/man /usr/share/doc \ - && apt-get clean && rm -rf /var/lib/apt/lists/* - -# PHP via ondrej PPA -RUN curl -sS 'https://keyserver.ubuntu.com/pks/lookup?op=get&search=0xb8dc7e53946656efbce4c1dd71daeaab4ad4cab6' \ - | gpg --dearmor | tee /etc/apt/keyrings/ppa_ondrej_php.gpg > /dev/null \ - && echo "deb [signed-by=/etc/apt/keyrings/ppa_ondrej_php.gpg] https://ppa.launchpadcontent.net/ondrej/php/ubuntu noble main" \ - > /etc/apt/sources.list.d/ppa_ondrej_php.list \ - && apt-get update \ - && apt-get install -y --no-install-recommends \ - php{{.PHPVersion}}-fpm php{{.PHPVersion}}-cli \ - php{{.PHPVersion}}-pgsql php{{.PHPVersion}}-sqlite3 php{{.PHPVersion}}-gd \ - php{{.PHPVersion}}-curl php{{.PHPVersion}}-mongodb php{{.PHPVersion}}-imap \ - php{{.PHPVersion}}-mysql php{{.PHPVersion}}-mbstring php{{.PHPVersion}}-xml \ - php{{.PHPVersion}}-zip php{{.PHPVersion}}-bcmath php{{.PHPVersion}}-soap \ - php{{.PHPVersion}}-intl php{{.PHPVersion}}-readline php{{.PHPVersion}}-ldap \ - php{{.PHPVersion}}-msgpack php{{.PHPVersion}}-igbinary php{{.PHPVersion}}-redis \ - php{{.PHPVersion}}-memcached php{{.PHPVersion}}-pcov php{{.PHPVersion}}-imagick \ - php{{.PHPVersion}}-xdebug \ - && rm -rf /usr/share/man /usr/share/doc \ - && apt-get clean && rm -rf /var/lib/apt/lists/* - -# Configure FPM: TCP port 9000, log to stderr, workers run as sail. -# php-fpm master runs as root (required for PID file, worker management). -# The pool handles the sail user drop — gosu is not used for php-fpm. -# Note: `daemonize = no` was removed — PHP 8.5 dropped this global directive. -# Foreground mode is enforced via the -F flag in CMD instead. -# Dev-friendly OPcache: recheck file timestamps every request (no stale bytecode) -RUN printf "opcache.revalidate_freq=0\nmemory_limit=1G\n" > /etc/php/{{.PHPVersion}}/fpm/conf.d/99-frank-dev.ini \ - && printf "opcache.revalidate_freq=0\nmemory_limit=1G\n" > /etc/php/{{.PHPVersion}}/cli/conf.d/99-frank-dev.ini - -RUN sed -i 's|error_log = .*|error_log = /dev/stderr|' /etc/php/{{.PHPVersion}}/fpm/php-fpm.conf \ - && sed -i 's|listen = .*|listen = 0.0.0.0:9000|' /etc/php/{{.PHPVersion}}/fpm/pool.d/www.conf \ - && sed -i 's|^user = .*|user = sail|' /etc/php/{{.PHPVersion}}/fpm/pool.d/www.conf \ - && sed -i 's|^group = .*|group = sail|' /etc/php/{{.PHPVersion}}/fpm/pool.d/www.conf - -# Node.js -RUN curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key \ - | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg \ - && echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_${NODE_VERSION}.x nodistro main" \ - > /etc/apt/sources.list.d/nodesource.list \ - && apt-get update \ - && apt-get install -y --no-install-recommends nodejs \ - && npm install -g bun \ - && corepack enable \ - && corepack prepare npm@latest pnpm@latest --activate \ - && apt-get clean && rm -rf /var/lib/apt/lists/* - -# Database clients -RUN curl -sS https://www.postgresql.org/media/keys/ACCC4CF8.asc \ - | gpg --dearmor | tee /etc/apt/keyrings/pgdg.gpg > /dev/null \ - && echo "deb [signed-by=/etc/apt/keyrings/pgdg.gpg] http://apt.postgresql.org/pub/repos/apt $(. /etc/os-release && echo $VERSION_CODENAME)-pgdg main" \ - > /etc/apt/sources.list.d/pgdg.list \ - && apt-get update \ - && apt-get install -y --no-install-recommends \ - $MYSQL_CLIENT postgresql-client-$POSTGRES_VERSION \ - && apt-get clean && rm -rf /var/lib/apt/lists/* - -# Composer -COPY --from=composer:latest /usr/bin/composer /usr/bin/composer - -# User setup: remove the default ubuntu user (frees GID 1000), then create sail. -# Using userdel+groupadd matches Sail's actual implementation and is simpler than -# a conditional groupmod — the ubuntu user is always present on ubuntu:24.04. -RUN userdel -r ubuntu \ - && groupadd --force -g $WWWGROUP sail \ - && useradd -ms /bin/bash --no-user-group -g $WWWGROUP -u 1337 sail \ - && git config --system --add safe.directory /var/www/html - -# Entrypoint (heredoc — single-quoted delimiter suppresses $VAR expansion in body) -RUN cat <<'SCRIPT' > /entrypoint.sh -#!/usr/bin/env bash -if [ -n "$WWWUSER" ]; then - usermod -u "$WWWUSER" sail -fi -mkdir -p /var/www/html/storage/psysh 2>/dev/null || true -if [ -d "/var/www/html/storage" ]; then - chown -R sail:sail /var/www/html/storage /var/www/html/bootstrap/cache 2>/dev/null || true - chmod -R 755 /var/www/html/storage /var/www/html/bootstrap/cache 2>/dev/null || true -fi -if [ -f "/var/www/html/.env.example" ] && [ ! -f "/var/www/html/.env" ]; then - cp /var/www/html/.env.example /var/www/html/.env - php artisan key:generate --no-interaction 2>/dev/null || true -fi -if [[ "$1" == php-fpm* ]]; then - exec "$@" -else - exec gosu sail "$@" -fi -SCRIPT -RUN chmod +x /entrypoint.sh - -WORKDIR /var/www/html -EXPOSE 9000 - -USER root -ENTRYPOINT ["/entrypoint.sh"] -CMD ["php-fpm{{.PHPVersion}}", "-F"] +FROM frank/runtime:{{.PHPVersion}}-fpm-node24-pg17 diff --git a/templates/runtimes/fpm/base.Dockerfile.tmpl b/templates/runtimes/fpm/base.Dockerfile.tmpl new file mode 100644 index 0000000..73858b5 --- /dev/null +++ b/templates/runtimes/fpm/base.Dockerfile.tmpl @@ -0,0 +1,117 @@ +# Generated by Frank — edit frank.yaml, not this file +FROM ubuntu:24.04 + +ARG WWWGROUP=1000 +ARG NODE_VERSION=24 +ARG POSTGRES_VERSION=17 +ARG MYSQL_CLIENT=mysql-client + +ENV DEBIAN_FRONTEND=noninteractive +ENV TZ=UTC + +RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone + +# Base system tools +RUN mkdir -p /etc/apt/keyrings \ + && apt-get update && apt-get install -y --no-install-recommends \ + gnupg gosu curl ca-certificates zip unzip git supervisor \ + sqlite3 libgd3 python3 dnsutils \ + librsvg2-bin fswatch ffmpeg nano \ + && rm -rf /usr/share/man /usr/share/doc \ + && apt-get clean && rm -rf /var/lib/apt/lists/* + +# PHP via ondrej PPA +RUN curl -sS 'https://keyserver.ubuntu.com/pks/lookup?op=get&search=0xb8dc7e53946656efbce4c1dd71daeaab4ad4cab6' \ + | gpg --dearmor | tee /etc/apt/keyrings/ppa_ondrej_php.gpg > /dev/null \ + && echo "deb [signed-by=/etc/apt/keyrings/ppa_ondrej_php.gpg] https://ppa.launchpadcontent.net/ondrej/php/ubuntu noble main" \ + > /etc/apt/sources.list.d/ppa_ondrej_php.list \ + && apt-get update \ + && apt-get install -y --no-install-recommends \ + php{{.PHPVersion}}-fpm php{{.PHPVersion}}-cli \ + php{{.PHPVersion}}-pgsql php{{.PHPVersion}}-sqlite3 php{{.PHPVersion}}-gd \ + php{{.PHPVersion}}-curl php{{.PHPVersion}}-mongodb php{{.PHPVersion}}-imap \ + php{{.PHPVersion}}-mysql php{{.PHPVersion}}-mbstring php{{.PHPVersion}}-xml \ + php{{.PHPVersion}}-zip php{{.PHPVersion}}-bcmath php{{.PHPVersion}}-soap \ + php{{.PHPVersion}}-intl php{{.PHPVersion}}-readline php{{.PHPVersion}}-ldap \ + php{{.PHPVersion}}-msgpack php{{.PHPVersion}}-igbinary php{{.PHPVersion}}-redis \ + php{{.PHPVersion}}-memcached php{{.PHPVersion}}-pcov php{{.PHPVersion}}-imagick \ + php{{.PHPVersion}}-xdebug \ + && rm -rf /usr/share/man /usr/share/doc \ + && apt-get clean && rm -rf /var/lib/apt/lists/* + +# Configure FPM: TCP port 9000, log to stderr, workers run as sail. +# php-fpm master runs as root (required for PID file, worker management). +# The pool handles the sail user drop — gosu is not used for php-fpm. +# Note: `daemonize = no` was removed — PHP 8.5 dropped this global directive. +# Foreground mode is enforced via the -F flag in CMD instead. +# Dev-friendly OPcache: recheck file timestamps every request (no stale bytecode) +RUN printf "opcache.revalidate_freq=0\nmemory_limit=1G\n" > /etc/php/{{.PHPVersion}}/fpm/conf.d/99-frank-dev.ini \ + && printf "opcache.revalidate_freq=0\nmemory_limit=1G\n" > /etc/php/{{.PHPVersion}}/cli/conf.d/99-frank-dev.ini + +RUN sed -i 's|error_log = .*|error_log = /dev/stderr|' /etc/php/{{.PHPVersion}}/fpm/php-fpm.conf \ + && sed -i 's|listen = .*|listen = 0.0.0.0:9000|' /etc/php/{{.PHPVersion}}/fpm/pool.d/www.conf \ + && sed -i 's|^user = .*|user = sail|' /etc/php/{{.PHPVersion}}/fpm/pool.d/www.conf \ + && sed -i 's|^group = .*|group = sail|' /etc/php/{{.PHPVersion}}/fpm/pool.d/www.conf + +# Node.js +RUN curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key \ + | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg \ + && echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_${NODE_VERSION}.x nodistro main" \ + > /etc/apt/sources.list.d/nodesource.list \ + && apt-get update \ + && apt-get install -y --no-install-recommends nodejs \ + && npm install -g bun \ + && corepack enable \ + && corepack prepare npm@latest pnpm@latest --activate \ + && apt-get clean && rm -rf /var/lib/apt/lists/* + +# Database clients +RUN curl -sS https://www.postgresql.org/media/keys/ACCC4CF8.asc \ + | gpg --dearmor | tee /etc/apt/keyrings/pgdg.gpg > /dev/null \ + && echo "deb [signed-by=/etc/apt/keyrings/pgdg.gpg] http://apt.postgresql.org/pub/repos/apt $(. /etc/os-release && echo $VERSION_CODENAME)-pgdg main" \ + > /etc/apt/sources.list.d/pgdg.list \ + && apt-get update \ + && apt-get install -y --no-install-recommends \ + $MYSQL_CLIENT postgresql-client-$POSTGRES_VERSION \ + && apt-get clean && rm -rf /var/lib/apt/lists/* + +# Composer +COPY --from=composer:latest /usr/bin/composer /usr/bin/composer + +# User setup: remove the default ubuntu user (frees GID 1000), then create sail. +# Using userdel+groupadd matches Sail's actual implementation and is simpler than +# a conditional groupmod — the ubuntu user is always present on ubuntu:24.04. +RUN userdel -r ubuntu \ + && groupadd --force -g $WWWGROUP sail \ + && useradd -ms /bin/bash --no-user-group -g $WWWGROUP -u 1337 sail \ + && git config --system --add safe.directory /var/www/html + +# Entrypoint (heredoc — single-quoted delimiter suppresses $VAR expansion in body) +RUN cat <<'SCRIPT' > /entrypoint.sh +#!/usr/bin/env bash +if [ -n "$WWWUSER" ]; then + usermod -u "$WWWUSER" sail +fi +mkdir -p /var/www/html/storage/psysh 2>/dev/null || true +if [ -d "/var/www/html/storage" ]; then + chown -R sail:sail /var/www/html/storage /var/www/html/bootstrap/cache 2>/dev/null || true + chmod -R 755 /var/www/html/storage /var/www/html/bootstrap/cache 2>/dev/null || true +fi +if [ -f "/var/www/html/.env.example" ] && [ ! -f "/var/www/html/.env" ]; then + cp /var/www/html/.env.example /var/www/html/.env + php artisan key:generate --no-interaction 2>/dev/null || true +fi +if [[ "$1" == php-fpm* ]]; then + exec "$@" +else + exec gosu sail "$@" +fi +SCRIPT +RUN chmod +x /entrypoint.sh + +WORKDIR /var/www/html +EXPOSE 9000 + +USER root +ENTRYPOINT ["/entrypoint.sh"] +CMD ["php-fpm{{.PHPVersion}}", "-F"] diff --git a/templates/runtimes/frankenphp/Dockerfile.tmpl b/templates/runtimes/frankenphp/Dockerfile.tmpl index 8fbc94f..af1f091 100644 --- a/templates/runtimes/frankenphp/Dockerfile.tmpl +++ b/templates/runtimes/frankenphp/Dockerfile.tmpl @@ -1,129 +1,5 @@ # Generated by Frank — edit frank.yaml, not this file - -# ── Builder: compile PHP extensions ────────────────────────────────────────── -FROM dunglas/frankenphp:1-php{{.PHPVersion}} AS builder - -# Dev libs required to compile extensions (not present in final image) -RUN apt-get update && apt-get install -y --no-install-recommends \ - libmemcached-dev libmagickwand-dev libkrb5-dev libreadline-dev \ - libldap-dev libsqlite3-dev libxml2-dev libzip-dev \ - libpng-dev libonig-dev libicu-dev libpq-dev \ - && rm -rf /var/lib/apt/lists/* - -# Bundled extensions (compiled into PHP, enabled here). -# Note: imap is not installed — libc-client-dev was dropped from Debian trixie (the FrankenPHP -# base OS) and the extension cannot be compiled. Use the fpm runtime if imap is required. -# Note: pdo_sqlite, sqlite3, readline, and mbstring are already statically compiled into the -# FrankenPHP binary — omitting them here avoids a mid-run source tree cleanup that breaks -# subsequent extensions. -RUN docker-php-ext-install \ - soap ldap \ - pdo_mysql pdo_pgsql pgsql exif pcntl bcmath gd intl zip - -# PECL extensions — install igbinary/msgpack first as other extensions can link against them -RUN pecl install igbinary \ - && pecl install msgpack \ - && pecl install redis \ - && pecl install memcached \ - && pecl install mongodb \ - && pecl install imagick \ - && pecl install xdebug \ - && pecl install pcov \ - && docker-php-ext-enable igbinary msgpack redis memcached mongodb imagick xdebug pcov \ - && docker-php-source delete \ - && pecl clear-cache \ - && find /usr/local/lib/php/extensions -name '*.so' -exec strip --strip-all {} + - -# ── Final image ─────────────────────────────────────────────────────────────── -FROM dunglas/frankenphp:1-php{{.PHPVersion}} AS final - -# Copy compiled extensions from builder. -# The .so files live in a versioned ABI subdirectory (e.g. no-debug-zts-20240924/). -# Both stages share the same base so the ABI path is identical — copy the whole tree. -COPY --from=builder /usr/local/lib/php/extensions/ /usr/local/lib/php/extensions/ -COPY --from=builder /usr/local/etc/php/conf.d/ /usr/local/etc/php/conf.d/ - -# Dev-friendly OPcache: recheck file timestamps every request (no stale bytecode) -RUN printf "opcache.revalidate_freq=0\nmemory_limit=1G\n" > /usr/local/etc/php/conf.d/zz-frank-dev.ini - -ARG WWWGROUP=1000 -ARG NODE_VERSION=24 -ARG POSTGRES_VERSION=17 -ARG MYSQL_CLIENT=default-mysql-client - -ENV DEBIAN_FRONTEND=noninteractive -ENV TZ=UTC - -RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone - -# Runtime libs for compiled extensions + system tools -# Runtime lib names are Debian trixie-specific (the FrankenPHP base OS as of 2026-04). -RUN mkdir -p /etc/apt/keyrings \ - && apt-get update && apt-get install -y --no-install-recommends \ - libmagickwand-7.q16-10 libmemcached11t64 libldap2 libkrb5-3 \ - libreadline8 libsqlite3-0 libicu76 libzip5 \ - gosu supervisor sqlite3 python3 dnsutils librsvg2-bin \ - fswatch ffmpeg nano git curl ca-certificates gnupg zip unzip \ - && rm -rf /usr/share/man /usr/share/doc \ - && apt-get clean && rm -rf /var/lib/apt/lists/* - -# Node.js -RUN curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key \ - | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg \ - && echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_${NODE_VERSION}.x nodistro main" \ - > /etc/apt/sources.list.d/nodesource.list \ - && apt-get update \ - && apt-get install -y --no-install-recommends nodejs \ - && npm install -g bun \ - && corepack enable \ - && corepack prepare npm@latest pnpm@latest --activate \ - && apt-get clean && rm -rf /var/lib/apt/lists/* - -# Database clients (pgdg repo — detect Debian codename dynamically) -RUN curl -sS https://www.postgresql.org/media/keys/ACCC4CF8.asc \ - | gpg --dearmor | tee /etc/apt/keyrings/pgdg.gpg > /dev/null \ - && echo "deb [signed-by=/etc/apt/keyrings/pgdg.gpg] http://apt.postgresql.org/pub/repos/apt $(. /etc/os-release && echo $VERSION_CODENAME)-pgdg main" \ - > /etc/apt/sources.list.d/pgdg.list \ - && apt-get update \ - && apt-get install -y --no-install-recommends \ - $MYSQL_CLIENT postgresql-client-$POSTGRES_VERSION \ - && apt-get clean && rm -rf /var/lib/apt/lists/* - -# Composer -COPY --from=composer:latest /usr/bin/composer /usr/bin/composer - -# User setup -RUN groupadd --force -g $WWWGROUP sail \ - && useradd -ms /bin/bash --no-user-group -g $WWWGROUP -u 1337 sail \ - && git config --system --add safe.directory /var/www/html +FROM frank/runtime:{{.PHPVersion}}-frankenphp-node24-pg17 # Copy Caddyfile (generated at .frank/Caddyfile; build context is project root) COPY .frank/Caddyfile /etc/caddy/Caddyfile - -# Entrypoint (heredoc — single-quoted delimiter suppresses $VAR expansion in body) -RUN cat <<'SCRIPT' > /entrypoint.sh -#!/usr/bin/env bash -if [ -n "$WWWUSER" ]; then - usermod -u "$WWWUSER" sail -fi -mkdir -p /var/www/html/storage/psysh 2>/dev/null || true -if [ -d "/var/www/html/storage" ]; then - chown -R sail:sail /var/www/html/storage /var/www/html/bootstrap/cache 2>/dev/null || true - chmod -R 755 /var/www/html/storage /var/www/html/bootstrap/cache 2>/dev/null || true -fi -if [ -f "/var/www/html/.env.example" ] && [ ! -f "/var/www/html/.env" ]; then - cp /var/www/html/.env.example /var/www/html/.env - php artisan key:generate --no-interaction 2>/dev/null || true -fi -mkdir -p /config/caddy /data/caddy -chown -R sail:sail /config/caddy /data/caddy -exec gosu sail "$@" -SCRIPT -RUN chmod +x /entrypoint.sh - -WORKDIR /var/www/html -EXPOSE 80 443 5173 - -USER root -ENTRYPOINT ["/entrypoint.sh"] -CMD ["frankenphp", "run", "--config", "/etc/caddy/Caddyfile"] diff --git a/templates/runtimes/frankenphp/base.Dockerfile.tmpl b/templates/runtimes/frankenphp/base.Dockerfile.tmpl new file mode 100644 index 0000000..230ced6 --- /dev/null +++ b/templates/runtimes/frankenphp/base.Dockerfile.tmpl @@ -0,0 +1,126 @@ +# Generated by Frank — edit frank.yaml, not this file + +# ── Builder: compile PHP extensions ────────────────────────────────────────── +FROM dunglas/frankenphp:1-php{{.PHPVersion}} AS builder + +# Dev libs required to compile extensions (not present in final image) +RUN apt-get update && apt-get install -y --no-install-recommends \ + libmemcached-dev libmagickwand-dev libkrb5-dev libreadline-dev \ + libldap-dev libsqlite3-dev libxml2-dev libzip-dev \ + libpng-dev libonig-dev libicu-dev libpq-dev \ + && rm -rf /var/lib/apt/lists/* + +# Bundled extensions (compiled into PHP, enabled here). +# Note: imap is not installed — libc-client-dev was dropped from Debian trixie (the FrankenPHP +# base OS) and the extension cannot be compiled. Use the fpm runtime if imap is required. +# Note: pdo_sqlite, sqlite3, readline, and mbstring are already statically compiled into the +# FrankenPHP binary — omitting them here avoids a mid-run source tree cleanup that breaks +# subsequent extensions. +RUN docker-php-ext-install \ + soap ldap \ + pdo_mysql pdo_pgsql pgsql exif pcntl bcmath gd intl zip + +# PECL extensions — install igbinary/msgpack first as other extensions can link against them +RUN pecl install igbinary \ + && pecl install msgpack \ + && pecl install redis \ + && pecl install memcached \ + && pecl install mongodb \ + && pecl install imagick \ + && pecl install xdebug \ + && pecl install pcov \ + && docker-php-ext-enable igbinary msgpack redis memcached mongodb imagick xdebug pcov \ + && docker-php-source delete \ + && pecl clear-cache \ + && find /usr/local/lib/php/extensions -name '*.so' -exec strip --strip-all {} + + +# ── Final image ─────────────────────────────────────────────────────────────── +FROM dunglas/frankenphp:1-php{{.PHPVersion}} AS final + +# Copy compiled extensions from builder. +# The .so files live in a versioned ABI subdirectory (e.g. no-debug-zts-20240924/). +# Both stages share the same base so the ABI path is identical — copy the whole tree. +COPY --from=builder /usr/local/lib/php/extensions/ /usr/local/lib/php/extensions/ +COPY --from=builder /usr/local/etc/php/conf.d/ /usr/local/etc/php/conf.d/ + +# Dev-friendly OPcache: recheck file timestamps every request (no stale bytecode) +RUN printf "opcache.revalidate_freq=0\nmemory_limit=1G\n" > /usr/local/etc/php/conf.d/zz-frank-dev.ini + +ARG WWWGROUP=1000 +ARG NODE_VERSION=24 +ARG POSTGRES_VERSION=17 +ARG MYSQL_CLIENT=default-mysql-client + +ENV DEBIAN_FRONTEND=noninteractive +ENV TZ=UTC + +RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone + +# Runtime libs for compiled extensions + system tools +# Runtime lib names are Debian trixie-specific (the FrankenPHP base OS as of 2026-04). +RUN mkdir -p /etc/apt/keyrings \ + && apt-get update && apt-get install -y --no-install-recommends \ + libmagickwand-7.q16-10 libmemcached11t64 libldap2 libkrb5-3 \ + libreadline8 libsqlite3-0 libicu76 libzip5 \ + gosu supervisor sqlite3 python3 dnsutils librsvg2-bin \ + fswatch ffmpeg nano git curl ca-certificates gnupg zip unzip \ + && rm -rf /usr/share/man /usr/share/doc \ + && apt-get clean && rm -rf /var/lib/apt/lists/* + +# Node.js +RUN curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key \ + | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg \ + && echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_${NODE_VERSION}.x nodistro main" \ + > /etc/apt/sources.list.d/nodesource.list \ + && apt-get update \ + && apt-get install -y --no-install-recommends nodejs \ + && npm install -g bun \ + && corepack enable \ + && corepack prepare npm@latest pnpm@latest --activate \ + && apt-get clean && rm -rf /var/lib/apt/lists/* + +# Database clients (pgdg repo — detect Debian codename dynamically) +RUN curl -sS https://www.postgresql.org/media/keys/ACCC4CF8.asc \ + | gpg --dearmor | tee /etc/apt/keyrings/pgdg.gpg > /dev/null \ + && echo "deb [signed-by=/etc/apt/keyrings/pgdg.gpg] http://apt.postgresql.org/pub/repos/apt $(. /etc/os-release && echo $VERSION_CODENAME)-pgdg main" \ + > /etc/apt/sources.list.d/pgdg.list \ + && apt-get update \ + && apt-get install -y --no-install-recommends \ + $MYSQL_CLIENT postgresql-client-$POSTGRES_VERSION \ + && apt-get clean && rm -rf /var/lib/apt/lists/* + +# Composer +COPY --from=composer:latest /usr/bin/composer /usr/bin/composer + +# User setup +RUN groupadd --force -g $WWWGROUP sail \ + && useradd -ms /bin/bash --no-user-group -g $WWWGROUP -u 1337 sail \ + && git config --system --add safe.directory /var/www/html + +# Entrypoint (heredoc — single-quoted delimiter suppresses $VAR expansion in body) +RUN cat <<'SCRIPT' > /entrypoint.sh +#!/usr/bin/env bash +if [ -n "$WWWUSER" ]; then + usermod -u "$WWWUSER" sail +fi +mkdir -p /var/www/html/storage/psysh 2>/dev/null || true +if [ -d "/var/www/html/storage" ]; then + chown -R sail:sail /var/www/html/storage /var/www/html/bootstrap/cache 2>/dev/null || true + chmod -R 755 /var/www/html/storage /var/www/html/bootstrap/cache 2>/dev/null || true +fi +if [ -f "/var/www/html/.env.example" ] && [ ! -f "/var/www/html/.env" ]; then + cp /var/www/html/.env.example /var/www/html/.env + php artisan key:generate --no-interaction 2>/dev/null || true +fi +mkdir -p /config/caddy /data/caddy +chown -R sail:sail /config/caddy /data/caddy +exec gosu sail "$@" +SCRIPT +RUN chmod +x /entrypoint.sh + +WORKDIR /var/www/html +EXPOSE 80 443 5173 + +USER root +ENTRYPOINT ["/entrypoint.sh"] +CMD ["frankenphp", "run", "--config", "/etc/caddy/Caddyfile"]