feat(Production): Complete production deployment infrastructure

- Add comprehensive health check system with multiple endpoints
- Add Prometheus metrics endpoint
- Add production logging configurations (5 strategies)
- Add complete deployment documentation suite:
  * QUICKSTART.md - 30-minute deployment guide
  * DEPLOYMENT_CHECKLIST.md - Printable verification checklist
  * DEPLOYMENT_WORKFLOW.md - Complete deployment lifecycle
  * PRODUCTION_DEPLOYMENT.md - Comprehensive technical reference
  * production-logging.md - Logging configuration guide
  * ANSIBLE_DEPLOYMENT.md - Infrastructure as Code automation
  * README.md - Navigation hub
  * DEPLOYMENT_SUMMARY.md - Executive summary
- Add deployment scripts and automation
- Add DEPLOYMENT_PLAN.md - Concrete plan for immediate deployment
- Update README with production-ready features

All production infrastructure is now complete and ready for deployment.
This commit is contained in:
2025-10-25 19:18:37 +02:00
parent caa85db796
commit fc3d7e6357
83016 changed files with 378904 additions and 20919 deletions

View File

@@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
namespace App\Framework\Vite\Exceptions;
use App\Framework\Exception\ErrorCode;
use App\Framework\Exception\FrameworkException;
/**
* Exception thrown when Vite manifest operations fail
*/
final class ViteManifestException extends FrameworkException
{
public static function fileNotFound(string $path): self
{
return self::create(
ErrorCode::CONFIG_FILE_NOT_FOUND,
"Vite manifest file not found: {$path}"
)->withData([
'manifest_path' => $path,
'suggestion' => 'Run "npm run build" to generate the manifest file'
]);
}
public static function cannotRead(string $path): self
{
return self::create(
ErrorCode::CONFIG_FILE_INVALID,
"Cannot read Vite manifest file: {$path}"
)->withData([
'manifest_path' => $path
]);
}
public static function invalidJson(string $path, string $error): self
{
return self::create(
ErrorCode::CONFIG_FILE_INVALID,
"Invalid JSON in Vite manifest file: {$path}"
)->withData([
'manifest_path' => $path,
'json_error' => $error
]);
}
public static function entryNotFound(string $entryName): self
{
return self::create(
ErrorCode::ENTITY_NOT_FOUND,
"Vite entry point not found in manifest: {$entryName}"
)->withData([
'entry_name' => $entryName,
'suggestion' => 'Check that the entry point is defined in vite.config.js'
]);
}
}

View File

@@ -0,0 +1,88 @@
<?php
declare(strict_types=1);
namespace App\Framework\Vite\ValueObjects;
/**
* Value Object for Vite configuration
*
* Holds configuration for Vite integration including paths,
* dev server settings, and entrypoint configuration.
*/
final readonly class ViteConfig
{
/**
* @param string $buildDirectory Directory where Vite outputs files (relative to public/)
* @param string $manifestFileName Name of the manifest file
* @param string $devServerUrl URL of the Vite dev server
* @param array<string> $entrypoints Default entrypoints to load
* @param string|null $nonce CSP nonce for inline scripts
* @param bool $hotReload Whether to enable hot module replacement in dev
*/
public function __construct(
public string $buildDirectory = 'assets',
public string $manifestFileName = '.vite/manifest.json',
public string $devServerUrl = 'https://localhost:3000',
public array $entrypoints = ['main'],
public ?string $nonce = null,
public bool $hotReload = true
) {
}
/**
* Get the full manifest path relative to public directory
*/
public function getManifestPath(string $publicPath): string
{
return rtrim($publicPath, '/') . '/' . ltrim($this->manifestFileName, '/');
}
/**
* Get the dev server entry URL
*/
public function getDevServerEntry(string $entry): string
{
return rtrim($this->devServerUrl, '/') . '/' . ltrim($entry, '/');
}
/**
* Get the Vite client URL for HMR
*/
public function getViteClientUrl(): string
{
return rtrim($this->devServerUrl, '/') . '/@vite/client';
}
/**
* Create config with custom entrypoints
*
* @param array<string> $entrypoints
*/
public function withEntrypoints(array $entrypoints): self
{
return new self(
buildDirectory: $this->buildDirectory,
manifestFileName: $this->manifestFileName,
devServerUrl: $this->devServerUrl,
entrypoints: $entrypoints,
nonce: $this->nonce,
hotReload: $this->hotReload
);
}
/**
* Create config with CSP nonce
*/
public function withNonce(string $nonce): self
{
return new self(
buildDirectory: $this->buildDirectory,
manifestFileName: $this->manifestFileName,
devServerUrl: $this->devServerUrl,
entrypoints: $this->entrypoints,
nonce: $nonce,
hotReload: $this->hotReload
);
}
}

