build: M1 Dockerized development stack with PHP skeleton, config/logging, Tailwind assets, and wrappers

Implements MeshVault M1 (issues #6–11):

- Four-service Docker Compose stack (nginx, PHP-FPM 8.3, PostgreSQL 17, Node assets)
- Slim 4 + PHP-DI routing with escaped PHP views and production/dev error handling
- Immutable validated AppConfig with redacted diagnostics
- JSON stdout Monolog logging with job-scoped context
- Tailwind CSS v4 deterministic locked builds with template scanning
- POSIX and PowerShell dev wrappers (up/down/test/lint/assets/watch)
- Startup config validation fails before FPM serves as unprivileged www-data
- migrate/seed explicitly unavailable until M2 (#12); #10 remains open

Verified via clean-start acceptance on remote host (keith@192.168.2.23).
This commit is contained in:
Keith Solomon committed 2026-10-11 16:45:45 -05:00
1 parent c2747a5762
commit d3e8cf8fae
39 files changed
+5291 -38

No files matched your search

+14
View File
@@ -0,0 +1,14 @@
# Explicit build inputs only: exclude secrets, collections and host dependencies.
**
!docker/
!docker/php/
!docker/php/**
!composer.json
!composer.lock
!bin/
!bin/**
!docker/assets/
!docker/assets/**
!package.json
!package-lock.json
+30
View File
@@ -0,0 +1,30 @@
# Copy to ignored .env and choose a private password before starting.
COMPOSE_PROJECT_NAME=meshvault-m1
MESHVAULT_HTTP_HOST=127.0.0.1
MESHVAULT_HTTP_PORT=8090
MESHVAULT_DB_NAME=meshvault
MESHVAULT_DB_USER=meshvault
MESHVAULT_DB_PASSWORD=
# Dedicated managed destination only, never an original collection.
# Host directory must already exist and be writable by container UID/GID 33.
MESHVAULT_LIBRARY_HOST_DIR=./library
# Absolute in-container path, outside /var/www/html; keep both paths distinct.
MESHVAULT_LIBRARY_DIR=/var/lib/meshvault/library
# Application configuration (Task2 library; startup/Compose wiring is Task3).
# Native PHP does NOT load this file: export variables in the shell or pass an
# explicit string array to AppConfig::fromEnvironment(). Never commit secrets.
# Defaults apply only when a key is absent; explicitly empty values are invalid.
# Allowed: development, test, production. Default: production.
MESHVAULT_ENV=development
# PSR-3: debug, info, notice, warning, error, critical, alert, emergency. Default: info.
MESHVAULT_LOG_LEVEL=info
# Required DB_HOST/DB_NAME/DB_USER/DB_PASSWORD and LIBRARY_DIR have no defaults.
# DB host/name/user must have no controls or surrounding whitespace.
# Password is preserved verbatim; blank/whitespace-only or NUL is invalid.
MESHVAULT_DB_HOST=db
# Decimal digits 1..65535; default: 5432.
MESHVAULT_DB_PORT=5432
# LIBRARY_DIR must be absolute, existing and writable by the actual runtime user.
# Validation never creates/chmods directories or opens a database connection.
+5
View File
@@ -0,0 +1,5 @@
# Enforce LF line endings for all text files to avoid container CRLF issues.
* text=auto eol=lf
# Ensure shell entrypoints remain executable across Windows checkouts.
docker/php/*.sh text eol=lf
+27 -12
View File
@@ -1,8 +1,26 @@
# Contributing to MeshVault # Contributing to MeshVault
## Setup and checks ## Setup and checks
Use PHP 8.3+ and Composer 2. Run from the repository root: Host requirements: Docker Compose v2+ with Linux containers. No host PHP,
Composer or Node is required.
```sh
# Start the stack (builds images if needed)
./dev.sh up
# Or on Windows: .\dev.ps1 up
# Run checks inside containers
./dev.sh test
./dev.sh lint
# Build assets deterministically
./dev.sh assets
# Watch during development
./dev.sh watch
```
If you have PHP 8.3+ and Composer 2 locally, you can also run:
```sh ```sh
composer install --no-interaction composer install --no-interaction
@@ -11,17 +29,14 @@ composer lint
composer test composer test
``` ```
Commit `composer.lock` for reproducible development tooling. PHP_CodeSniffer is a Commit both `composer.lock` and `package-lock.json` for reproducible builds.
development dependency, not an application framework. Composer scripts use PHP and `migrate` and `seed` are unavailable until M2 (#12); they exit nonzero and leave
local vendor binaries so they work in PowerShell as well as Unix shells. #10 open. Never remove volumes with `down -v`; the wrapper forbids it.
`composer lint` enforces PSR-12 on PHP in `src`, `public`, `db/migrations` and `composer lint` enforces PSR-12 on PHP in `src`, `public`, `config`, `bin`,
`tests`. `composer test` currently checks only repository foundations. Add behavior `templates`, `db/migrations` and `tests`. `composer test` runs foundation checks
and regression tests with each implementation; the HTTP/database test framework and PHPUnit unit/integration tests. Add behavior and regression tests with each
will be selected with the application skeleton (#7), not assumed here. implementation.
Docker, database migrations, Node/Tailwind builds and CI are not implemented yet.
Do not use undocumented application commands or mistake foundation checks for
integration coverage.
## Conventions ## Conventions
+51 -15
View File
@@ -3,10 +3,11 @@
MeshVault is a self-hosted 3D model library being built as a Docker service. MeshVault is a self-hosted 3D model library being built as a Docker service.
It catalogs logical models containing STL parts, images and supplementary documents. It catalogs logical models containing STL parts, images and supplementary documents.
**Status:** M0 foundations only. There is no runnable web application, database **Status:** M1 development stack is up. A four-service Docker Compose stack serves
schema or Docker stack yet. Installation and a verified application quick start a Slim 4 landing page with Tailwind CSS, configuration/logging, and cross-platform
are deliverables of [M1](https://git.keithsolomon.net/keith/MeshVault/milestones). task wrappers. There is no catalog application, database schema or migration runner
Do not deploy this scaffold as a service. yet. See [the development stack setup](docker/README.md) for first-run instructions.
Do not deploy this as a production service.
## Planned MVP ## Planned MVP
@@ -33,8 +34,10 @@ management, external imports, AI and multi-user libraries are outside MVP.
## Architecture and decisions ## Architecture and decisions
Docker, PostgreSQL and PHP application logic; PHP-rendered templates with Tailwind Docker, PostgreSQL and PHP application logic; PHP-rendered templates with Tailwind
CSS and browser JavaScript. Node is planned for asset builds, with mesh processing CSS and browser JavaScript. Node runs inside an assets container for deterministic
introduced only when those future features are scoped. No PHP framework is selected. locked builds and watch; no host Node is required. Slim 4, PHP-DI and Monolog are
installed, locked and wired. HTTP, configuration, logging and task wrappers are
verified on the remote development host.
Accepted decisions: [managed storage (#1)](https://git.keithsolomon.net/keith/MeshVault/issues/1), Accepted decisions: [managed storage (#1)](https://git.keithsolomon.net/keith/MeshVault/issues/1),
[dates (#2)](https://git.keithsolomon.net/keith/MeshVault/issues/2), [dates (#2)](https://git.keithsolomon.net/keith/MeshVault/issues/2),
@@ -49,7 +52,7 @@ Local planning notes and ADR copies are deliberately not versioned.
| `src/` | PHP application and domain code | | `src/` | PHP application and domain code |
| `public/` | Future HTTP document root and generated browser assets | | `public/` | Future HTTP document root and generated browser assets |
| `db/migrations/` | Ordered PostgreSQL schema migrations | | `db/migrations/` | Ordered PostgreSQL schema migrations |
| `docker/` | Future container definitions and service configuration | | `docker/` | Development container definitions, configuration and setup |
| `assets/` | Source assets, including the project icon | | `assets/` | Source assets, including the project icon |
| `tests/` | Automated checks and future application fixtures/tests | | `tests/` | Automated checks and future application fixtures/tests |
@@ -57,18 +60,51 @@ Directory READMEs describe boundaries; they are not application implementations.
## Development checks ## Development checks
With PHP 8.3+ and Composer 2 installed: The only host prerequisite is Docker Compose v2+ with Linux containers. All PHP
and Node tooling runs inside containers.
```sh ```sh
composer install --no-interaction # Start the stack (builds images if needed)
composer validate --strict ./dev.sh up
composer lint # Or on Windows: .\dev.ps1 up
composer test
# Run checks inside the app container
./dev.sh test
./dev.sh lint
# Build minified assets inside the assets container
./dev.sh assets
# Watch for template changes
./dev.sh watch
``` ```
These commands validate foundation files and PSR-12 configuration, not application `composer test` and `composer lint` also work on a host with PHP 8.3+ and Composer 2.
behavior. See [CONTRIBUTING.md](CONTRIBUTING.md) for conventions and test limitations. `composer audit` checks locked PHP dependencies. `migrate` and `seed` are unavailable
Container-based development, migrations and frontend build commands arrive in M1. until M2 (#12); they exit nonzero and leave #10 open.
Asset builds use a locked `package-lock.json` inside a container; host Node is not
required. The generated `public/build/app.css` is served statically by nginx.
### Configuration and logging library
`MeshVault\Config\AppConfig::fromEnvironment(array $environment)` accepts explicit
string values; supported keys, defaults and validation rules are in `.env.example`.
Native PHP does not read `.env`: export the variables through your shell/process
manager, then pass `getenv()` to the factory after requiring `vendor/autoload.php`.
Compose environment injection/startup validation will be wired in Task3. Validate as
the actual runtime user, not root. The library never creates or chmods library roots.
Readonly properties: `environment`, `libraryPath`, `dbHost`, `dbPort`, `dbName`,
`dbUser`, `dbPassword`, `logLevel`. Configuration JSON/debug/serialization is redacted;
serialized configuration cannot be restored. Read credentials only at their point of
use: do not cast configuration to arrays or log credentials/messages containing them.
This is not a general-purpose sanitizer for arbitrary caller-supplied secrets.
`MeshVault\Logging\LoggerFactory::create($config)` returns a PSR-3 logger writing
newline-delimited JSON to stdout at the configured level. `forJob($logger, $jobId)`
returns an independent scope; its `job_id` takes precedence over caller context,
while other context is retained. Re-scoping replaces the job ID; the base logger
and other jobs are unchanged. FPM forwarding remains a Task3 verification gate.
## License ## License
+3
View File
@@ -0,0 +1,3 @@
@import "tailwindcss" source(none);
@source "../../templates/**/*.php";
@source "../../public/**/*.php";
+16
View File
@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
$stage = 'autoload';
try {
require dirname(__DIR__) . '/vendor/autoload.php';
$stage = 'configuration';
\MeshVault\Config\AppConfig::fromEnvironment(getenv());
fwrite(STDOUT, "Configuration valid for runtime user.\n");
} catch (\Throwable $exception) {
$detail = $stage === 'configuration' && $exception instanceof \InvalidArgumentException
? $exception->getMessage() : 'Install dependencies and check bootstrap files.';
fwrite(STDERR, 'Configuration check failed: ' . $detail . "\n");
exit(1);
}
+18 -2
View File
@@ -4,14 +4,30 @@
"type": "project", "type": "project",
"license": "AGPL-3.0-only", "license": "AGPL-3.0-only",
"require": { "require": {
"php": ">=8.3" "php": ">=8.3",
"monolog/monolog": "^3.12",
"php-di/php-di": "^7.1",
"psr/log": "^3.0",
"slim/psr7": "^1.8",
"slim/slim": "^4.15.3"
}, },
"require-dev": { "require-dev": {
"phpunit/phpunit": "^12.0",
"squizlabs/php_codesniffer": "^3.13.5" "squizlabs/php_codesniffer": "^3.13.5"
}, },
"autoload": {
"psr-4": {
"MeshVault\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"MeshVault\\Tests\\": "tests/"
}
},
"scripts": { "scripts": {
"lint": "phpcs --standard=phpcs.xml.dist", "lint": "phpcs --standard=phpcs.xml.dist",
"test": "@php tests/foundation.php" "test": ["@php tests/foundation.php", "@php vendor/phpunit/phpunit/phpunit"]
}, },
"config": { "config": {
"sort-packages": true, "sort-packages": true,
Generated
+2683 -2
View File
File diff suppressed because it is too large. Load diff
+14
View File
@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
use MeshVault\Config\AppConfig;
use MeshVault\Http\View;
use MeshVault\Logging\LoggerFactory;
use Psr\Log\LoggerInterface;
return static fn (AppConfig $config): array => [
AppConfig::class => $config,
View::class => new View(dirname(__DIR__) . '/templates'),
LoggerInterface::class => static fn (): LoggerInterface => LoggerFactory::create($config),
];
+10
View File
@@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
use MeshVault\Http\HomeHandler;
use Slim\App;
return static function (App $app): void {
$app->get('/', HomeHandler::class);
};
+42
View File
@@ -0,0 +1,42 @@
# Deliberately use $args: unknown switches belong to the underlying command.
$ErrorActionPreference = 'Stop'
$Command = if ($args.Count) { $args[0] } else { '' }
$Forward = @($args | Select-Object -Skip 1)
try {
switch ($Command) {
{ $_ -in 'migrate', 'seed' } {
[Console]::Error.WriteLine("$Command unavailable until M2 (#12); #10 remains open")
exit 1
}
'up' { $ComposeArgs = @('up', '-d', '--build') }
'down' {
foreach ($Argument in $Forward) {
if ($Argument -match '^--volumes|^-v$') {
[Console]::Error.WriteLine('Volume removal is forbidden by this wrapper')
exit 2
}
}
$ComposeArgs = @('down')
}
{ $_ -in 'test', 'lint' } {
$ComposeArgs = @('run', '--rm', '--no-deps', '-T', '--entrypoint', 'composer', 'app', $Command)
}
'assets' { $ComposeArgs = @('run', '--rm', '--no-deps', '-T', 'assets', 'npm', 'run', 'build', '--') }
{ $_ -in 'watch', 'assets:watch' } {
$ComposeArgs = @('run', '--rm', '--no-deps', '-T', 'assets', 'npm', 'run', 'watch', '--')
}
default {
[Console]::Error.WriteLine('Usage: dev.ps1 up|down|test|lint|assets|watch|migrate|seed [arguments]')
exit 2
}
}
Push-Location $PSScriptRoot
try {
& docker compose @ComposeArgs @Forward
$Result = $LASTEXITCODE
} finally { Pop-Location }
exit $Result
} catch {
[Console]::Error.WriteLine($_.Exception.Message)
exit 1
}
+21
View File
@@ -0,0 +1,21 @@
#!/bin/sh
set -eu
cd -- "$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd)"
cmd=${1:-}
if [ "$#" -gt 0 ]; then shift; fi
case "$cmd" in
migrate|seed) echo "$cmd unavailable until M2 (#12); #10 remains open" >&2; exit 1 ;;
down)
for arg do
case "$arg" in
--volumes*|-v) echo 'Volume removal is forbidden by this wrapper' >&2; exit 2 ;;
esac
done
exec docker compose down "$@" ;;
up) exec docker compose up -d --build "$@" ;;
test|lint) exec docker compose run --rm --no-deps -T --entrypoint composer app "$cmd" "$@" ;;
assets) exec docker compose run --rm --no-deps -T assets npm run build -- "$@" ;;
watch|assets:watch) exec docker compose run --rm --no-deps -T assets npm run watch -- "$@" ;;
*) echo 'Usage: dev.sh up|down|test|lint|assets|watch|migrate|seed [arguments]' >&2; exit 2 ;;
esac
+87
View File
@@ -0,0 +1,87 @@
services:
web:
image: nginx:1.30-alpine
ports:
- "${MESHVAULT_HTTP_HOST:-127.0.0.1}:${MESHVAULT_HTTP_PORT:-8090}:80"
volumes:
- ./public:/var/www/html/public:ro
- ./docker/nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
depends_on:
- app
app:
build:
context: .
dockerfile: docker/php/Dockerfile
user: www-data
working_dir: /var/www/html
environment:
MESHVAULT_LIBRARY_DIR: ${MESHVAULT_LIBRARY_DIR:-/var/lib/meshvault/library}
MESHVAULT_ENV: ${MESHVAULT_ENV:-production}
MESHVAULT_LOG_LEVEL: ${MESHVAULT_LOG_LEVEL:-info}
MESHVAULT_DB_HOST: ${MESHVAULT_DB_HOST:-db}
MESHVAULT_DB_PORT: ${MESHVAULT_DB_PORT:-5432}
MESHVAULT_DB_NAME: ${MESHVAULT_DB_NAME:-meshvault}
MESHVAULT_DB_USER: ${MESHVAULT_DB_USER:-meshvault}
MESHVAULT_DB_PASSWORD: ${MESHVAULT_DB_PASSWORD:?Set MESHVAULT_DB_PASSWORD in .env}
volumes:
- ./public:/var/www/html/public:ro
- ./src:/var/www/html/src:ro
- ./config:/var/www/html/config:ro
- ./templates:/var/www/html/templates:ro
- ./tests:/var/www/html/tests:ro
- ./db:/var/www/html/db:ro
- ./bin:/var/www/html/bin:ro
- ./composer.json:/var/www/html/composer.json:ro
- ./phpunit.xml.dist:/var/www/html/phpunit.xml.dist:ro
- ./phpcs.xml.dist:/var/www/html/phpcs.xml.dist:ro
- ./dev.sh:/var/www/html/dev.sh:ro
- ./dev.ps1:/var/www/html/dev.ps1:ro
- ./README.md:/var/www/html/README.md:ro
- ./CONTRIBUTING.md:/var/www/html/CONTRIBUTING.md:ro
- ./LICENSE:/var/www/html/LICENSE:ro
- ./.editorconfig:/var/www/html/.editorconfig:ro
- ./.dockerignore:/var/www/html/.dockerignore:ro
- ./docker-compose.yml:/var/www/html/docker-compose.yml:ro
- ./docker:/var/www/html/docker:ro
- ./assets:/var/www/html/assets:ro
- type: bind
source: ${MESHVAULT_LIBRARY_HOST_DIR:-./library}
target: ${MESHVAULT_LIBRARY_DIR:-/var/lib/meshvault/library}
bind:
create_host_path: false
depends_on:
db:
condition: service_healthy
db:
image: postgres:17-bookworm
environment:
POSTGRES_DB: ${MESHVAULT_DB_NAME:-meshvault}
POSTGRES_USER: ${MESHVAULT_DB_USER:-meshvault}
POSTGRES_PASSWORD: ${MESHVAULT_DB_PASSWORD:?Set MESHVAULT_DB_PASSWORD in .env}
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U \"$$POSTGRES_USER\" -d \"$$POSTGRES_DB\""]
interval: 5s
timeout: 5s
retries: 12
start_period: 10s
assets:
build:
context: .
dockerfile: docker/assets/Dockerfile
profiles: [tools]
user: node
working_dir: /workspace
init: true
volumes:
- ./assets/css:/workspace/assets/css:ro
- ./public:/workspace/public:ro
- ./public/build:/workspace/public/build
- ./templates:/workspace/templates:ro
volumes:
pgdata:
+113 -6
View File
@@ -1,7 +1,114 @@
# Container configuration # Development containers (M1)
M1 issue #6 adds nginx, PHP-FPM, PostgreSQL and asset build services. There is no Four-service Docker Compose stack: nginx, unprivileged PHP-FPM, PostgreSQL and
Compose file yet. Keep database data, managed library files and uploaded media an assets container for Tailwind CSS builds. The application layer (Slim 4,
persistent and outside the application source. Mount import sources read-only PHP-DI, Monolog) is wired, but there is no catalog schema, migration runner or
where possible. Node is asset tooling in MVP, not a required mesh-processing asset pipeline beyond Tailwind. Do not expose this stack to the Internet.
runtime. Never bake credentials or user collections into images.
## Prerequisites
- Docker Engine/Desktop with Linux containers and Docker Compose v2+.
- No host PHP, Composer or Node is required.
## First start
From the repository root, copy `.env.example` to `.env` and edit
`MESHVAULT_DB_PASSWORD` to a private, nonempty value. Never commit `.env` or print
expanded Compose configuration in shared logs; use `docker compose config --quiet`.
Create a **new dedicated** `library` directory (`mkdir library`). Do not use an
existing collection. On Linux, give container UID/GID 33 ownership of that new
empty directory, for example `sudo chown 33:33 ./library` and
`sudo chmod 750 ./library`. Alternatively, after building, use the narrowly scoped
one-off command below **only for this new empty directory**:
```sh
docker compose build app
docker compose run --rm --no-deps --user root app chown 33:33 /var/lib/meshvault/library
docker compose config --quiet
docker compose up -d --wait
```
Also ensure `public/build` is writable by the assets container user (`node`, UID
1000). On Linux, run:
```sh
mkdir -p public/build
chown 1000:1000 public/build
```
Docker Desktop bind permissions differ; verify writability using the runtime
identity rather than assuming host chmod works. The stack never automatically
creates or changes ownership of a configured library directory.
## Task wrappers
Use `dev.sh` (POSIX) or `dev.ps1` (PowerShell) from the repository root. They are
independent of your current working directory and propagate exit codes.
```sh
./dev.sh up # build and start services
./dev.sh down # stop services (volume removal is forbidden)
./dev.sh test # run composer test inside the app container
./dev.sh lint # run composer lint inside the app container
./dev.sh assets # build minified CSS inside the assets container
./dev.sh watch # start Tailwind watch inside the assets container
./dev.sh migrate # unavailable until M2 (#12); exits nonzero
./dev.sh seed # unavailable until M2 (#12); exits nonzero
```
`down --volumes` and `down -v` are blocked by the wrapper. Never remove the named
`pgdata` volume or the library bind.
## Asset builds
Assets are built inside the `assets` service using a locked `package-lock.json`.
The generated `public/build/app.css` is served statically by nginx.
```sh
./dev.sh assets
```
Watch mode rebuilds on template changes:
```sh
./dev.sh watch
```
## Verification
Open http://127.0.0.1:8090/ for the landing page. `docker compose exec app id`
should report UID/GID 33. Check a disposable library write without becoming root:
```sh
docker compose exec app php -r '$p = getenv("MESHVAULT_LIBRARY_DIR") . "/write-check"; if (file_put_contents($p, "ok") === false) { exit(1); } unlink($p);'
```
`down` preserves the named `pgdata` volume and library bind. Changing database
credentials in `.env` does not change credentials in an initialized PostgreSQL
volume. Back up data before upgrades.
## Settings and boundaries
`.env.example` documents every current setting: Compose project, HTTP host/port,
DB name/user/password, host library path and absolute container library path.
The container library path must stay outside `/var/www/html` and must not overlap
system paths. The only required non-default value is the database password.
The default project is `meshvault-m1`; HTTP binds localhost:8090. Only nginx has a
published port. PostgreSQL readiness gates app startup. FPM itself runs as
`www-data`, not just its workers. nginx mounts only `public/` and executes only
`/index.php`; source, credentials and library are not web mounts.
Source mounts are explicit and read-only. No host `vendor` or `node_modules` is
mounted; dependencies are installed inside images from lockfiles. No Docker socket
is mounted. Startup configuration validation is provided by the entrypoint script.
Image branches: PHP 8.3 FPM Bookworm (security support to December 2027), PostgreSQL
17 Bookworm (support to November 2029), nginx 1.30 Alpine (stable), Node 24
Bookworm slim (LTS). Tags are versioned but move with patch updates, not digest
locks. PHP 8.3 compatibility is retained.
Remote verification uses only `/home/keith/Projects/MeshVault` after confirming
HOME, explicit project `meshvault-m1`, and an explicit source-only copy manifest.
Access from another machine via `ssh -L 8090:127.0.0.1:8090 keith@192.168.2.23`.
Never attach original collections or change unrelated services.
+7
View File
@@ -0,0 +1,7 @@
FROM node:24-bookworm-slim
WORKDIR /workspace
COPY --chown=node:node package.json package-lock.json ./
RUN chown node:node /workspace
USER node
RUN npm ci --no-audit --no-fund
CMD ["npm", "run", "build"]
+28
View File
@@ -0,0 +1,28 @@
server {
listen 80;
server_name _;
root /var/www/html/public;
index index.php;
server_tokens off;
autoindex off;
location ~ /\. {
return 404;
}
location / {
try_files $uri $uri/ /index.php?$query_string;
}
# Only the front controller may execute; never arbitrary uploaded PHP.
location = /index.php {
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME /var/www/html/public/index.php;
fastcgi_param HTTP_PROXY "";
fastcgi_pass app:9000;
}
location ~* \.php(?:/|$) {
return 404;
}
}
+20
View File
@@ -0,0 +1,20 @@
FROM php:8.3-fpm-bookworm
RUN apt-get update && apt-get install -y --no-install-recommends libonig-dev libxml2-dev unzip \
&& docker-php-ext-install mbstring dom \
&& rm -rf /var/lib/apt/lists/*
COPY --from=composer:2 /usr/bin/composer /usr/local/bin/composer
WORKDIR /var/www/html
# Dependencies are Linux-native and rebuilt from the lock, never host-mounted.
COPY composer.json composer.lock ./
RUN composer install --no-interaction --prefer-dist --no-progress
COPY bin ./bin
COPY docker/php/entrypoint.sh /usr/local/bin/meshvault-entrypoint
COPY docker/php/zz-meshvault.conf /usr/local/etc/php-fpm.d/zz-meshvault.conf
RUN chmod 755 /usr/local/bin/meshvault-entrypoint
RUN mkdir -p /var/www/html/.phpunit.cache && chown www-data:www-data /var/www/html/.phpunit.cache
# The entire FPM process runs as the same identity used for library checks.
USER www-data
ENTRYPOINT ["meshvault-entrypoint"]
CMD ["php-fpm"]
+11
View File
@@ -0,0 +1,11 @@
#!/bin/sh
set -eu
# Image and Compose both select www-data; validation and FPM inherit this identity.
if [ "$1" = "php-fpm" ]; then
if [ "$(id -u)" = 0 ]; then
echo 'Refusing to validate or serve FPM as root; use www-data.' >&2
exit 1
fi
php /var/www/html/bin/check-config.php
fi
exec docker-php-entrypoint "$@"
+7
View File
@@ -0,0 +1,7 @@
[www]
; Preserve explicit Compose settings and forward Monolog JSON without prefixes.
clear_env = no
catch_workers_output = yes
decorate_workers_output = no
php_admin_flag[log_errors] = on
php_admin_flag[display_errors] = off
+1236
View File
File diff suppressed because it is too large. Load diff
+12
View File
@@ -0,0 +1,12 @@
{
"name": "meshvault-assets",
"private": true,
"scripts": {
"build": "tailwindcss -i ./assets/css/app.css -o ./public/build/app.css --minify",
"watch": "tailwindcss -i ./assets/css/app.css -o ./public/build/app.css --watch=always"
},
"devDependencies": {
"@tailwindcss/cli": "4.3.3",
"tailwindcss": "4.3.3"
}
}
+4 -1
View File
@@ -1,4 +1,4 @@
<?xml version="1.0"?> <?xml version="1.0"?>
<ruleset name="MeshVault"> <ruleset name="MeshVault">
<description>MeshVault PHP coding standard: PSR-12.</description> <description>MeshVault PHP coding standard: PSR-12.</description>
<rule ref="PSR12"/> <rule ref="PSR12"/>
@@ -6,6 +6,9 @@
<arg name="encoding" value="utf-8"/> <arg name="encoding" value="utf-8"/>
<file>src</file> <file>src</file>
<file>public</file> <file>public</file>
<file>config</file>
<file>bin</file>
<file>templates</file>
<file>db/migrations</file> <file>db/migrations</file>
<file>tests</file> <file>tests</file>
</ruleset> </ruleset>
+11
View File
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/12.5/phpunit.xsd"
bootstrap="vendor/autoload.php" cacheDirectory=".phpunit.cache"
failOnWarning="true" failOnRisky="true" failOnNotice="true" failOnDeprecation="true">
<testsuites>
<testsuite name="unit"><directory>tests/Unit</directory></testsuite>
<testsuite name="http"><directory>tests/Http</directory></testsuite>
<testsuite name="integration"><directory>tests/Integration</directory></testsuite>
</testsuites>
</phpunit>
+32
View File
@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
// This boundary intentionally does not depend on autoloading, DI or templates.
ini_set('display_errors', '0');
$stage = 'autoload';
try {
require dirname(__DIR__) . '/vendor/autoload.php';
$stage = 'configuration';
$config = \MeshVault\Config\AppConfig::fromEnvironment(getenv());
$stage = 'application';
\MeshVault\Http\AppFactory::create($config)->run();
} catch (\Throwable $exception) {
$safeDetail = $stage === 'configuration' && $exception instanceof \InvalidArgumentException
? $exception->getMessage()
: 'Check dependencies and application bootstrap at ' . $exception->getFile() . ':' . $exception->getLine();
error_log((string) json_encode([
'message' => 'Application bootstrap failed',
'stage' => $stage,
'detail' => $safeDetail,
'exception_type' => $exception::class,
], JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE));
http_response_code(500);
header('Content-Type: text/html; charset=utf-8');
echo '<!doctype html><html lang="en"><meta charset="utf-8"><title>Internal Server Error</title>';
echo '<h1>Internal Server Error</h1>';
if (getenv('MESHVAULT_ENV') === 'development') {
echo '<pre>' . htmlspecialchars($safeDetail, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</pre>';
}
echo '</html>';
}
+102
View File
@@ -0,0 +1,102 @@
<?php
declare(strict_types=1);
namespace MeshVault\Config;
use InvalidArgumentException;
use JsonSerializable;
use SensitiveParameter;
final readonly class AppConfig implements JsonSerializable
{
private function __construct(
public string $environment,
public string $libraryPath,
public string $dbHost,
public int $dbPort,
public string $dbName,
public string $dbUser,
#[SensitiveParameter] public string $dbPassword,
public string $logLevel,
) {
}
/** Values are explicit strings, as returned by getenv(); no implicit .env loading. */
public static function fromEnvironment(#[SensitiveParameter] array $environment): self
{
$required = static function (string $key) use ($environment): string {
$value = $environment[$key] ?? null;
if (!is_string($value) || trim($value) === '' || str_contains($value, "\0")) {
throw new InvalidArgumentException($key . ' must be a non-empty string without NUL bytes.');
}
return $value;
};
$choice = static function (string $key, string $default, array $choices) use ($environment): string {
$value = array_key_exists($key, $environment) ? $environment[$key] : $default;
if (!is_string($value) || !in_array($value, $choices, true)) {
throw new InvalidArgumentException($key . ' must be one of: ' . implode(', ', $choices) . '.');
}
return $value;
};
$appEnvironment = $choice('MESHVAULT_ENV', 'production', ['development', 'test', 'production']);
$level = $choice('MESHVAULT_LOG_LEVEL', 'info', [
'debug', 'info', 'notice', 'warning', 'error', 'critical', 'alert', 'emergency',
]);
$port = array_key_exists('MESHVAULT_DB_PORT', $environment) ? $environment['MESHVAULT_DB_PORT'] : '5432';
if (!is_string($port) || !preg_match('/^[0-9]{1,5}$/D', $port) || (int) $port < 1 || (int) $port > 65535) {
throw new InvalidArgumentException('MESHVAULT_DB_PORT must be decimal digits in the range 1..65535.');
}
$path = $required('MESHVAULT_LIBRARY_DIR');
$absolute = str_starts_with($path, '/')
|| (PHP_OS_FAMILY === 'Windows' && preg_match('~^(?:[A-Za-z]:[\\\\/]|\\\\\\\\)~', $path));
if (!$absolute || preg_match('/[\x00-\x1f\x7f]/', $path)) {
throw new InvalidArgumentException('MESHVAULT_LIBRARY_DIR must be an absolute local directory path.');
}
clearstatcache(true, $path);
if (!is_dir($path) || !is_writable($path)) {
throw new InvalidArgumentException(
'MESHVAULT_LIBRARY_DIR must exist as a directory writable by the runtime user.'
);
}
$database = [];
foreach (['MESHVAULT_DB_HOST', 'MESHVAULT_DB_NAME', 'MESHVAULT_DB_USER'] as $key) {
$value = $required($key);
if (trim($value) !== $value || preg_match('/[\x00-\x1f\x7f]/', $value)) {
throw new InvalidArgumentException($key . ' must not contain controls or surrounding whitespace.');
}
$database[$key] = $value;
}
return new self(
$appEnvironment,
$path,
$database['MESHVAULT_DB_HOST'],
(int) $port,
$database['MESHVAULT_DB_NAME'],
$database['MESHVAULT_DB_USER'],
$required('MESHVAULT_DB_PASSWORD'),
$level,
);
}
/** Diagnostic representations intentionally contain no configuration values. */
public function jsonSerialize(): array
{
return ['configuration' => '[redacted]'];
}
public function __debugInfo(): array
{
return $this->jsonSerialize();
}
public function __serialize(): array
{
return $this->jsonSerialize();
}
public function __unserialize(array $data): void
{
throw new \LogicException('AppConfig must be created from explicit environment input.');
}
}
+65
View File
@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
namespace MeshVault\Http;
use DI\ContainerBuilder;
use MeshVault\Config\AppConfig;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Log\LoggerInterface;
use Slim\App;
use Slim\Exception\HttpMethodNotAllowedException;
use Slim\Exception\HttpNotFoundException;
use Slim\Psr7\Response;
use Throwable;
final class AppFactory
{
public static function create(AppConfig $config): App
{
$builder = new ContainerBuilder();
$definitions = require dirname(__DIR__, 2) . '/config/container.php';
$builder->addDefinitions($definitions($config));
$container = $builder->build();
$app = \Slim\Factory\AppFactory::createFromContainer($container);
$routes = require dirname(__DIR__, 2) . '/config/routes.php';
$routes($app);
$app->addRoutingMiddleware();
$errors = $app->addErrorMiddleware($config->environment === 'development', true, false);
$errors->setDefaultErrorHandler(function (
ServerRequestInterface $request,
Throwable $exception,
bool $displayErrorDetails
) use ($container): Response {
$status = match (true) {
$exception instanceof HttpNotFoundException => 404,
$exception instanceof HttpMethodNotAllowedException => 405,
default => 500,
};
$title = match ($status) {
404 => 'Not Found',
405 => 'Method Not Allowed',
default => 'Internal Server Error',
};
if ($status === 500) {
// No exception messages, arguments, request data or config values in production logs.
$container->get(LoggerInterface::class)->error('HTTP request failed', [
'exception_type' => $exception::class,
'file' => $exception->getFile(),
'line' => $exception->getLine(),
]);
}
$response = new Response($status);
if ($exception instanceof HttpMethodNotAllowedException) {
$response = $response->withHeader('Allow', implode(', ', $exception->getAllowedMethods()));
}
$response->getBody()->write($container->get(View::class)->render('error', [
'title' => $title,
'detail' => $displayErrorDetails ? $exception::class . ': ' . $exception->getMessage() : '',
]));
return $response->withHeader('Content-Type', 'text/html; charset=utf-8');
});
return $app;
}
}
+21
View File
@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace MeshVault\Http;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
final readonly class HomeHandler
{
public function __construct(private View $view)
{
}
public function __invoke(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
{
$response->getBody()->write($this->view->render('home', ['title' => 'MeshVault']));
return $response->withHeader('Content-Type', 'text/html; charset=utf-8');
}
}
+31
View File
@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace MeshVault\Http;
final readonly class View
{
public function __construct(private string $directory)
{
}
public function render(string $template, array $data): string
{
if (!in_array($template, ['home', 'error'], true)) {
throw new \InvalidArgumentException('Unknown view.');
}
$escape = static fn (string $value): string => htmlspecialchars(
$value,
ENT_QUOTES | ENT_SUBSTITUTE,
'UTF-8'
);
ob_start();
try {
require $this->directory . '/' . $template . '.php';
return (string) ob_get_contents();
} finally {
ob_end_clean();
}
}
}
+25
View File
@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace MeshVault\Logging;
use Psr\Log\AbstractLogger;
use Psr\Log\LoggerInterface;
use Stringable;
/** Immutable job scope; no mutable processor shared with another job. */
final class JobLogger extends AbstractLogger
{
public function __construct(
public readonly LoggerInterface $logger,
private readonly string $jobId,
) {
}
public function log($level, string|Stringable $message, array $context = []): void
{
$context['job_id'] = $this->jobId;
$this->logger->log($level, $message, $context);
}
}
+30
View File
@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace MeshVault\Logging;
use MeshVault\Config\AppConfig;
use Monolog\Formatter\JsonFormatter;
use Monolog\Handler\StreamHandler;
use Monolog\Logger;
use Psr\Log\LoggerInterface;
final class LoggerFactory
{
public static function create(AppConfig $config): LoggerInterface
{
$handler = new StreamHandler('php://stdout', $config->logLevel);
$handler->setFormatter(new JsonFormatter(JsonFormatter::BATCH_MODE_JSON, true));
return new Logger('meshvault', [$handler]);
}
public static function forJob(LoggerInterface $logger, string $jobId): LoggerInterface
{
// Re-scoping an existing job replaces its ID without mutating either logger.
if ($logger instanceof JobLogger) {
$logger = $logger->logger;
}
return new JobLogger($logger, $jobId);
}
}
+10
View File
@@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
?>
<!doctype html>
<html lang="en">
<head><meta charset="utf-8"><title><?= $escape($data['title']) ?></title></head>
<body><h1><?= $escape($data['title']) ?></h1><pre><?= $escape($data['detail']) ?></pre></body>
</html>
+15
View File
@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
?>
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title><?= $escape($data['title']) ?></title>
<link rel="stylesheet" href="/build/app.css">
</head>
<body><h1 class="text-blue-600"><?= $escape($data['title']) ?></h1><p>Your managed 3D model library.</p></body>
</html>
+113
View File
@@ -0,0 +1,113 @@
<?php
declare(strict_types=1);
namespace MeshVault\Tests\Http;
use MeshVault\Config\AppConfig;
use MeshVault\Http\AppFactory;
use MeshVault\Http\HomeHandler;
use MeshVault\Http\View;
use PHPUnit\Framework\TestCase;
use Slim\Psr7\Factory\ServerRequestFactory;
use Slim\Psr7\Response;
final class AppTest extends TestCase
{
private function app(string $environment = 'production'): \Slim\App
{
self::assertTrue(class_exists(AppFactory::class), 'HTTP AppFactory must exist');
return AppFactory::create(AppConfig::fromEnvironment([
'MESHVAULT_ENV' => $environment,
'MESHVAULT_LIBRARY_DIR' => sys_get_temp_dir(),
'MESHVAULT_DB_HOST' => 'db',
'MESHVAULT_DB_NAME' => 'meshvault',
'MESHVAULT_DB_USER' => 'meshvault',
'MESHVAULT_DB_PASSWORD' => 'private-secret',
]));
}
public function testHomeUsesContainerResolvedHandlerAndHtmlView(): void
{
$app = $this->app();
self::assertInstanceOf(\DI\Container::class, $app->getContainer());
self::assertInstanceOf(HomeHandler::class, $app->getContainer()->get(HomeHandler::class));
$app->getContainer()->set(View::class, new View(dirname(__DIR__, 2) . '/templates'));
$response = $app->handle((new ServerRequestFactory())->createServerRequest('GET', '/'));
self::assertSame(200, $response->getStatusCode());
self::assertSame('text/html; charset=utf-8', $response->getHeaderLine('Content-Type'));
self::assertStringContainsString('<h1 class="text-blue-600">MeshVault</h1>', (string) $response->getBody());
self::assertSame(HomeHandler::class, array_values($app->getRouteCollector()->getRoutes())[0]->getCallable());
}
public function testViewEscapesDynamicTextAndQuotes(): void
{
self::assertTrue(class_exists(View::class), 'PHP View must exist');
$html = (new View(dirname(__DIR__, 2) . '/templates'))->render('home', ['title' => '<script>"&\'</script>']);
self::assertStringContainsString('&lt;script&gt;&quot;&amp;&#039;&lt;/script&gt;', $html);
self::assertStringNotContainsString('<script>', $html);
}
public function testViewRejectsTraversalBeforeStartingOutputBuffer(): void
{
$level = ob_get_level();
try {
(new View(dirname(__DIR__, 2) . '/templates'))->render('../config/container', []);
self::fail('Traversal must be rejected');
} catch (\InvalidArgumentException $exception) {
self::assertSame('Unknown view.', $exception->getMessage());
self::assertSame($level, ob_get_level());
}
}
public function testViewRestoresOutputBufferWhenTemplateThrows(): void
{
$level = ob_get_level();
try {
(new View(dirname(__DIR__, 2) . '/templates'))->render('home', ['title' => new \stdClass()]);
self::fail('Invalid view value must throw');
} catch (\TypeError) {
self::assertSame($level, ob_get_level());
}
}
public function testNotFound(): void
{
$response = $this->app()->handle((new ServerRequestFactory())->createServerRequest('GET', '/missing'));
self::assertSame(404, $response->getStatusCode());
self::assertStringContainsString('Not Found', (string) $response->getBody());
}
public function testMethodNotAllowed(): void
{
$response = $this->app()->handle((new ServerRequestFactory())->createServerRequest('POST', '/'));
self::assertSame(405, $response->getStatusCode());
self::assertStringContainsString('GET', $response->getHeaderLine('Allow'));
}
public function testProductionExceptionIsGeneric(): void
{
$app = $this->app();
$app->get('/test-only', function (): never {
throw new \RuntimeException('private-secret <script>failure</script>');
});
$response = $app->handle((new ServerRequestFactory())->createServerRequest('GET', '/test-only'));
self::assertSame(500, $response->getStatusCode());
self::assertStringContainsString('Internal Server Error', (string) $response->getBody());
self::assertStringNotContainsString('private-secret', (string) $response->getBody());
self::assertStringNotContainsString('AppTest', (string) $response->getBody());
}
public function testDevelopmentExceptionIsReadableAndEscaped(): void
{
$app = $this->app('development');
$app->get('/test-only', function (): never {
throw new \RuntimeException('Readable <script>failure</script>');
});
$response = $app->handle((new ServerRequestFactory())->createServerRequest('GET', '/test-only'));
self::assertSame(500, $response->getStatusCode());
$body = (string) $response->getBody();
self::assertStringContainsString('Readable &lt;script&gt;failure&lt;/script&gt;', $body);
self::assertStringNotContainsString('<script>', (string) $response->getBody());
}
}
+59
View File
@@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace MeshVault\Tests\Http;
use PHPUnit\Framework\TestCase;
final class BootstrapTest extends TestCase
{
private function runPhp(string $file, string $mode, bool $valid): array
{
$env = array_merge(getenv(), [
'MESHVAULT_ENV' => $mode,
'MESHVAULT_LIBRARY_DIR' => sys_get_temp_dir(),
'MESHVAULT_DB_HOST' => $valid ? 'db' : '',
'MESHVAULT_DB_NAME' => 'meshvault',
'MESHVAULT_DB_USER' => 'meshvault',
'MESHVAULT_DB_PASSWORD' => 'bootstrap-secret',
]);
$code = 'register_shutdown_function(function () { fwrite(STDERR, "STATUS=" . http_response_code()); });'
. 'require ' . var_export($file, true) . ';';
$process = proc_open([PHP_BINARY, '-r', $code], [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes, null, $env);
$out = stream_get_contents($pipes[1]);
$err = stream_get_contents($pipes[2]);
fclose($pipes[1]);
fclose($pipes[2]);
return [proc_close($process), $out, $err];
}
public function testProductionBootstrapFailureIsGeneric500(): void
{
[$exit, $out, $err] = $this->runPhp('public/index.php', 'production', false);
self::assertStringContainsString('Internal Server Error', $out);
self::assertStringContainsString('STATUS=500', $err);
self::assertStringNotContainsString('MESHVAULT_DB_HOST', $out);
self::assertStringNotContainsString('bootstrap-secret', $out . $err);
self::assertStringContainsString('MESHVAULT_DB_HOST', $err);
}
public function testDevelopmentBootstrapFailureIsReadable(): void
{
[$exit, $out, $err] = $this->runPhp('public/index.php', 'development', false);
self::assertStringContainsString('MESHVAULT_DB_HOST', $out);
self::assertStringContainsString('STATUS=500', $err);
self::assertStringNotContainsString('bootstrap-secret', $out . $err);
}
public function testConfigCliSucceedsOnlyForValidConfig(): void
{
self::assertFileExists('bin/check-config.php');
[$exit, $out, $err] = $this->runPhp('bin/check-config.php', 'production', true);
self::assertSame(0, $exit, $err);
[$exit, $out, $err] = $this->runPhp('bin/check-config.php', 'production', false);
self::assertSame(1, $exit);
self::assertStringContainsString('MESHVAULT_DB_HOST', $err);
self::assertStringNotContainsString('bootstrap-secret', $out . $err);
}
}
+40
View File
@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace MeshVault\Tests\Http;
use PHPUnit\Framework\TestCase;
final class DeploymentTest extends TestCase
{
public function testFpmConfigExistsAndForwardsUndecoratedWorkerLogs(): void
{
$path = dirname(__DIR__, 2) . '/docker/php/zz-meshvault.conf';
self::assertFileExists($path);
$config = file_get_contents($path);
self::assertStringContainsString('clear_env = no', $config);
self::assertStringContainsString('catch_workers_output = yes', $config);
self::assertStringContainsString('decorate_workers_output = no', $config);
}
public function testComposeSuppliesEveryApplicationSettingAndViewDirectory(): void
{
$compose = file_get_contents(dirname(__DIR__, 2) . '/docker-compose.yml');
$app = explode(' db:', explode(' app:', $compose, 2)[1], 2)[0];
foreach (['ENV', 'LOG_LEVEL', 'DB_HOST', 'DB_PORT', 'DB_NAME', 'DB_USER', 'DB_PASSWORD'] as $key) {
self::assertStringContainsString('MESHVAULT_' . $key . ':', $app);
}
foreach (['config', 'templates'] as $directory) {
self::assertStringContainsString('./' . $directory . ':/var/www/html/' . $directory . ':ro', $app);
}
}
public function testBuildContextIncludesDependencyAndStartupInputs(): void
{
$ignore = file_get_contents(dirname(__DIR__, 2) . '/.dockerignore');
foreach (['composer.json', 'composer.lock', 'bin/', 'bin/**', 'docker/php/**'] as $input) {
self::assertStringContainsString('!' . $input, $ignore);
}
}
}
+65
View File
@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
namespace MeshVault\Tests\Integration;
use PHPUnit\Framework\TestCase;
final class TaskRunnerTest extends TestCase
{
public function testCommandsFromAnotherWorkingDirectory(): void
{
$root = dirname(__DIR__, 2);
$temporary = sys_get_temp_dir() . '/meshvault-runner-' . bin2hex(random_bytes(6));
mkdir($temporary);
$windows = PHP_OS_FAMILY === 'Windows';
$fake = $temporary . ($windows ? '/docker.cmd' : '/docker');
file_put_contents($fake, $windows
? "@echo off\r\necho cwd=%CD%\r\necho %*\r\nexit /b 37\r\n"
: "#!/bin/sh\nprintf 'cwd=%s\\n' \"\$PWD\"\nprintf '%s\\n' \"\$@\"\nexit 37\n");
chmod($fake, 0755);
$environment = getenv();
$environment[$windows ? 'Path' : 'PATH'] = $temporary . PATH_SEPARATOR . getenv('PATH');
$runner = $windows
? ['powershell.exe', '-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', $root . '/dev.ps1']
: ['sh', $root . '/dev.sh'];
try {
foreach (['up', 'down', 'test', 'lint', 'assets', 'watch'] as $command) {
[$code, $output] = $this->invoke(
[...$runner, $command, '--probe', 'two words'],
$temporary,
$environment
);
self::assertSame(37, $code, $command . ': ' . $output);
self::assertStringContainsString('compose', $output);
self::assertStringContainsString('--probe', $output);
self::assertStringContainsString('two words', $output);
self::assertStringContainsString(str_replace('\\', '/', $root), str_replace('\\', '/', $output));
}
foreach (['unknown', 'migrate', 'seed'] as $command) {
[$code, $output] = $this->invoke([...$runner, $command], $temporary, $environment);
self::assertNotSame(0, $code);
self::assertStringNotContainsString('cwd=', $output);
}
foreach (['-v', '--volumes', '--volumes=true'] as $flag) {
[$code, $output] = $this->invoke([...$runner, 'down', $flag], $temporary, $environment);
self::assertNotSame(0, $code);
self::assertStringNotContainsString('cwd=', $output);
}
} finally {
unlink($fake);
rmdir($temporary);
}
}
private function invoke(array $command, string $cwd, array $environment): array
{
$process = proc_open($command, [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes, $cwd, $environment);
self::assertIsResource($process);
$output = stream_get_contents($pipes[1]) . stream_get_contents($pipes[2]);
fclose($pipes[1]);
fclose($pipes[2]);
return [proc_close($process), $output];
}
}
+124
View File
@@ -0,0 +1,124 @@
<?php
declare(strict_types=1);
namespace MeshVault\Tests\Unit;
use InvalidArgumentException;
use MeshVault\Config\AppConfig;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
final class ConfigTest extends TestCase
{
public static function environment(): array
{
return [
'MESHVAULT_LIBRARY_DIR' => sys_get_temp_dir(),
'MESHVAULT_DB_HOST' => 'db',
'MESHVAULT_DB_NAME' => 'meshvault',
'MESHVAULT_DB_USER' => 'meshvault',
'MESHVAULT_DB_PASSWORD' => ' private-secret ',
];
}
public function testDefaultsAndImmutableExplicitInput(): void
{
self::assertTrue(class_exists(AppConfig::class), 'AppConfig must exist');
$config = AppConfig::fromEnvironment(self::environment());
self::assertSame('production', $config->environment);
self::assertSame('info', $config->logLevel);
self::assertSame(5432, $config->dbPort);
self::assertSame(sys_get_temp_dir(), $config->libraryPath);
self::assertSame('db', $config->dbHost);
self::assertSame('meshvault', $config->dbName);
self::assertSame('meshvault', $config->dbUser);
self::assertSame(' private-secret ', $config->dbPassword);
self::assertTrue((new \ReflectionClass($config))->isReadOnly());
$this->expectException(\Error::class);
$config->dbPort = 1234;
}
public static function invalidValues(): iterable
{
foreach (array_keys(self::environment()) as $key) {
foreach ([null, '', ' ', false, 42, [], new \stdClass()] as $value) {
yield [$key, $value];
}
}
foreach (['0', '65536', '-1', '+5432', '1.2', '1e3', ' 5432', '5432x', '', null, 5432] as $port) {
yield ['MESHVAULT_DB_PORT', $port];
}
foreach (['', 'prod', 'Production', 'development ', null, []] as $env) {
yield ['MESHVAULT_ENV', $env];
}
foreach (['', 'verbose', 'INFO', null, false] as $level) {
yield ['MESHVAULT_LOG_LEVEL', $level];
}
foreach ([__FILE__, __DIR__ . '/absent-library', 'relative/path', "bad\0path"] as $path) {
yield ['MESHVAULT_LIBRARY_DIR', $path];
}
yield ['MESHVAULT_DB_HOST', "db\nsecret"];
yield ['MESHVAULT_DB_NAME', "name\0secret"];
yield ['MESHVAULT_DB_USER', ' user '];
}
#[DataProvider('invalidValues')]
public function testInvalidValuesAreRejectedWithoutEchoingInput(string $key, mixed $value): void
{
self::assertTrue(class_exists(AppConfig::class), 'AppConfig must exist');
$environment = self::environment();
$environment[$key] = $value;
try {
AppConfig::fromEnvironment($environment);
self::fail('Expected invalid configuration: ' . $key);
} catch (InvalidArgumentException $error) {
self::assertStringContainsString($key, $error->getMessage());
self::assertStringNotContainsString('private-secret', (string) $error);
if (is_string($value) && strlen($value) > 5) {
self::assertStringNotContainsString($value, $error->getMessage());
}
}
}
public function testRequiredKeysMustBePresent(): void
{
self::assertTrue(class_exists(AppConfig::class), 'AppConfig must exist');
foreach (array_keys(self::environment()) as $key) {
$environment = self::environment();
unset($environment[$key]);
try {
AppConfig::fromEnvironment($environment);
self::fail('Missing key accepted');
} catch (InvalidArgumentException $error) {
self::assertStringContainsString($key, $error->getMessage());
}
}
}
public function testSupportedEnvironmentsLevelsAndPortBoundaries(): void
{
self::assertTrue(class_exists(AppConfig::class), 'AppConfig must exist');
foreach (['development', 'test', 'production'] as $env) {
foreach (['debug', 'info', 'notice', 'warning', 'error', 'critical', 'alert', 'emergency'] as $level) {
foreach (['1', '65535'] as $port) {
$config = AppConfig::fromEnvironment(self::environment() + [
'MESHVAULT_ENV' => $env, 'MESHVAULT_LOG_LEVEL' => $level, 'MESHVAULT_DB_PORT' => $port,
]);
self::assertSame($env, $config->environment);
self::assertSame($level, $config->logLevel);
self::assertSame((int) $port, $config->dbPort);
}
}
}
}
public function testDiagnosticsAndSerializationDoNotExposePassword(): void
{
self::assertTrue(class_exists(AppConfig::class), 'AppConfig must exist');
$config = AppConfig::fromEnvironment(self::environment());
self::assertStringNotContainsString('private-secret', json_encode($config, JSON_THROW_ON_ERROR));
self::assertStringNotContainsString('private-secret', print_r($config, true));
self::assertStringNotContainsString('private-secret', serialize($config));
}
}
+89
View File
@@ -0,0 +1,89 @@
<?php
declare(strict_types=1);
namespace MeshVault\Tests\Unit;
use MeshVault\Logging\LoggerFactory;
use PHPUnit\Framework\TestCase;
use Psr\Log\AbstractLogger;
use Stringable;
final class LoggingTest extends TestCase
{
public function testJsonStdoutFilteringAndIsolatedJobContext(): void
{
self::assertTrue(class_exists(LoggerFactory::class), 'LoggerFactory must exist');
$code = <<<'PHP'
<?php
require 'vendor/autoload.php';
$config = \MeshVault\Config\AppConfig::fromEnvironment([
'MESHVAULT_LIBRARY_DIR' => sys_get_temp_dir(),
'MESHVAULT_DB_HOST' => 'db',
'MESHVAULT_DB_NAME' => 'meshvault',
'MESHVAULT_DB_USER' => 'meshvault',
'MESHVAULT_DB_PASSWORD' => 'private-secret',
'MESHVAULT_LOG_LEVEL' => 'warning',
]);
$base = \MeshVault\Logging\LoggerFactory::create($config);
$a = \MeshVault\Logging\LoggerFactory::forJob($base, 'job-a');
$b = \MeshVault\Logging\LoggerFactory::forJob($base, 'job-b');
$base->info('filtered');
$a->debug('filtered job');
$a->warning("first\nline", ['file' => 'part.stl', 'job_id' => 'spoofed', 'config' => $config]);
$b->error('second', ['count' => 2]);
$base->warning('base');
$a->critical('again');
\MeshVault\Logging\LoggerFactory::forJob($a, 'job-c')->alert('nested');
PHP;
$process = proc_open([PHP_BINARY], [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes);
self::assertIsResource($process);
fwrite($pipes[0], $code);
fclose($pipes[0]);
$stdout = stream_get_contents($pipes[1]);
$stderr = stream_get_contents($pipes[2]);
fclose($pipes[1]);
fclose($pipes[2]);
self::assertSame(0, proc_close($process), $stderr);
self::assertSame('', $stderr);
self::assertStringNotContainsString('private-secret', $stdout);
self::assertStringNotContainsString('filtered', $stdout);
$lines = explode("\n", rtrim($stdout, "\r\n"));
self::assertCount(5, $lines);
$records = array_map(
static fn (string $line): array => json_decode($line, true, 512, JSON_THROW_ON_ERROR),
$lines,
);
self::assertSame("first\nline", $records[0]['message']);
self::assertSame('WARNING', $records[0]['level_name']);
self::assertSame('job-a', $records[0]['context']['job_id']);
self::assertSame('part.stl', $records[0]['context']['file']);
self::assertSame('job-b', $records[1]['context']['job_id']);
self::assertSame(2, $records[1]['context']['count']);
self::assertArrayNotHasKey('job_id', $records[2]['context']);
self::assertSame('job-a', $records[3]['context']['job_id']);
self::assertSame('job-c', $records[4]['context']['job_id']);
self::assertArrayHasKey('datetime', $records[0]);
}
public function testJobScopeWorksWithAnyPsrLoggerAndRetainsMessage(): void
{
self::assertTrue(class_exists(LoggerFactory::class), 'LoggerFactory must exist');
$base = new class extends AbstractLogger {
public array $records = [];
public function log($level, string|Stringable $message, array $context = []): void
{
$this->records[] = [$level, $message, $context];
}
};
$message = new class implements Stringable {
public function __toString(): string
{
return 'message';
}
};
LoggerFactory::forJob($base, 'job-a')->notice($message, ['file' => 'part.stl']);
self::assertSame(['notice', $message, ['file' => 'part.stl', 'job_id' => 'job-a']], $base->records[0]);
}
}