Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
272 changes: 272 additions & 0 deletions src/Config/RegistryConfigCompiler.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,272 @@
<?php

declare(strict_types=1);

/**
* Copyright 2026 The Horde Project (http://www.horde.org/)
*
* See the enclosed file LICENSE for license information (LGPL). If you
* did not receive this file, see http://www.horde.org/licenses/lgpl21.
*
* @category Horde
* @copyright 2026 The Horde Project
* @license http://www.horde.org/licenses/lgpl21 LGPL 2.1
* @package Core
*/

namespace Horde\Core\Config;

use Horde\Core\Factory\RegistryConfigCompilerFactory;
use Horde\Injector\Attribute\Factory;

/**
* Compile all registry sources into a single array graph.
*
* Produces the two-level structure consumed by RegistryConfigLoader's
* compiled-registry short-circuit:
*
* [
* 'default' => [ ...merged default registry, per-app array ],
* 'foo.example.com' => [ ...delta relative to default ],
* 'bar.example.com' => [ ...delta relative to default ],
* ]
*
* The 'default' slot is the fully-merged result of the standard load
* chain minus vhost. Vendor defaults, deployment base, registry.d
* snippets and registry.local.php get merged with
* array_replace_recursive at each step. This matches what
* RegistryConfigLoader::load() returns to a caller with no vhost.
*
* Each vhost slot holds only the delta: the raw contents of the
* corresponding registry-{hostname}.php file. Storing the delta rather
* than a fully-merged view keeps the compiled artifact small. For a
* dozen vhosts that share the same base registry, the compiled file
* grows by roughly one delta per vhost rather than N full registries.
* RegistryConfigLoader recombines default + vhost-slot at read time.
*
* ## Vhost discovery
*
* If no explicit vhost list is supplied, the compiler discovers vhosts
* by globbing `{configBase}/horde/registry-*.php`. Extracts the vhost
* name from the filename. This is the common case. A deployment's
* vhost set is defined by which vhost files exist.
*
* Callers with an external source of truth for the vhost list (a
* deployment manifest, a database, a config array) can pass `$vhosts`
* explicitly. The compiler then only reads vhost files for the listed
* names, and silently skips names whose files don't exist.
*
* ## Merge semantics
*
* Uses `array_replace_recursive`, matching RegistryConfigLoader. Legacy
* Horde_Registry used bare `include` (which is shallow-replacement at
* the top level of $this->applications). Modern uses recursive. The
* compiler follows modern.
*
* ## Not written to disk by the compiler
*
* `compile()` returns an in-memory array. Callers that want to
* persist it use {@see RegistryConfigWriter}, which handles atomic
* write and opcache invalidation as separate concerns. The
* compiler stays a pure-compute class with no filesystem side
* effects. Deterministic, testable and safe to invoke from any
* context (CLI, HTTP request, unit test) without worrying about
* disk state.
*
* The split is intentional: serialization format, atomic write,
* permission handling and opcache invalidation are operational
* concerns that vary by caller (installer vs. hordectl vs. CI
* pipeline). Baking them into the compiler would force every
* caller through one policy.
*
* @category Horde
* @copyright 2026 The Horde Project
* @license http://www.horde.org/licenses/lgpl21 LGPL 2.1
* @package Core
*/
#[Factory(factory: RegistryConfigCompilerFactory::class, method: 'create')]
class RegistryConfigCompiler
{
/**
* @param string $configBase Deployment config root (HORDE_CONFIG_BASE).
* The compiler reads
* `{configBase}/horde/registry.php`,
* `registry.d/*.php`,
* `registry.local.php` and
* `registry-{vhost}.php` files.
* @param string $vendorBase Path to the vendor/horde/horde
* directory. The compiler reads
* `{vendorBase}/config/registry.php` as
* the factory defaults.
* @param string[]|null $vhosts Explicit vhost list, or null for
* auto-discovery via filesystem glob.
* Pass an empty array for a
* default-only compile.
*/
public function __construct(
private readonly string $configBase,
private readonly string $vendorBase,
private readonly ?array $vhosts = null,
) {}

/**
* Compile the full registry graph.
*
* @return array{
* default: array<string, array<string, mixed>>,
* } & array<string, array<string, array<string, mixed>>>
* A map with the 'default' key holding the merged default
* registry, plus one key per compiled vhost holding that
* vhost's raw delta. Vhosts whose files don't exist are
* silently omitted from the result.
*/
public function compile(): array
{
$result = ['default' => $this->compileDefault()];

$vhosts = $this->vhosts ?? $this->discoverVhosts();
foreach ($vhosts as $vhost) {
$delta = $this->loadVhostDelta($vhost);
if ($delta !== null) {
$result[$vhost] = $delta;
}
}

return $result;
}

/**
* Merge the four non-vhost layers into a single applications array.
*
* Order (each layer overrides the previous via
* array_replace_recursive):
* 1. vendor defaults: {vendorBase}/config/registry.php
* 2. deployment base: {configBase}/horde/registry.php
* 3. snippets: {configBase}/horde/registry.d/*.php (sorted)
* 4. local override: {configBase}/horde/registry.local.php
*
* @return array<string, array<string, mixed>>
*/
private function compileDefault(): array
{
$confDir = $this->configBase . '/horde/';
$vendorDir = $this->vendorBase . '/config/';

$files = [];

// Layer 1: vendor defaults.
$files[] = $vendorDir . 'registry.php';

// Layer 2: deployment base.
$files[] = $confDir . 'registry.php';

// Layer 3: snippets. Sorted so numeric prefixes (01-, 02-, ...)
// load in the intended order across filesystems.
$snippetsDir = $confDir . 'registry.d';
if (is_dir($snippetsDir)) {
$snippets = glob($snippetsDir . '/*.php');
if ($snippets !== false) {
sort($snippets);
$files = array_merge($files, $snippets);
}
}

// Layer 4: local override.
$files[] = $confDir . 'registry.local.php';

$applications = [];
foreach ($files as $file) {
if (file_exists($file)) {
$applications = array_replace_recursive(
$applications,
$this->includeFile($file)
);
}
}

return $applications;
}

/**
* Read a single vhost file and return its raw applications delta.
*
* Returns null (not an empty array) when the file doesn't exist,
* so callers can distinguish "no vhost file" from "vhost file with
* no applications". The latter should still be recorded as a
* present-but-empty override.
*
* @return array<string, array<string, mixed>>|null
*/
private function loadVhostDelta(string $vhost): ?array
{
$vhostFile = $this->configBase . '/horde/registry-' . $vhost . '.php';
if (!file_exists($vhostFile)) {
return null;
}
return $this->includeFile($vhostFile);
}

/**
* Discover vhosts by scanning for registry-*.php files.
*
* Returns hostnames extracted from filenames, unsorted. Sorting is
* not meaningful for vhost slots. They are keyed by hostname in
* the output, and no vhost merges onto another.
*
* @return string[]
*/
private function discoverVhosts(): array
{
$pattern = $this->configBase . '/horde/registry-*.php';
$files = glob($pattern);
if ($files === false) {
return [];
}

$vhosts = [];
foreach ($files as $file) {
$basename = basename($file);
// strip 'registry-' prefix (9 chars) and '.php' suffix.
$vhost = substr($basename, 9, -4);
// Guard against pathological empty match. Theoretically
// impossible from the glob, but a defensive skip is cheap.
if ($vhost !== '') {
$vhosts[] = $vhost;
}
}

return $vhosts;
}

/**
* Include a registry file with $this->applications context.
*
* Registry files assign to $this->applications['app'] = [...]. To
* make that work without polluting the compiler's own state, bind
* the include to a disposable object whose only public property is
* $applications, then read that property back after include
* returns.
*
* Duplicated from RegistryConfigLoader::includeFile intentionally.
* Both callers do the same thing, extraction would create a new
* public helper class for 22 lines of shared logic, and the
* duplication risk is low because the file-format contract is
* stable.
*
* @return array<string, array<string, mixed>>
*/
private function includeFile(string $file): array
{
$mock = new class {
/** @var array<string, array<string, mixed>> */
public array $applications = [];
};

$executor = function () use ($file) {
include $file;
};
$executor->bindTo($mock, $mock)();

return $mock->applications;
}
}
90 changes: 87 additions & 3 deletions src/Config/RegistryConfigLoader.php
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,21 @@
*
* Registry is global - always loads from 'horde' app only.
*
* ## Compiled-registry short-circuit
*
* If a compiled-registry file exists at $compiledRegistryPath, the
* loader loads it via `require` and returns a merged view without
* scanning any of the layer files above. The compiled file is
* produced by {@see RegistryConfigCompiler} and holds a
* two-level structure: a 'default' key with the merged default
* registry, and one key per compiled vhost with that vhost's raw
* delta. `load()` merges default + vhost-slot at read time.
*
* Callers who want to force layer reloading (e.g. after editing a
* registry.d snippet) should delete the compiled file. `clearCache()`
* only invalidates the in-memory RegistryState cache, not the on-disk
* compiled file.
*
* @category Horde
* @copyright 2026 The Horde Project
* @license http://www.horde.org/licenses/lgpl21 LGPL 2.1
Expand All @@ -44,13 +59,21 @@
class RegistryConfigLoader
{
private ?RegistryState $cache = null;
private readonly string $compiledRegistryPath;

public function __construct(
private string $configBase, // HORDE_CONFIG_BASE constant
private string $vendorBase, // Path to vendor/horde/horde directory
private Vhost|string $vhost = 'localhost'
private Vhost|string $vhost = 'localhost',
?string $compiledRegistryPath = null,
) {
$this->vhost = Vhost::from($this->vhost);
// The compiled registry lives outside the admin-authored
// var/config/horde/ tree. It's a derived cache, not a config
// source. Default: sibling var/cache/compiled/registry.php.
// Callers with non-standard layouts pass this explicitly.
$this->compiledRegistryPath = $compiledRegistryPath
?? dirname($this->configBase) . '/cache/compiled/registry.php';
}

/**
Expand All @@ -64,7 +87,22 @@ public function __construct(
public function load(string $file = 'registry.php'): RegistryState
{
if ($this->cache === null) {
$applications = $this->loadFiles($file);
// Fast path: if a compiled registry exists, use it and
// skip every layer file. The compiler wrote the same
// merge result the layer scan would produce, so callers
// observe identical output either way.
//
// Only the default filename is served from the compiled
// artifact. Callers passing an explicit non-default $file
// want a different registry (e.g. a test fixture) and
// must fall through to the layer scan.
if ($file === 'registry.php'
&& file_exists($this->compiledRegistryPath)
) {
$applications = $this->loadFromCompiled();
} else {
$applications = $this->loadFiles($file);
}
$this->cache = new RegistryState($applications);
}

Expand Down Expand Up @@ -202,7 +240,53 @@ private function loadFiles(string $file): array
}

/**
* Include PHP file in isolated scope with $this context
* Load the merged registry from a pre-compiled artifact.
*
* The compiled file is a plain PHP `<?php return [...]` returning
* the two-level structure produced by RegistryConfigCompiler:
* [
* 'default' => [ merged default registry ],
* '<vhost>' => [ vhost-specific delta ],
* ]
*
* If the current vhost is present in the compiled artifact, its
* delta merges onto the default via array_replace_recursive (same
* semantics as the layer scan). Vhosts not present in the compiled
* artifact fall back to the default silently. This matches the
* layer scan's behavior when no registry-{vhost}.php file exists.
*
* @return array<string, array<string, mixed>>
*/
private function loadFromCompiled(): array
{
$compiled = require $this->compiledRegistryPath;

// Defensive: if the compiled file is somehow malformed (not an
// array, or missing 'default'), fall back to layers rather
// than propagate an unusable state. This preserves the "you
// can delete the compiled file to force a fresh scan" story
// even when the file is corrupt.
if (!is_array($compiled) || !isset($compiled['default'])) {
return $this->loadFiles('registry.php');
}

$applications = $compiled['default'];

if ($this->vhost->isAvailable()) {
$hostname = $this->vhost->getHostname();
if ($hostname !== null && isset($compiled[$hostname])) {
$applications = array_replace_recursive(
$applications,
$compiled[$hostname]
);
}
}

return $applications;
}

/**
* Include PHP file in isolated scope with $this context.
*
* Registry files expect $this->applications to be available
*
Expand Down
Loading
Loading