lthn.io/app/Core/Config/ConfigVersioning.php

356 lines
11 KiB
PHP
Raw Normal View History

<?php
/*
* Core PHP Framework
*
* Licensed under the European Union Public Licence (EUPL) v1.2.
* See LICENSE file for details.
*/
declare(strict_types=1);
namespace Core\Config;
use Core\Config\Models\ConfigProfile;
use Core\Config\Models\ConfigVersion;
use Illuminate\Support\Collection;
/**
* Configuration versioning service.
*
* Provides ability to version config changes and rollback to previous versions.
* Each version captures a snapshot of all config values for a scope.
*
* ## Features
*
* - Create named snapshots of config state
* - Rollback to any previous version
* - Compare versions to see differences
* - Automatic versioning on significant changes
* - Retention policy for old versions
*
* ## Usage
*
* ```php
* $versioning = app(ConfigVersioning::class);
*
* // Create a snapshot before changes
* $version = $versioning->createVersion($workspace, 'Before CDN migration');
*
* // Make changes...
* $config->set('cdn.provider', 'bunny', $profile);
*
* // Rollback if needed
* $versioning->rollback($version->id, $workspace);
*
* // Compare versions
* $diff = $versioning->compare($workspace, $oldVersionId, $newVersionId);
* ```
*
* ## Version Structure
*
* Each version stores:
* - Scope (workspace/system)
* - Timestamp
* - Label/description
* - Full snapshot of all config values
* - Author (if available)
*
* @see ConfigService For runtime config access
* @see ConfigExporter For import/export operations
*/
class ConfigVersioning
{
/**
* Maximum versions to keep per scope (configurable).
*/
protected int $maxVersions;
public function __construct(
protected ConfigService $config,
protected ConfigExporter $exporter,
) {
$this->maxVersions = (int) config('core.config.max_versions', 50);
}
/**
* Create a new config version (snapshot).
*
* @param object|null $workspace Workspace model instance or null for system scope
* @param string $label Version label/description
* @param string|null $author Author identifier (user ID, email, etc.)
* @return ConfigVersion The created version
*/
public function createVersion(
?object $workspace = null,
string $label = '',
?string $author = null,
): ConfigVersion {
$profile = $this->getOrCreateProfile($workspace);
// Get current config as JSON snapshot
$snapshot = $this->exporter->exportJson($workspace, includeSensitive: true, includeKeys: false);
$version = ConfigVersion::create([
'profile_id' => $profile->id,
'workspace_id' => $workspace?->id,
'label' => $label ?: 'Version '.now()->format('Y-m-d H:i:s'),
'snapshot' => $snapshot,
'author' => $author ?? $this->getCurrentAuthor(),
'created_at' => now(),
]);
// Enforce retention policy
$this->pruneOldVersions($profile->id);
return $version;
}
/**
* Rollback to a specific version.
*
* @param int $versionId Version ID to rollback to
* @param object|null $workspace Workspace model instance or null for system scope
* @param bool $createBackup Create a backup version before rollback (default: true)
* @return ImportResult Import result with stats
*
* @throws \InvalidArgumentException If version not found or scope mismatch
*/
public function rollback(
int $versionId,
?object $workspace = null,
bool $createBackup = true,
): ImportResult {
$version = ConfigVersion::find($versionId);
if ($version === null) {
throw new \InvalidArgumentException("Version not found: {$versionId}");
}
// Verify scope matches
$workspaceId = $workspace?->id;
if ($version->workspace_id !== $workspaceId) {
throw new \InvalidArgumentException('Version scope does not match target scope');
}
// Create backup before rollback
if ($createBackup) {
$this->createVersion($workspace, 'Backup before rollback to version '.$versionId);
}
// Import the snapshot
return $this->exporter->importJson($version->snapshot, $workspace);
}
/**
* Get all versions for a scope.
*
* @param object|null $workspace Workspace model instance or null for system scope
* @param int $limit Maximum versions to return
* @return Collection<int, ConfigVersion>
*/
public function getVersions(?object $workspace = null, int $limit = 20): Collection
{
$workspaceId = $workspace?->id;
return ConfigVersion::where('workspace_id', $workspaceId)
->orderByDesc('created_at')
->limit($limit)
->get();
}
/**
* Get a specific version.
*
* @param int $versionId Version ID
*/
public function getVersion(int $versionId): ?ConfigVersion
{
return ConfigVersion::find($versionId);
}
/**
* Compare two versions.
*
* @param object|null $workspace Workspace model instance or null for system scope
* @param int $oldVersionId Older version ID
* @param int $newVersionId Newer version ID
* @return VersionDiff Difference between versions
*
* @throws \InvalidArgumentException If versions not found
*/
public function compare(?object $workspace, int $oldVersionId, int $newVersionId): VersionDiff
{
$oldVersion = ConfigVersion::find($oldVersionId);
$newVersion = ConfigVersion::find($newVersionId);
if ($oldVersion === null) {
throw new \InvalidArgumentException("Old version not found: {$oldVersionId}");
}
if ($newVersion === null) {
throw new \InvalidArgumentException("New version not found: {$newVersionId}");
}
// Parse snapshots
$oldData = json_decode($oldVersion->snapshot, true)['values'] ?? [];
$newData = json_decode($newVersion->snapshot, true)['values'] ?? [];
return $this->computeDiff($oldData, $newData);
}
/**
* Compare current state with a version.
*
* @param object|null $workspace Workspace model instance or null for system scope
* @param int $versionId Version ID to compare against
* @return VersionDiff Difference between version and current state
*
* @throws \InvalidArgumentException If version not found
*/
public function compareWithCurrent(?object $workspace, int $versionId): VersionDiff
{
$version = ConfigVersion::find($versionId);
if ($version === null) {
throw new \InvalidArgumentException("Version not found: {$versionId}");
}
// Get current state
$currentJson = $this->exporter->exportJson($workspace, includeSensitive: true, includeKeys: false);
$currentData = json_decode($currentJson, true)['values'] ?? [];
// Get version state
$versionData = json_decode($version->snapshot, true)['values'] ?? [];
return $this->computeDiff($versionData, $currentData);
}
/**
* Compute difference between two value arrays.
*
* @param array<array{key: string, value: mixed, locked: bool}> $oldValues
* @param array<array{key: string, value: mixed, locked: bool}> $newValues
*/
protected function computeDiff(array $oldValues, array $newValues): VersionDiff
{
$diff = new VersionDiff;
// Index by key
$oldByKey = collect($oldValues)->keyBy('key');
$newByKey = collect($newValues)->keyBy('key');
// Find added keys (in new but not in old)
foreach ($newByKey as $key => $newValue) {
if (! $oldByKey->has($key)) {
$diff->addAdded($key, $newValue['value']);
}
}
// Find removed keys (in old but not in new)
foreach ($oldByKey as $key => $oldValue) {
if (! $newByKey->has($key)) {
$diff->addRemoved($key, $oldValue['value']);
}
}
// Find changed keys (in both but different)
foreach ($oldByKey as $key => $oldValue) {
if ($newByKey->has($key)) {
$newValue = $newByKey[$key];
if ($oldValue['value'] !== $newValue['value']) {
$diff->addChanged($key, $oldValue['value'], $newValue['value']);
}
if (($oldValue['locked'] ?? false) !== ($newValue['locked'] ?? false)) {
$diff->addLockChanged($key, $oldValue['locked'] ?? false, $newValue['locked'] ?? false);
}
}
}
return $diff;
}
/**
* Delete a version.
*
* @param int $versionId Version ID
*
* @throws \InvalidArgumentException If version not found
*/
public function deleteVersion(int $versionId): void
{
$version = ConfigVersion::find($versionId);
if ($version === null) {
throw new \InvalidArgumentException("Version not found: {$versionId}");
}
$version->delete();
}
/**
* Prune old versions beyond retention limit.
*
* @param int $profileId Profile ID
*/
protected function pruneOldVersions(int $profileId): void
{
$versions = ConfigVersion::where('profile_id', $profileId)
->orderByDesc('created_at')
->get();
if ($versions->count() > $this->maxVersions) {
$toDelete = $versions->slice($this->maxVersions);
foreach ($toDelete as $version) {
$version->delete();
}
}
}
/**
* Get or create profile for a workspace (or system).
*/
protected function getOrCreateProfile(?object $workspace): ConfigProfile
{
if ($workspace !== null) {
return ConfigProfile::ensureWorkspace($workspace->id);
}
return ConfigProfile::ensureSystem();
}
/**
* Get current author for version attribution.
*/
protected function getCurrentAuthor(): ?string
{
// Try to get authenticated user
if (function_exists('auth') && auth()->check()) {
$user = auth()->user();
return $user->email ?? $user->name ?? (string) $user->id;
}
// Return null if no user context
return null;
}
/**
* Set maximum versions to keep per scope.
*
* @param int $max Maximum versions
*/
public function setMaxVersions(int $max): void
{
$this->maxVersions = max(1, $max);
}
/**
* Get maximum versions to keep per scope.
*/
public function getMaxVersions(): int
{
return $this->maxVersions;
}
}