View File

@@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
namespace App\Framework\Vite\ValueObjects;
/**
* Value Object representing a single Vite manifest entry
*
* Represents an asset entry from Vite's manifest.json file,
* including its compiled file path, source, dependencies, and metadata.
*/
final readonly class ViteEntry
{
/**
* @param string $file Compiled output file path (e.g., "assets/main-abc123.js")
* @param string $src Source file path (e.g., "resources/js/main.js")
* @param bool $isEntry Whether this is an entry point file
* @param array<string> $css Associated CSS files
* @param array<string> $imports Import dependencies (other entry names)
* @param string|null $integrity Subresource integrity hash
*/
public function __construct(
public string $file,
public string $src,
public bool $isEntry,
public array $css = [],
public array $imports = [],
public ?string $integrity = null
) {
}
/**
* Create ViteEntry from manifest entry array
*/
public static function fromManifestEntry(string $name, array $entry): self
{
return new self(
file: $entry['file'] ?? '',
src: $entry['src'] ?? $name,
isEntry: $entry['isEntry'] ?? false,
css: $entry['css'] ?? [],
imports: $entry['imports'] ?? [],
integrity: $entry['integrity'] ?? null
);
}
/**
* Get the full URL path for this entry
*/
public function getUrl(string $baseUrl = ''): string
{
return rtrim($baseUrl, '/') . '/' . ltrim($this->file, '/');
}
/**
* Check if this entry has CSS dependencies
*/
public function hasCss(): bool
{
return ! empty($this->css);
}
/**
* Check if this entry has import dependencies
*/
public function hasImports(): bool
{
return ! empty($this->imports);
}
}

View File

@@ -0,0 +1,131 @@
<?php
declare(strict_types=1);
namespace App\Framework\Vite\ValueObjects;
use App\Framework\Vite\Exceptions\ViteManifestException;
/**
* Value Object representing Vite's manifest.json
*
* Parses and provides access to Vite's build manifest,
* which maps source files to their compiled output files.
*/
final readonly class ViteManifest
{
/**
* @param array<string, ViteEntry> $entries Map of entry names to ViteEntry objects
*/
public function __construct(
public array $entries
) {
}
/**
* Create ViteManifest from manifest file path
*/
public static function fromFile(string $path): self
{
if (! file_exists($path)) {
throw ViteManifestException::fileNotFound($path);
}
$content = file_get_contents($path);
if ($content === false) {
throw ViteManifestException::cannotRead($path);
}
$data = json_decode($content, true);
if (json_last_error() !== JSON_ERROR_NONE) {
throw ViteManifestException::invalidJson($path, json_last_error_msg());
}
return self::fromArray($data);
}
/**
* Create ViteManifest from array data
*
* @param array<string, array> $data
*/
public static function fromArray(array $data): self
{
$entries = [];
foreach ($data as $name => $entry) {
if (! is_array($entry)) {
continue;
}
$entries[$name] = ViteEntry::fromManifestEntry($name, $entry);
}
return new self($entries);
}
/**
* Get entry by name
*/
public function getEntry(string $name): ?ViteEntry
{
return $this->entries[$name] ?? null;
}
/**
* Check if entry exists
*/
public function hasEntry(string $name): bool
{
return isset($this->entries[$name]);
}
/**
* Get all entry point entries (isEntry === true)
*
* @return array<string, ViteEntry>
*/
public function getEntryPoints(): array
{
return array_filter(
$this->entries,
fn (ViteEntry $entry) => $entry->isEntry
);
}
/**
* Get entry with all its dependencies resolved
*
* @return array<ViteEntry>
*/
public function getEntryWithDependencies(string $name): array
{
$entry = $this->getEntry($name);
if ($entry === null) {
return [];
}
$resolved = [$entry];
// Recursively resolve imports
foreach ($entry->imports as $importName) {
$importEntries = $this->getEntryWithDependencies($importName);
foreach ($importEntries as $importEntry) {
// Avoid duplicates
if (! in_array($importEntry, $resolved, true)) {
$resolved[] = $importEntry;
}
}
}
return $resolved;
}
/**
* Get total number of entries
*/
public function count(): int
{
return count($this->entries);
}
}

View File

