diff --git a/packages/aloft/composer.json b/packages/aloft/composer.json new file mode 100644 index 000000000..686001f82 --- /dev/null +++ b/packages/aloft/composer.json @@ -0,0 +1,22 @@ +{ + "name": "tempest/aloft", + "description": "Development and Production webserver Dockerfiles and utilities for TempestPHP.", + "require": { + "php": "^8.5", + "tempest/core": "3.x-dev", + "tempest/support": "3.x-dev" + }, + "require-dev": {}, + "autoload": { + "psr-4": { + "Tempest\\Aloft\\": "src" + } + }, + "autoload-dev": { + "psr-4": { + "Tempest\\Aloft\\Tests\\": "tests" + } + }, + "license": "MIT", + "minimum-stability": "dev" +} \ No newline at end of file diff --git a/packages/aloft/docker/.dockerignore b/packages/aloft/docker/.dockerignore new file mode 100644 index 000000000..27bf69a9d --- /dev/null +++ b/packages/aloft/docker/.dockerignore @@ -0,0 +1,66 @@ +# ----------------------------------------------------------------------- +# Git +# ----------------------------------------------------------------------- +.git +.gitignore +.gitattributes + +# ----------------------------------------------------------------------- +# Docker build tooling (never needed inside the image) +# ----------------------------------------------------------------------- +Dockerfile +docker-bake.hcl +.dockerignore + +# ----------------------------------------------------------------------- +# CI / tooling config +# ----------------------------------------------------------------------- +.github +.gitlab-ci.yml +.travis.yml +.editorconfig +.env* +!.env.example + +# ----------------------------------------------------------------------- +# PHP tooling and dev dependencies +# ----------------------------------------------------------------------- +/vendor +composer.lock + +# ----------------------------------------------------------------------- +# Node (if any frontend tooling is present) +# ----------------------------------------------------------------------- +node_modules +npm-debug.log +yarn-error.log + +# ----------------------------------------------------------------------- +# Tests +# ----------------------------------------------------------------------- +/tests +/test +phpunit.xml +phpunit.xml.dist +.phpunit.result.cache +.phpunit.cache + +# ----------------------------------------------------------------------- +# Static analysis and code style +# ----------------------------------------------------------------------- +.php-cs-fixer.cache +.php-cs-fixer.php +phpstan.neon +phpstan.neon.dist +psalm.xml +psalm.xml.dist + +# ----------------------------------------------------------------------- +# IDE and OS noise +# ----------------------------------------------------------------------- +.idea +.vscode +*.swp +*.swo +.DS_Store +Thumbs.db diff --git a/packages/aloft/docker/Caddyfile.noworker b/packages/aloft/docker/Caddyfile.noworker new file mode 100644 index 000000000..cced0a3ca --- /dev/null +++ b/packages/aloft/docker/Caddyfile.noworker @@ -0,0 +1,56 @@ +# The Caddyfile is an easy way to configure FrankenPHP and the Caddy web server. +# This Caddyfile is provided by the TempestPHP Framework with some added options for convenience. +# +# https://github.com/tempestphp/tempest-framework +# https://frankenphp.dev/docs/config +# https://caddyserver.com/docs/caddyfile + +{ + skip_install_trust + {$CADDY_DEFAULT_BIND} + http_port {$CADDY_HTTP_PORT:8000} + https_port {$CADDY_HTTPS_PORT:8443} + + {$CADDY_GLOBAL_OPTIONS} + + frankenphp { + {$FRANKENPHP_CONFIG} + } +} + +{$CADDY_EXTRA_CONFIG} + +{$CADDY_SERVER_NAME:localhost}:{$CADDY_HTTP_PORT:8000}, {$CADDY_SERVER_NAME:localhost}:{$CADDY_HTTPS_PORT:8443} { + #log { + # # Redact the authorization query parameter that can be set by Mercure + # format filter { + # request>uri query { + # replace authorization REDACTED + # } + # } + #} + + root {$CADDY_SERVER_ROOT:public/} + encode zstd br gzip + + # Uncomment the following lines to enable Mercure and Vulcain modules + #mercure { + # # Publisher JWT key + # publisher_jwt {env.MERCURE_PUBLISHER_JWT_KEY} {env.MERCURE_PUBLISHER_JWT_ALG} + # # Subscriber JWT key + # subscriber_jwt {env.MERCURE_SUBSCRIBER_JWT_KEY} {env.MERCURE_SUBSCRIBER_JWT_ALG} + # # Allow anonymous subscribers (double-check that it's what you want) + # anonymous + # # Enable the subscription API (double-check that it's what you want) + # subscriptions + # # Extra directives + # {$MERCURE_EXTRA_DIRECTIVES} + #} + #vulcain + + {$CADDY_SERVER_EXTRA_DIRECTIVES} + + php_server { + #worker /path/to/your/worker.php + } +} \ No newline at end of file diff --git a/packages/aloft/docker/Dockerfile b/packages/aloft/docker/Dockerfile new file mode 100644 index 000000000..496a80a78 --- /dev/null +++ b/packages/aloft/docker/Dockerfile @@ -0,0 +1,82 @@ +# Build-time arguments — set by bake.hcl, can be overridden individually +ARG FRANKENPHP_VERSION=1.11.2 +ARG PHP_VERSION=8.5.3 +ARG BASE_IMAGE=dunglas/frankenphp:${FRANKENPHP_VERSION}-php${PHP_VERSION} +# Controls which distroless variant is used as the runner base. +# Valid values mirror gcr.io/distroless/cc-debian13 tags: nonroot | debug-nonroot +ARG DISTROLESS_VARIANT=nonroot + +FROM ${BASE_IMAGE} AS frankenphp + +RUN curl -sSLf \ + -o /usr/local/bin/install-php-extensions \ + https://github.com/mlocati/docker-php-extension-installer/releases/latest/download/install-php-extensions && \ + chmod +x /usr/local/bin/install-php-extensions + +# Install additional extensions here and they are carried forward into the final image +RUN install-php-extensions \ + gd \ + intl \ + mysqli \ + pcntl \ + pdo_mysql \ + pdo_pgsql \ + pdo_sqlite \ + redis \ + zip + +# Install pax-utils for lddtree +RUN apt-get update && apt-get install -y --no-install-recommends pax-utils && rm -rf /var/lib/apt/lists/* + +# Copy and run the staging script which collects all runtime files into +# /tmp/staging with full paths preserved +COPY stage-files.sh /tmp/stage-files.sh +RUN chmod +x /tmp/stage-files.sh && /tmp/stage-files.sh + +# Re-declare so it is in scope for this stage (ARGs declared before the first +# FROM are not automatically visible inside stages) +ARG DISTROLESS_VARIANT=nonroot + +# Grab distroless image — variant is controlled by DISTROLESS_VARIANT ARG +FROM gcr.io/distroless/cc-debian13:${DISTROLESS_VARIANT} AS common + +# See https://caddyserver.com/docs/conventions#file-locations for details +ENV XDG_CONFIG_HOME=/config +ENV XDG_DATA_HOME=/data + +# Required from frankenphp +ENV GODEBUG=cgocheck=0 + +LABEL org.opencontainers.image.title=TempestPHP +LABEL org.opencontainers.image.description="The framework that gets out of your way" +LABEL org.opencontainers.image.url=https://tempestphp.com +LABEL org.opencontainers.image.source=https://github.com/tempestphp/tempest-framework/ +LABEL org.opencontainers.image.licenses=MIT +LABEL org.opencontainers.image.vendor="Brent Roose and contributors" + +# All libs, binaries and config collected by stage-files.sh into /tmp/staging. +# Everything is normalised to usr/ paths so this single COPY is safe against +# the distroless /lib -> usr/lib symlink. +COPY --from=frankenphp /tmp/staging/ / + +# App directories need specific ownership — distroless has no chown so we +# use the COPY --chown flag. These overwrite the copies already in /tmp/staging. +# From the gcr container, the nonroot user is uid 1002, with gid 1000 +COPY --from=frankenphp --chown=1002:1000 /app /app +COPY --from=frankenphp --chown=1002:1000 /config /config +COPY --from=frankenphp --chown=1002:1000 /data /data +COPY --from=frankenphp --chown=1002:1000 /etc/frankenphp /etc/frankenphp + +COPY Caddyfile.noworker /etc/frankenphp/Caddyfile + +WORKDIR /app + +EXPOSE 8000 +EXPOSE 8443 +EXPOSE 8443/udp +EXPOSE 2019 + +USER nonroot + +CMD ["frankenphp", "run", "--config", "/etc/frankenphp/Caddyfile", "--adapter", "caddyfile"] +HEALTHCHECK CMD curl -f http://localhost:2019/metrics || exit 1 diff --git a/packages/aloft/docker/docker-bake.hcl b/packages/aloft/docker/docker-bake.hcl new file mode 100644 index 000000000..68c29dd68 --- /dev/null +++ b/packages/aloft/docker/docker-bake.hcl @@ -0,0 +1,97 @@ +# ----------------------------------------------------------------------- +# Variables — override via env vars or --set on the CLI +# e.g. FRANKENPHP_VERSION=1.12.0 docker buildx bake +# ----------------------------------------------------------------------- + +variable "FRANKENPHP_VERSION" { + description = "FrankenPHP release version" + default = "1.11.2" +} + +variable "PHP_VERSION" { + description = "PHP release version" + default = "8.5.3" +} + +variable "PUSH" { + default = "0" +} + +variable "REGISTRY" { + default = "" +} + +# Derived — prepends registry if set, otherwise just the image name +variable "IMAGE" { + default = REGISTRY != "" ? "${REGISTRY}/aloft" : "tempestphp/aloft" +} + +# Derived values — not meant to be overridden directly +variable "BASE_IMAGE" { + default = "dunglas/frankenphp:${FRANKENPHP_VERSION}-php${PHP_VERSION}" +} + +variable "VERSION_TAG" { + default = "${FRANKENPHP_VERSION}-${PHP_VERSION}" +} + +# ----------------------------------------------------------------------- +# Shared platform target — all runner targets inherit from this +# ----------------------------------------------------------------------- + +target "_common" { + dockerfile = "Dockerfile" + context = "." + platforms = PUSH == "1" ? ["linux/amd64", "linux/arm64"] : [] + output = PUSH == "1" ? ["type=registry"] : ["type=docker"] + args = { + FRANKENPHP_VERSION = FRANKENPHP_VERSION + PHP_VERSION = PHP_VERSION + BASE_IMAGE = BASE_IMAGE + } +} + +# ----------------------------------------------------------------------- +# latest-nonroot — runner is gcr.io/distroless/cc-debian13:nonroot +# Tags: tempestphp/aloft:latest-nonroot +# tempestphp/aloft:1.11.2-8.5.3-nonroot +# ----------------------------------------------------------------------- + +target "latest-nonroot" { + inherits = ["_common"] + target = "common" + args = { + DISTROLESS_VARIANT = "nonroot" + } + tags = [ + "${IMAGE}:latest-nonroot", + "${IMAGE}:${VERSION_TAG}-nonroot", + ] +} + +# ----------------------------------------------------------------------- +# debug-nonroot — runner is gcr.io/distroless/cc-debian13:debug-nonroot +# Includes busybox shell for exec access while still running as nonroot. +# Tags: tempestphp/aloft:debug-nonroot +# tempestphp/aloft:1.11.2-8.5.3-debug-nonroot +# ----------------------------------------------------------------------- + +target "debug-nonroot" { + inherits = ["_common"] + target = "common" + args = { + DISTROLESS_VARIANT = "debug-nonroot" + } + tags = [ + "${IMAGE}:debug-nonroot", + "${IMAGE}:${VERSION_TAG}-debug-nonroot", + ] +} + +# ----------------------------------------------------------------------- +# Default group — builds both variants in parallel +# ----------------------------------------------------------------------- + +group "default" { + targets = ["latest-nonroot", "debug-nonroot"] +} diff --git a/packages/aloft/docker/stage-files.sh b/packages/aloft/docker/stage-files.sh new file mode 100644 index 000000000..8d602b400 --- /dev/null +++ b/packages/aloft/docker/stage-files.sh @@ -0,0 +1,177 @@ +#!/usr/bin/env bash +# stage-files.sh +# +# Runs inside the frankenphp intermediate stage during docker build. +# Collects every runtime file needed in the final distroless image into +# /tmp/staging, preserving full filesystem paths so the Dockerfile can +# use a single COPY --from=frankenphp /tmp/staging/ / +# +# Steps: +# 1. lddtree --copy-to-tree for extensions, php, frankenphp +# 2. Resolve soname symlinks → copy versioned targets beside them +# 3. Normalise lib/ and lib64/ → usr/lib/ and usr/lib64/ (distroless symlink collision) +# 4. Explicit copies for dlopen'd plugins lddtree cannot discover +# 5. PHP config, ld config, mime types, frankenphp runtime files + +set -euo pipefail + +STAGING=/tmp/staging +ARCH=$(uname -m | sed 's/x86_64/x86_64-linux-gnu/;s/aarch64/aarch64-linux-gnu/') +DEB_DIR=/tmp/debs +DEB_EXTRACT=/tmp/deb-extract + +mkdir -p "${STAGING}" "${DEB_DIR}" + +# --------------------------------------------------------------------------- +# 1. lddtree — ELF dependency tree for all extensions + binaries +# +# This gives us the soname symlinks and the correct set of libraries needed. +# The versioned files behind those symlinks are fetched via apt in step 2. +# --------------------------------------------------------------------------- + +find /usr/local/lib/php/extensions/ -name '*.so' -print0 \ + | xargs -0 -r lddtree --copy-to-tree "${STAGING}" 2>/dev/null || true + +lddtree --copy-to-tree "${STAGING}" /usr/local/bin/php 2>/dev/null || true +lddtree --copy-to-tree "${STAGING}" /usr/local/bin/frankenphp 2>/dev/null || true +lddtree --copy-to-tree "${STAGING}" /usr/local/lib/libphp.so 2>/dev/null || true + +# Normalise lib/ and lib64/ → usr/lib/ and usr/lib64/ before package resolution. +# lddtree follows the /lib -> usr/lib and /lib64 -> usr/lib64 symlinks on the +# source system and may write files under staging/lib/ or staging/lib64/. +# distroless has both as symlinks so COPY / would collide — merge into usr/. +if [ -d "${STAGING}/lib" ]; then + mkdir -p "${STAGING}/usr/lib" + cp -a "${STAGING}/lib/." "${STAGING}/usr/lib/" + rm -rf "${STAGING}/lib" +fi + +if [ -d "${STAGING}/lib64" ]; then + mkdir -p "${STAGING}/usr/lib64" + cp -a "${STAGING}/lib64/." "${STAGING}/usr/lib64/" + rm -rf "${STAGING}/lib64" +fi + +# --------------------------------------------------------------------------- +# 2. apt-get download + dpkg-deb extract +# +# lddtree creates relative soname symlinks inside staging, so the versioned +# file each symlink points to only exists on the source system, not in +# staging. Rather than trying to resolve paths manually, we find which +# Debian package owns each collected .so file, download that package, and +# extract it — giving us both the soname symlink and the versioned file +# with no path gymnastics required. +# --------------------------------------------------------------------------- + +# Collect owning packages for all .so files lddtree placed in staging +find "${STAGING}/usr/lib" -name "*.so*" 2>/dev/null \ + | sed "s|${STAGING}||" \ + | xargs -r dpkg -S 2>/dev/null \ + | cut -d: -f1 \ + | sort -u > /tmp/pkgs-needed.txt + +# Also add explicit packages for dlopen'd libs lddtree can't discover +# (kerberos plugins, sasl plugins, libjansson for FrankenPHP admin API) +cat >> /tmp/pkgs-needed.txt << 'EOF' +libjansson4 +libkrb5-3 +libsasl2-2 +EOF + +sort -u /tmp/pkgs-needed.txt > /tmp/pkgs-deduped.txt + +# Download debs (failures are non-fatal — some names may vary by suite) +cd "${DEB_DIR}" +while read -r pkg; do + apt-get download "${pkg}" 2>/dev/null || true +done < /tmp/pkgs-deduped.txt + +# Extract only usr/lib from each deb into staging +for deb in "${DEB_DIR}"/*.deb; do + [ -f "${deb}" ] || continue + rm -rf "${DEB_EXTRACT}" + mkdir -p "${DEB_EXTRACT}" + dpkg-deb -x "${deb}" "${DEB_EXTRACT}" + if [ -d "${DEB_EXTRACT}/usr/lib" ]; then + cp -a "${DEB_EXTRACT}/usr/lib/." "${STAGING}/usr/lib/" + fi +done +rm -rf "${DEB_EXTRACT}" "${DEB_DIR}" + +# --------------------------------------------------------------------------- +# 3. dlopen'd plugin directories +# +# These are subdirectories loaded at runtime via dlopen() — dpkg-deb extract +# above handles the files, but ensure the directories land correctly. +# --------------------------------------------------------------------------- + +# Kerberos pre-authentication plugins +if [ -d "/usr/lib/${ARCH}/krb5" ]; then + cp -a "/usr/lib/${ARCH}/krb5" "${STAGING}/usr/lib/${ARCH}/" +fi + +# SASL mechanism plugins +if [ -d "/usr/lib/${ARCH}/sasl2" ]; then + cp -a "/usr/lib/${ARCH}/sasl2" "${STAGING}/usr/lib/${ARCH}/" + mkdir -p "${STAGING}/usr/lib/sasl2" + cp -a "/usr/lib/${ARCH}/sasl2/." "${STAGING}/usr/lib/sasl2/" +fi + +# libjansson — dlopen'd by FrankenPHP admin API, not in any DT_NEEDED chain. +# Copy directly from the source filesystem; apt-get download is unreliable +# here since libjansson4 may not be registered in the image's dpkg database. +find "/usr/lib/${ARCH}" -maxdepth 1 -name 'libjansson.so*' | while read -r f; do + dest="${STAGING}/usr/lib/${ARCH}/$(basename "${f}")" + [ -e "${dest}" ] && continue + cp -a "${f}" "${dest}" +done + +# --------------------------------------------------------------------------- +# 5. PHP runtime files +# --------------------------------------------------------------------------- + +# Main PHP shared library (already collected via lddtree above, belt+suspenders) +mkdir -p "${STAGING}/usr/local/lib" +[ -f "${STAGING}/usr/local/lib/libphp.so" ] || \ + cp /usr/local/lib/libphp.so "${STAGING}/usr/local/lib/" + +# libwatcher (FrankenPHP file watcher) +cp -a /usr/local/lib/libwatcher* "${STAGING}/usr/local/lib/" + +# PHP extension .so files (already under staging from lddtree but confirm) +mkdir -p "${STAGING}/usr/local/lib/php/extensions" +cp -a /usr/local/lib/php/extensions/. "${STAGING}/usr/local/lib/php/extensions/" + +# PHP configuration +mkdir -p "${STAGING}/usr/local/etc/php/conf.d" +cp -a /usr/local/etc/php/. "${STAGING}/usr/local/etc/php/" + +# --------------------------------------------------------------------------- +# 6. Dynamic linker config +# --------------------------------------------------------------------------- + +mkdir -p "${STAGING}/etc/ld.so.conf.d" +[ -f /etc/ld.so.conf ] && cp /etc/ld.so.conf "${STAGING}/etc/" +[ -f /etc/ld.so.cache ] && cp /etc/ld.so.cache "${STAGING}/etc/" +cp -a /etc/ld.so.conf.d/. "${STAGING}/etc/ld.so.conf.d/" + +# --------------------------------------------------------------------------- +# 7. Misc runtime config +# --------------------------------------------------------------------------- + +# MIME types (FrankenPHP/Caddy uses this for content-type detection) +[ -f /etc/mime.types ] && cp /etc/mime.types "${STAGING}/etc/" + + +FILE_COUNT=$(find "${STAGING}" -type f | wc -l) +LINK_COUNT=$(find "${STAGING}" -type l | wc -l) +echo "✓ Staging complete — ${FILE_COUNT} files, ${LINK_COUNT} symlinks collected" + +# Fail fast if any symlinks are dangling — a versioned lib target missing +# from staging means the final image would have broken .so links at runtime +DANGLING=$(find "${STAGING}" -type l ! -exec test -e {} \; -print) +if [ -n "${DANGLING}" ]; then + echo "✗ Dangling symlinks found in staging:" >&2 + echo "${DANGLING}" >&2 + exit 1 +fi diff --git a/packages/aloft/docker/usage-notes.md b/packages/aloft/docker/usage-notes.md new file mode 100644 index 000000000..f16eb13d9 --- /dev/null +++ b/packages/aloft/docker/usage-notes.md @@ -0,0 +1,47 @@ +# Usage notes + +NB: This file is NOT FINAL and will be removed from the PR this is just so that the image can be tested in development + +## Build locally + +cd into the folder +```bash +docker buildx bake +``` +After build you should have something similar, disk usage will vary slightly based on your arch (this is aarch64 on macos m1 17.x). +```bash +docker % docker image ls + +IMAGE ID DISK USAGE CONTENT SIZE EXTRA +gcr.io/distroless/cc-debian13:debug-nonroot f60c5a64690d 38.5MB 0B +gcr.io/distroless/cc-debian13:nonroot 5c5da034ed6e 37.2MB 0B +tempestphp/aloft:1.11.2-8.5.3-debug-nonroot 1bf5840bddb2 219MB 0B +tempestphp/aloft:1.11.2-8.5.3-nonroot a5039ddf9345 218MB 0B +tempestphp/aloft:debug-nonroot 1bf5840bddb2 219MB 0B +tempestphp/aloft:latest-nonroot a5039ddf9345 218MB 0B +``` + +## Push to a registry + +cd into the folder +```bash +PUSH=1 REGISTRY=registry.url/tempestphp docker buildx bake +``` + +View on your registry, but should create the four tags and two images. + +e.g. I pushed to my private gitea + +tempestphp/aloft/versions: + +1.11.2-8.5.3-debug-nonroot +Published 16 hours ago by iamdadmin + +latest-nonroot +Published 16 hours ago by iamdadmin + +1.11.2-8.5.3-nonroot +Published 16 hours ago by iamdadmin + +debug-nonroot +Published 16 hours ago by iamdadmin diff --git a/packages/aloft/src/Commands/RequireCommand.php b/packages/aloft/src/Commands/RequireCommand.php new file mode 100644 index 000000000..e69de29bb diff --git a/packages/aloft/tests/tests.txt b/packages/aloft/tests/tests.txt new file mode 100644 index 000000000..e1a7747d7 --- /dev/null +++ b/packages/aloft/tests/tests.txt @@ -0,0 +1 @@ +Tests are TBC as it may not be appropriate to use PHPUnit to test here \ No newline at end of file diff --git a/packages/router/src/Commands/ServeCommand.php b/packages/router/src/Commands/ServeCommand.php index 76bf4b01d..52468490e 100644 --- a/packages/router/src/Commands/ServeCommand.php +++ b/packages/router/src/Commands/ServeCommand.php @@ -4,9 +4,13 @@ namespace Tempest\Router\Commands; +use Tempest\Console\ConsoleArgument; use Tempest\Console\ConsoleCommand; use Tempest\Intl\Number; use Tempest\Support\Str; +use Tempest\Support\Str\ImmutableString; + +use function Tempest\root_path; if (class_exists(\Tempest\Console\ConsoleCommand::class)) { final readonly class ServeCommand @@ -15,19 +19,56 @@ name: 'serve', description: 'Starts a PHP development server', )] - public function __invoke(string $host = '127.0.0.1', int $port = 8000, string $publicDir = 'public/'): void - { - $routerFile = __DIR__ . '/router.php'; + public function __invoke( + string $host = '127.0.0.1', + int $port = 8000, + string $publicDir = 'public/', + #[ConsoleArgument( + description: 'Run via Aloft (Docker) instead of the built-in PHP dev server', + aliases: ['--aloft'], + )] + bool $aloft = false, + ): void { + $resolvedHost = new ImmutableString($host); + $resolvedPort = $port; + $resolvedPublicDir = new ImmutableString($publicDir); - if (Str\contains($host, ':')) { - [$host, $overriddenPort] = explode(':', $host, limit: 2); + if ($resolvedHost->contains(':')) { + [$rawHost, $overriddenPort] = explode(':', $resolvedHost->toString(), limit: 2); - $host = $host ?: '127.0.0.1'; + $resolvedHost = new ImmutableString($rawHost ?: '127.0.0.1'); + $resolvedPort = (int) Number\parse($overriddenPort, default: $port); + } - $port = Number\parse($overriddenPort, default: $port); + if ($aloft) { + $this->serveAloft($resolvedHost, $resolvedPort, $resolvedPublicDir); + } else { + $this->serveBuiltin($resolvedHost, $resolvedPort, $resolvedPublicDir); } + } + + private function serveBuiltin(ImmutableString $host, int $port, ImmutableString $publicDir): void + { + $routerFile = new ImmutableString(__DIR__ . '/router.php'); passthru("php -S {$host}:{$port} -t {$publicDir} {$routerFile}"); } + + private function serveAloft(ImmutableString $host, int $port, ImmutableString $publicDir): void + { + passthru( + "docker run --rm -it -p 80:8000 -p 443:8443 -p 443:8443/udp \ + -v " + . root_path() + . ":/app \ + -v " + . root_path('.tempest/aloft/data') + . ":/data \ + -v " + . root_path('.tempest/aloft/config') + . ":/config \ + tempestphp/aloft:latest-nonroot", + ); + } } }