@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace App\Framework\Vite;
use App\Framework\Cache\Cache;
use App\Framework\Core\PathProvider;
use App\Framework\DI\Attributes\Initializer;
use App\Framework\DI\Container;
use App\Framework\Process\Services\TcpPortCheckService;
use App\Framework\Vite\ValueObjects\ViteConfig;
/**
* Vite Initializer - Registers Vite services in DI Container
*/
final readonly class ViteInitializer
{
#[Initializer]
public function __invoke(Container $container): void
{
// Register ViteConfig
$container->singleton(ViteConfig::class, function () {
// Use host.docker.internal for Docker → Host communication
// The dev server runs on the host machine, but we check from inside Docker
return new ViteConfig(
buildDirectory: 'assets',
manifestFileName: '.vite/manifest.json',
devServerUrl: 'https://host.docker.internal:3000',
entrypoints: ['main'],
nonce: null,
hotReload: true
);
});
// Register TcpPortCheckService
$container->singleton(TcpPortCheckService::class, fn () => new TcpPortCheckService());
// Register ViteService
$container->singleton(ViteService::class, function (Container $c) {
return new ViteService(
config: $c->get(ViteConfig::class),
pathProvider: $c->get(PathProvider::class),
cache: $c->get(Cache::class),
portCheck: $c->get(TcpPortCheckService::class)
);
});
}
}

View File

@@ -0,0 +1,305 @@
<?php
declare(strict_types=1);
namespace App\Framework\Vite;
use App\Framework\Cache\Cache;
use App\Framework\Cache\CacheItem;
use App\Framework\Cache\CacheKey;
use App\Framework\Core\PathProvider;
use App\Framework\Core\ValueObjects\Duration;
use App\Framework\Process\Services\TcpPortCheckService;
use App\Framework\Vite\Exceptions\ViteManifestException;
use App\Framework\Vite\ValueObjects\ViteConfig;
use App\Framework\Vite\ValueObjects\ViteEntry;
use App\Framework\Vite\ValueObjects\ViteManifest;
/**
* Vite Service for Asset Management
*
* Manages Vite assets for both development (with HMR) and production (from manifest).
* Automatically detects dev server availability and generates appropriate HTML tags.
*/
final readonly class ViteService
{
private const DEV_SERVER_TIMEOUT = 1;
private const MANIFEST_CACHE_KEY = 'vite:manifest';
private const MANIFEST_CACHE_TTL_SECONDS = 3600;
public function __construct(
private ViteConfig $config,
private PathProvider $pathProvider,
private Cache $cache,
private TcpPortCheckService $portCheck
) {}
/**
* Check if Vite dev server is running
*/
public function isDevServerRunning(): bool
{
if (! $this->config->hotReload) {
return false;
}
// Parse dev server URL to get host and port
$url = parse_url($this->config->devServerUrl);
if ($url === false || ! isset($url['host'])) {
return false;
}
$host = $url['host'];
$port = $url['port'] ?? ($url['scheme'] === 'https' ? 443 : 80);
$isHttps = ($url['scheme'] ?? 'http') === 'https';
// Use TcpPortCheckService for port checking (no external dependencies)
if ($isHttps) {
return $this->portCheck->isSslPortOpen($host, $port, self::DEV_SERVER_TIMEOUT);
}
return $this->portCheck->isPortOpen($host, $port, self::DEV_SERVER_TIMEOUT);
}
/**
* Get Vite manifest (cached in production)
*/
public function getManifest(): ?ViteManifest
{
// In dev mode, skip manifest
if ($this->isDevServerRunning()) {
return null;
}
// Try cache first
$cacheKey = CacheKey::fromString(self::MANIFEST_CACHE_KEY);
$cached = $this->cache->get($cacheKey);
if ($cached !== null && $cached->value instanceof ViteManifest) {
return $cached->value;
}
// Load from file
$publicPath = $this->pathProvider->resolvePath('/public');
$manifestPath = $this->config->getManifestPath($publicPath);
try {
$manifest = ViteManifest::fromFile($manifestPath);
// Cache for 1 hour
$cacheItem = CacheItem::forSet(
key: $cacheKey,
value: $manifest,
ttl: Duration::fromSeconds(self::MANIFEST_CACHE_TTL_SECONDS)
);
$this->cache->set($cacheItem);
return $manifest;
} catch (ViteManifestException) {
return null;
}
}
/**
* Generate HTML tags for given entrypoints
*
* @param array<string> $entrypoints
*/
public function getTags(array $entrypoints = []): string
{
$entries = ! empty($entrypoints) ? $entrypoints : $this->config->entrypoints;
if ($this->isDevServerRunning()) {
return $this->getDevTags($entries);
}
return $this->getProductionTags($entries);
}
/**
* Generate tags for development mode
*
* @param array<string> $entrypoints
*/
private function getDevTags(array $entrypoints): string
{
$tags = [];
$nonce = $this->getNonceAttribute();
// Vite client for HMR
$tags[] = sprintf(
'<script type="module" src="%s"%s></script>',
$this->config->getViteClientUrl(),
$nonce
);
// Entrypoint scripts
foreach ($entrypoints as $entry) {
$entryPath = 'resources/js/' . $entry . '.js';
$url = $this->config->getDevServerEntry($entryPath);
$tags[] = sprintf(
'<script type="module" src="%s"%s></script>',
$url,
$nonce
);
}
return implode("\n", $tags);
}
/**
* Generate tags for production mode
*
* @param array<string> $entrypoints
*/
private function getProductionTags(array $entrypoints): string
{
$manifest = $this->getManifest();
if ($manifest === null) {
return '<!-- Vite manifest not found. Run "npm run build" -->';
}
$tags = [];
$processedEntries = [];
foreach ($entrypoints as $entryName) {
$entryPath = 'resources/js/' . $entryName . '.js';
$entries = $manifest->getEntryWithDependencies($entryPath);
foreach ($entries as $entry) {
// Avoid duplicates
if (in_array($entry->file, $processedEntries, true)) {
continue;
}
$processedEntries[] = $entry->file;
// Generate CSS tags
foreach ($entry->css as $cssFile) {
$tags[] = $this->generateCssTag($cssFile);
}
// Generate script tag
$tags[] = $this->generateScriptTag($entry);
}
}
return implode("\n", $tags);
}
/**
* Generate CSS link tag
*/
private function generateCssTag(string $cssFile): string
{
$url = '/' . ltrim($cssFile, '/');
return sprintf(
'<link rel="stylesheet" href="%s">',
htmlspecialchars($url, ENT_QUOTES, 'UTF-8')
);
}
/**
* Generate script tag
*/
private function generateScriptTag(ViteEntry $entry): string
{
$url = '/' . ltrim($entry->file, '/');
$nonce = $this->getNonceAttribute();
$integrity = $entry->integrity ? sprintf(' integrity="%s"', $entry->integrity) : '';
return sprintf(
'<script type="module" src="%s"%s%s></script>',
htmlspecialchars($url, ENT_QUOTES, 'UTF-8'),
$integrity,
$nonce
);
}
/**
* Get CSP nonce attribute if configured
*/
private function getNonceAttribute(): string
{
if ($this->config->nonce === null) {
return '';
}
return sprintf(' nonce="%s"', htmlspecialchars($this->config->nonce, ENT_QUOTES, 'UTF-8'));
}
/**
* Preload assets for given entrypoints
*
* @param array<string> $entrypoints
*/
public function getPreloadTags(array $entrypoints = []): string
{
// Preloading only makes sense in production
if ($this->isDevServerRunning()) {
return '';
}
$entries = ! empty($entrypoints) ? $entrypoints : $this->config->entrypoints;
$manifest = $this->getManifest();
if ($manifest === null) {
return '';
}
$tags = [];
$processedFiles = [];
foreach ($entries as $entryName) {
$entryPath = 'resources/js/' . $entryName . '.js';
$mainEntry = $manifest->getEntry($entryPath);
if ($mainEntry === null) {
continue;
}
// Preload main JS
if (! in_array($mainEntry->file, $processedFiles, true)) {
$processedFiles[] = $mainEntry->file;
$tags[] = $this->generatePreloadTag($mainEntry->file, 'script');
}
// Preload CSS
foreach ($mainEntry->css as $cssFile) {
if (! in_array($cssFile, $processedFiles, true)) {
$processedFiles[] = $cssFile;
$tags[] = $this->generatePreloadTag($cssFile, 'style');
}
}
}
return implode("\n", $tags);
}
/**
* Generate preload link tag
*/
private function generatePreloadTag(string $file, string $as): string
{
$url = '/' . ltrim($file, '/');
return sprintf(
'<link rel="preload" href="%s" as="%s">',
htmlspecialchars($url, ENT_QUOTES, 'UTF-8'),
$as
);
}
/**
* Clear manifest cache
*/
public function clearManifestCache(): bool
{
$cacheKey = CacheKey::fromString(self::MANIFEST_CACHE_KEY);
return $this->cache->forget($cacheKey);
}
}