549 lines
29 KiB
PHP
549 lines
29 KiB
PHP
<?php
|
|
|
|
namespace Tempo\Purchasing\Delivery;
|
|
|
|
/** Offline, journalled file deployment. Never migrates data, enables configuration or restarts services. */
|
|
final class CodeDeployment
|
|
{
|
|
private string $host;
|
|
|
|
public function __construct(string $host)
|
|
{
|
|
if (trim($host) === '') {
|
|
throw new \RuntimeException('Host explícito obrigatório.');
|
|
}
|
|
$this->host = str_replace('\\', '/', realpath($host) ?: throw new \RuntimeException('Host inexistente.'));
|
|
}
|
|
|
|
private function path(string $relative): string
|
|
{
|
|
if ($relative === '' || str_contains($relative, '..') || ! preg_match('~^[A-Za-z0-9_./-]+$~D', $relative)) {
|
|
throw new \RuntimeException('Caminho de implantação inválido.');
|
|
}
|
|
$path = $this->host.'/'.$relative;
|
|
$part = $this->host;
|
|
foreach (explode('/', $relative) as $segment) {
|
|
$part .= '/'.$segment;
|
|
if (is_link($part)) {
|
|
throw new \RuntimeException('Links recusados na implantação.');
|
|
}
|
|
}
|
|
$parent = $path;
|
|
while (! file_exists($parent)) {
|
|
$parent = dirname($parent);
|
|
}
|
|
$resolved = str_replace('\\', '/', realpath($parent) ?: '');
|
|
if ($resolved !== $this->host && ! str_starts_with(strtolower($resolved), strtolower($this->host).'/')) {
|
|
throw new \RuntimeException('Caminho fora do host.');
|
|
}
|
|
|
|
return $path;
|
|
}
|
|
|
|
private static function write(string $path, string $contents): void
|
|
{
|
|
if (! is_dir(dirname($path)) && ! mkdir(dirname($path), 0700, true)) {
|
|
throw new \RuntimeException('Falha no directório de implantação.');
|
|
}
|
|
$temp = $path.'.'.bin2hex(random_bytes(6)).'.next';
|
|
$stream = fopen($temp, 'xb');
|
|
if (! $stream) {
|
|
throw new \RuntimeException('Falha na preparação do ficheiro.');
|
|
}
|
|
chmod($temp, 0600);
|
|
try {
|
|
$written = 0;
|
|
while ($written < strlen($contents)) {
|
|
$n = fwrite($stream, substr($contents, $written));
|
|
if ($n === false || $n === 0) {
|
|
throw new \RuntimeException('Escrita incompleta.');
|
|
}
|
|
$written += $n;
|
|
}
|
|
fflush($stream);
|
|
if (function_exists('fsync')) {
|
|
fsync($stream);
|
|
}
|
|
} finally {
|
|
fclose($stream);
|
|
}
|
|
if (! rename($temp, $path)) {
|
|
throw new \RuntimeException('Falha na troca do ficheiro.');
|
|
}
|
|
}
|
|
|
|
private function manifest(string $bundle, string $expected): array
|
|
{
|
|
if (trim($bundle) === '') {
|
|
throw new \RuntimeException('Bundle explícito obrigatório.');
|
|
}
|
|
$bundle = realpath($bundle) ?: throw new \RuntimeException('Bundle inexistente.');
|
|
$text = file_get_contents($bundle.'/manifest.json');
|
|
if (! hash_equals($expected, hash('sha256', $text))) {
|
|
throw new \RuntimeException('Manifesto divergente.');
|
|
}
|
|
$manifest = json_decode($text, true, 32, JSON_THROW_ON_ERROR);
|
|
if (($manifest['format'] ?? '') !== 'tempo.operational-review.v1') {
|
|
throw new \RuntimeException('Formato de entrega desconhecido.');
|
|
}
|
|
$expectedFiles = array_fill_keys([...array_keys($manifest['support_files']), 'manifest.json'], true);
|
|
foreach (new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($bundle, \FilesystemIterator::SKIP_DOTS)) as $entry) {
|
|
$relative = str_replace('\\', '/', substr($entry->getPathname(), strlen($bundle) + 1));
|
|
if ($entry->isLink() || ($entry->isFile() && ! isset($expectedFiles[$relative]))) {
|
|
throw new \RuntimeException('Conteúdo extra ou link no bundle.');
|
|
}
|
|
}
|
|
foreach ($manifest['support_files'] as $file => $hash) {
|
|
$resolved = str_replace('\\', '/', realpath($bundle.'/'.$file) ?: '');
|
|
if (str_contains($file, '..') || ! preg_match('~^(bootstrap\.php|(app|packages|integrations|scripts)/[A-Za-z0-9_./-]+|resources/js/(app|PurchasingHome)\.jsx|public/build/manifest\.json)$~D', $file)
|
|
|| ! str_starts_with(strtolower($resolved), strtolower(str_replace('\\', '/', $bundle)).'/') || is_link($bundle.'/'.$file) || ! is_file($bundle.'/'.$file) || ! hash_equals($hash, hash_file('sha256', $bundle.'/'.$file))) {
|
|
throw new \RuntimeException('Integridade do bundle divergente.');
|
|
}
|
|
}
|
|
foreach ($manifest['host_overlays'] as $file => $hashes) {
|
|
if ((! str_starts_with($file, 'app/') && ! in_array($file, ['resources/js/app.jsx', 'resources/js/PurchasingHome.jsx', 'public/build/manifest.json'], true)) || ($manifest['support_files'][$file] ?? null) !== $hashes['after_sha256']) {
|
|
throw new \RuntimeException('Sobreposição inválida.');
|
|
}
|
|
$this->path($file);
|
|
}
|
|
|
|
return [$bundle, $manifest];
|
|
}
|
|
|
|
public function plan(string $bundle, string $manifestHash): array
|
|
{
|
|
$this->cachePaths();
|
|
[, $manifest] = $this->manifest($bundle, $manifestHash);
|
|
$pointer = $this->path('packages/purchasing/current.json');
|
|
$pointerHash = hash_file('sha256', $pointer);
|
|
$previous = $this->current();
|
|
$oldManifest = null;
|
|
if (is_dir($this->path('packages/internal-requisitions'))) {
|
|
if ($previous === null) {
|
|
throw new \RuntimeException('Bundle sem proveniência de implantação; reconciliar explicitamente.');
|
|
}
|
|
$journal = json_decode(file_get_contents($this->path('storage/app/private/module-deployments/'.$previous['deployment_id'].'/journal.json')), true, 32, JSON_THROW_ON_ERROR);
|
|
if ($journal['state'] !== 'applied') {
|
|
throw new \RuntimeException('Implantação incompleta exige recuperação.');
|
|
}
|
|
$this->verifyApplied($journal, $previous['deployment_id']);
|
|
[, $oldManifest] = $this->manifest($this->path('packages/internal-requisitions'), $previous['manifest_sha256']);
|
|
if (array_keys($oldManifest['host_overlays']) !== array_keys($manifest['host_overlays'])) {
|
|
throw new \RuntimeException('Mudança de composição do host exige plano próprio.');
|
|
}
|
|
foreach (['integrations/sgf/operational/BudgetSchema.php', 'integrations/sgf/operational/BudgetUiSchema.php', 'integrations/sgf/operational/SqlPrivileges.php', 'integrations/sgf/operational/Installation.php', 'integrations/sgf/operational/budget-contract.json', 'integrations/sgf/internal-requisitions/Schema.php', 'integrations/sgf/access/AccessOverlay.php'] as $file) {
|
|
if (($oldManifest['support_files'][$file] ?? null) !== ($manifest['support_files'][$file] ?? null)) {
|
|
$receiptPath=$this->path('storage/app/private/module-deployments/schema-transition-'.$manifestHash.'.json');
|
|
$receipt=is_file($receiptPath)?json_decode(file_get_contents($receiptPath),true,32,JSON_THROW_ON_ERROR):[];
|
|
if(($receipt['from_manifest_sha256']??null)!==$previous['manifest_sha256']||($receipt['to_manifest_sha256']??null)!==$manifestHash||($receipt['host']??null)!==$this->host||($receipt['changes'][$file]['before']??null)!==($oldManifest['support_files'][$file]??null)||($receipt['changes'][$file]['after']??null)!==($manifest['support_files'][$file]??null)||($receipt['schema_verified']??false)!==true){
|
|
throw new \RuntimeException('Transição de schema exige migração própria e recibo exacto do destino.');
|
|
}
|
|
}
|
|
}
|
|
} elseif ($previous !== null) {
|
|
throw new \RuntimeException('Registo activo sem bundle; recuperar a implantação anterior.');
|
|
}
|
|
$before = [];
|
|
foreach ($manifest['host_overlays'] as $file => $hashes) {
|
|
$hash = hash_file('sha256', $this->path($file));
|
|
$expected = $oldManifest === null ? $hashes['before_sha256'] : $oldManifest['host_overlays'][$file]['after_sha256'];
|
|
if ($oldManifest !== null && $hashes['before_sha256'] !== $oldManifest['host_overlays'][$file]['before_sha256']) {
|
|
throw new \RuntimeException('Baseline do host mudou entre versões.');
|
|
}
|
|
if ($hash !== $expected) {
|
|
throw new \RuntimeException('Host alterado; reconciliar antes de implantar.');
|
|
}
|
|
$before[$file] = $hash;
|
|
}
|
|
$target = hash('sha256', json_encode([$this->host, $pointerHash, $before, $previous], JSON_THROW_ON_ERROR));
|
|
|
|
return ['target_sha256' => $target, 'pointer_sha256' => $pointerHash, 'host_files' => $before, 'manifest_sha256' => $manifestHash, 'previous' => $previous, 'deployment_id' => $this->attempt($manifestHash), 'writes' => false];
|
|
}
|
|
|
|
private function current(): ?array
|
|
{
|
|
$path = $this->path('storage/app/private/module-deployments/current.json');
|
|
if (! is_file($path)) {
|
|
return null;
|
|
}
|
|
$value = json_decode(file_get_contents($path), true, 16, JSON_THROW_ON_ERROR);
|
|
if (! preg_match('/^[a-f0-9]{24}$/D', $value['deployment_id'] ?? '') || ! preg_match('/^[a-f0-9]{64}$/D', $value['manifest_sha256'] ?? '')) {
|
|
throw new \RuntimeException('Registo activo inválido.');
|
|
}
|
|
|
|
return $value;
|
|
}
|
|
|
|
private function attempt(string $hash): string
|
|
{
|
|
for ($i = 0; $i < 1000; $i++) {
|
|
$id = substr($i === 0 ? $hash : hash('sha256', $hash.':'.$i), 0, 24);
|
|
$file = $this->path('storage/app/private/module-deployments/'.$id.'/journal.json');
|
|
if (! is_file($file) || json_decode(file_get_contents($file), true, 32, JSON_THROW_ON_ERROR)['state'] !== 'rolled-back') {
|
|
return $id;
|
|
}
|
|
}
|
|
throw new \RuntimeException('Reconciliar histórico de tentativas.');
|
|
}
|
|
|
|
private function offline(bool $stopped): void
|
|
{
|
|
$this->cachePaths();
|
|
if (! $stopped || ! is_file($this->path('storage/framework/down'))) {
|
|
throw new \RuntimeException('Exige manutenção activa e serviços de escrita parados.');
|
|
}
|
|
if (! is_array(json_decode(file_get_contents($this->path('storage/framework/down')), true))) {
|
|
throw new \RuntimeException('Registo de manutenção inválido.');
|
|
}
|
|
}
|
|
|
|
private function cachePaths(): void
|
|
{
|
|
$expected = ['APP_CONFIG_CACHE' => 'bootstrap/cache/config.php', 'APP_EVENTS_CACHE' => 'bootstrap/cache/events.php', 'APP_PACKAGES_CACHE' => 'bootstrap/cache/packages.php', 'APP_SERVICES_CACHE' => 'bootstrap/cache/services.php', 'APP_ROUTES_CACHE' => 'bootstrap/cache/routes-v7.php'];
|
|
$values = [];
|
|
$env = $this->path('.env');
|
|
if (is_file($env)) {
|
|
foreach (file($env, FILE_IGNORE_NEW_LINES) as $line) {
|
|
if (preg_match('/^\s*(APP_(?:CONFIG|EVENTS|PACKAGES|SERVICES|ROUTES)_CACHE)\s*=\s*(.*?)\s*$/D', $line, $match)) {
|
|
$values[$match[1]] = trim($match[2], "\"'");
|
|
}
|
|
}
|
|
}
|
|
foreach ($expected as $key => $relative) {
|
|
$value = getenv($key);
|
|
if ($value === false) {
|
|
$value = $values[$key] ?? '';
|
|
}
|
|
if ($value !== '' && str_replace('\\', '/', $value) !== $relative && str_replace('\\', '/', $value) !== $this->host.'/'.$relative) {
|
|
throw new \RuntimeException('Cache Laravel personalizado exige plano de implantação próprio.');
|
|
}
|
|
}
|
|
}
|
|
|
|
private function locked(callable $work): mixed
|
|
{
|
|
$lock = $this->path('storage/app/private/module-deployments/deployment.lock');
|
|
if (! is_dir(dirname($lock))) {
|
|
mkdir(dirname($lock), 0700, true);
|
|
}
|
|
$stream = fopen($lock, 'c+');
|
|
if (! $stream || ! flock($stream, LOCK_EX | LOCK_NB)) {
|
|
throw new \RuntimeException('Outra implantação em curso.');
|
|
}
|
|
try {
|
|
return $work();
|
|
} finally {
|
|
flock($stream, LOCK_UN);
|
|
fclose($stream);
|
|
}
|
|
}
|
|
|
|
private function journal(string $id, array $data): void
|
|
{
|
|
self::write($this->path('storage/app/private/module-deployments/'.$id.'/journal.json'), json_encode($data, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR));
|
|
}
|
|
|
|
private function caches(): array
|
|
{
|
|
$out = [];
|
|
foreach (glob($this->path('bootstrap/cache').'/*.php') ?: [] as $file) {
|
|
if (! in_array(basename($file), ['config.php', 'events.php', 'packages.php', 'services.php'], true) && ! preg_match('/^routes(-v[0-9]+)?\.php$/D', basename($file))) {
|
|
continue;
|
|
}
|
|
$relative = 'bootstrap/cache/'.basename($file);
|
|
$out[$relative] = hash_file('sha256', $this->path($relative));
|
|
}
|
|
ksort($out);
|
|
|
|
return $out;
|
|
}
|
|
|
|
public function rebuildCaches(string $id, string $operator, bool $stopped, ?callable $checkpoint = null): array
|
|
{
|
|
if (! preg_match('/^[a-f0-9]{24}$/D', $id)) {
|
|
throw new \RuntimeException('Implantação inválida.');
|
|
}
|
|
|
|
return $this->locked(function () use ($id, $operator, $stopped, $checkpoint) {
|
|
$this->offline($stopped);
|
|
if (trim($operator) === '') {
|
|
throw new \RuntimeException('Operador obrigatório.');
|
|
}
|
|
$prefix = 'storage/app/private/module-deployments/'.$id;
|
|
$data = json_decode(file_get_contents($this->path($prefix.'/journal.json')), true, 32, JSON_THROW_ON_ERROR);
|
|
if ($data['state'] !== 'applied') {
|
|
throw new \RuntimeException('Código não está aplicado.');
|
|
}
|
|
$this->verifyApplied($data, $id);
|
|
$run = bin2hex(random_bytes(6));
|
|
$beforeCaches = $this->caches();
|
|
foreach ($beforeCaches as $file => $hash) {
|
|
self::write($this->path($prefix.'/cache-runs/'.$run.'/before/'.$file), file_get_contents($this->path($file)));
|
|
}
|
|
$data['cache_state'] = 'building';
|
|
$this->journal($id, $data);
|
|
if ($this->caches() !== $beforeCaches) {
|
|
throw new \RuntimeException('Cache mudou durante preparação da compilação.');
|
|
}
|
|
// Clear before booting Artisan: an interrupted config cache must never prevent recovery.
|
|
foreach ($beforeCaches as $file => $hash) {
|
|
if (! unlink($this->path($file))) {
|
|
throw new \RuntimeException('Falha ao invalidar cache antes da compilação.');
|
|
}
|
|
}
|
|
$failure = null;
|
|
$commands = [];
|
|
try {
|
|
foreach (['config:cache', 'route:cache', 'event:cache'] as $command) {
|
|
$log = $this->path($prefix.'/cache-runs/'.$run.'/native-cache.log');
|
|
if (! is_dir(dirname($log))) {
|
|
mkdir(dirname($log), 0700, true);
|
|
}
|
|
$process = proc_open([PHP_BINARY, $this->path('artisan'), $command], [0 => ['pipe', 'r'], 1 => ['file', $log, 'a'], 2 => ['file', $log, 'a']], $pipes, $this->host);
|
|
if (! is_resource($process)) {
|
|
throw new \RuntimeException('Não foi possível iniciar a compilação nativa.');
|
|
}
|
|
fclose($pipes[0]);
|
|
$exit = proc_close($process);
|
|
$commands[] = ['command' => $command, 'exit_code' => $exit];
|
|
if ($exit !== 0) {
|
|
throw new \RuntimeException('Falha na compilação nativa de cache; consultar log privado.');
|
|
}
|
|
$checkpoint && $checkpoint($command);
|
|
}
|
|
} catch (\Throwable $error) {
|
|
$failure = $error;
|
|
} finally {
|
|
$data['built_cache_files'] = $this->caches();
|
|
$data['cache_state'] = $failure ? 'failed' : 'built';
|
|
$data['cache_runs'][] = ['id' => $run, 'operator' => $operator, 'at' => gmdate('c'), 'commands' => $commands, 'state' => $data['cache_state']];
|
|
$this->journal($id, $data);
|
|
}
|
|
if ($failure) {
|
|
throw $failure;
|
|
}
|
|
|
|
return ['deployment_id' => $id, 'cache_state' => 'built', 'restart_required' => true, 'maintenance_preserved' => true];
|
|
});
|
|
}
|
|
|
|
public function apply(string $bundle, string $manifestHash, string $target, string $operator, bool $stopped, ?callable $checkpoint = null): array
|
|
{
|
|
return $this->locked(function () use ($bundle, $manifestHash, $target, $operator, $stopped, $checkpoint) {
|
|
$this->offline($stopped);
|
|
if (trim($operator) === '') {
|
|
throw new \RuntimeException('Operador nominativo obrigatório.');
|
|
}
|
|
[$bundle, $manifest] = $this->manifest($bundle, $manifestHash);
|
|
$id = $this->attempt($manifestHash);
|
|
$journalPath = $this->path('storage/app/private/module-deployments/'.$id.'/journal.json');
|
|
if (is_file($journalPath)) {
|
|
$old = json_decode(file_get_contents($journalPath), true, 32, JSON_THROW_ON_ERROR);
|
|
if ($old['state'] !== 'applied') {
|
|
throw new \RuntimeException('Implantação anterior exige recuperação explícita.');
|
|
}
|
|
$this->verifyApplied($old, $id);
|
|
|
|
return ['deployment_id' => $id, 'state' => 'applied', 'replayed' => true, 'restart_required' => true];
|
|
}
|
|
$plan = $this->plan($bundle, $manifestHash);
|
|
if (! hash_equals($target, $plan['target_sha256'])) {
|
|
throw new \RuntimeException('Destino de código divergente.');
|
|
}
|
|
$package = $this->path('packages/internal-requisitions');
|
|
$prefix = 'storage/app/private/module-deployments/'.$id;
|
|
$data = ['state' => 'prepared', 'operator' => $operator, 'created_at' => gmdate('c'), 'manifest_sha256' => $manifestHash,
|
|
'host_identity' => hash('sha256', $this->host),
|
|
'previous' => $plan['previous'],
|
|
'pointer_sha256' => $plan['pointer_sha256'], 'files' => [], 'cache_files' => [], 'bundle_files' => $manifest['support_files']];
|
|
foreach ($manifest['host_overlays'] as $file => $hashes) {
|
|
self::write($this->path($prefix.'/before/'.$file), file_get_contents($this->path($file)));
|
|
$hashes['before_sha256'] = $plan['host_files'][$file];
|
|
$data['files'][$file] = $hashes;
|
|
}
|
|
foreach (glob($this->path('bootstrap/cache').'/*.php') ?: [] as $cache) {
|
|
if (! in_array(basename($cache), ['config.php', 'events.php', 'packages.php', 'services.php'], true) && ! preg_match('/^routes(-v[0-9]+)?\.php$/D', basename($cache))) {
|
|
continue;
|
|
}
|
|
$relative = 'bootstrap/cache/'.basename($cache);
|
|
$this->path($relative);
|
|
self::write($this->path($prefix.'/before/'.$relative), file_get_contents($cache));
|
|
$data['cache_files'][$relative] = hash_file('sha256', $cache);
|
|
}
|
|
foreach ($manifest['support_files'] as $file => $hash) {
|
|
self::write($this->path($prefix.'/staged/'.$file), file_get_contents($bundle.'/'.$file));
|
|
}
|
|
self::write($this->path($prefix.'/staged/manifest.json'), file_get_contents($bundle.'/manifest.json'));
|
|
$this->journal($id, $data);
|
|
$checkpoint && $checkpoint('prepared');
|
|
$data['state'] = 'applying';
|
|
$this->journal($id, $data);
|
|
foreach ($data['cache_files'] as $file => $hash) {
|
|
if (! hash_equals($hash, hash_file('sha256', $this->path($file)))) {
|
|
throw new \RuntimeException('Cache mudou durante implantação.');
|
|
}
|
|
if (! unlink($this->path($file))) {
|
|
throw new \RuntimeException('Não foi possível invalidar cache.');
|
|
}
|
|
}
|
|
$checkpoint && $checkpoint('caches-cleared');
|
|
foreach ($data['files'] as $file => $hashes) {
|
|
if (! hash_equals($hashes['before_sha256'], hash_file('sha256', $this->path($file)))) {
|
|
throw new \RuntimeException('Ficheiro mudou durante implantação.');
|
|
}
|
|
self::write($this->path($file), file_get_contents($bundle.'/'.$file));
|
|
$checkpoint && $checkpoint('file:'.$file);
|
|
}
|
|
// Both resolved paths are checked within the host before this directory move.
|
|
if ($data['previous'] !== null) {
|
|
if (! rename($package, $this->path($prefix.'/previous-bundle'))) {
|
|
throw new \RuntimeException('Falha ao preservar bundle anterior.');
|
|
}
|
|
$checkpoint && $checkpoint('previous-bundle-preserved');
|
|
}
|
|
if (! rename($this->path($prefix.'/staged'), $package)) {
|
|
throw new \RuntimeException('Falha na troca do bundle.');
|
|
}
|
|
$checkpoint && $checkpoint('bundle-switched');
|
|
$this->verifyApplied($data, $id, false);
|
|
self::write($this->path('storage/app/private/module-deployments/current.json'), json_encode(['deployment_id' => $id, 'manifest_sha256' => $manifestHash], JSON_THROW_ON_ERROR));
|
|
$data['state'] = 'applied';
|
|
$data['applied_at'] = gmdate('c');
|
|
$this->journal($id, $data);
|
|
|
|
return ['deployment_id' => $id, 'state' => 'applied', 'restart_required' => true, 'maintenance_preserved' => true, 'configuration_changed' => false, 'activation_performed' => false];
|
|
});
|
|
}
|
|
|
|
private function verifyApplied(array $data, string $id, bool $checkCurrent = true): void
|
|
{
|
|
if ($checkCurrent && $this->current() !== ['deployment_id' => $id, 'manifest_sha256' => $data['manifest_sha256']]) {
|
|
throw new \RuntimeException('A implantação já não é a versão activa.');
|
|
}
|
|
if (! hash_equals($data['host_identity'], hash('sha256', $this->host))) {
|
|
throw new \RuntimeException('Registo pertence a outro host.');
|
|
}
|
|
if (! hash_equals($data['pointer_sha256'], hash_file('sha256', $this->path('packages/purchasing/current.json')))) {
|
|
throw new \RuntimeException('Ponteiro de Compras mudou.');
|
|
}
|
|
$this->manifest($this->path('packages/internal-requisitions'), $data['manifest_sha256']);
|
|
foreach ($data['files'] as $file => $hashes) {
|
|
if (! hash_equals($hashes['after_sha256'], hash_file('sha256', $this->path($file)))) {
|
|
throw new \RuntimeException('Código aplicado alterado.');
|
|
}
|
|
}
|
|
foreach ($data['bundle_files'] as $file => $hash) {
|
|
if (! hash_equals($hash, hash_file('sha256', $this->path('packages/internal-requisitions/'.$file)))) {
|
|
throw new \RuntimeException('Bundle aplicado alterado.');
|
|
}
|
|
}
|
|
}
|
|
|
|
public function rollback(string $id, string $operator, bool $stopped): array
|
|
{
|
|
if (! preg_match('/^[a-f0-9]{24}$/D', $id)) {
|
|
throw new \RuntimeException('Implantação inválida.');
|
|
}
|
|
|
|
return $this->locked(function () use ($id, $operator, $stopped) {
|
|
$this->offline($stopped);
|
|
if (trim($operator) === '') {
|
|
throw new \RuntimeException('Operador obrigatório.');
|
|
}
|
|
$prefix = 'storage/app/private/module-deployments/'.$id;
|
|
$data = json_decode(file_get_contents($this->path($prefix.'/journal.json')), true, 32, JSON_THROW_ON_ERROR);
|
|
$active = $this->current();
|
|
$own = ['deployment_id' => $id, 'manifest_sha256' => $data['manifest_sha256']];
|
|
if ($active !== $own && $active !== ($data['previous'] ?? null)) {
|
|
throw new \RuntimeException('Outra versão está activa; recusar reversão fora de ordem.');
|
|
}
|
|
if (! hash_equals($data['host_identity'], hash('sha256', $this->host))) {
|
|
throw new \RuntimeException('Registo pertence a outro host.');
|
|
}
|
|
if (! hash_equals($data['pointer_sha256'], hash_file('sha256', $this->path('packages/purchasing/current.json')))) {
|
|
throw new \RuntimeException('Ponteiro divergiu; não recuperar por arrasto.');
|
|
}
|
|
if ($data['state'] === 'rolled-back') {
|
|
return ['deployment_id' => $id, 'state' => 'rolled-back', 'replayed' => true, 'restart_required' => true, 'maintenance_preserved' => true, 'data_schema_changed' => false];
|
|
}
|
|
$rebuilt = array_key_exists('built_cache_files', $data);
|
|
if ($rebuilt && $this->caches() !== $data['built_cache_files']) {
|
|
throw new \RuntimeException('Cache mudou após compilação registada.');
|
|
}
|
|
foreach ($data['files'] as $file => $hashes) {
|
|
if (! in_array(hash_file('sha256', $this->path($file)), [$hashes['before_sha256'], $hashes['after_sha256']], true)) {
|
|
throw new \RuntimeException('Código local alterado; recuperação recusada.');
|
|
}
|
|
if (! hash_equals($hashes['before_sha256'], hash_file('sha256', $this->path($prefix.'/before/'.$file)))) {
|
|
throw new \RuntimeException('Backup de código divergente.');
|
|
}
|
|
}
|
|
foreach ($data['cache_files'] as $file => $hash) {
|
|
if (! hash_equals($hash, hash_file('sha256', $this->path($prefix.'/before/'.$file)))) {
|
|
throw new \RuntimeException('Backup de cache divergente.');
|
|
}
|
|
if (! $rebuilt && is_file($this->path($file)) && ! hash_equals($hash, hash_file('sha256', $this->path($file)))) {
|
|
throw new \RuntimeException('Cache recriado; reconciliar antes de recuperar.');
|
|
}
|
|
}
|
|
if (! $rebuilt && array_diff(array_keys($this->caches()), array_keys($data['cache_files'])) !== []) {
|
|
throw new \RuntimeException('Cache recriado; reconciliar antes de recuperar.');
|
|
}
|
|
if (is_dir($this->path($prefix.'/previous-bundle'))) {
|
|
$this->manifest($this->path($prefix.'/previous-bundle'), $data['previous']['manifest_sha256']);
|
|
}
|
|
if (is_dir($this->path('packages/internal-requisitions'))) {
|
|
$isPrevious = $active === ($data['previous'] ?? null) && $data['previous'] !== null && ! is_dir($this->path($prefix.'/previous-bundle'));
|
|
if ($isPrevious) {
|
|
$this->manifest($this->path('packages/internal-requisitions'), $data['previous']['manifest_sha256']);
|
|
}
|
|
if (! $isPrevious) {
|
|
$this->manifest($this->path('packages/internal-requisitions'), $data['manifest_sha256']);
|
|
foreach ($data['bundle_files'] as $file => $hash) {
|
|
if (! hash_equals($hash, hash_file('sha256', $this->path('packages/internal-requisitions/'.$file)))) {
|
|
throw new \RuntimeException('Bundle alterado; recuperação recusada.');
|
|
}
|
|
}
|
|
if (! rename($this->path('packages/internal-requisitions'), $this->path($prefix.'/withdrawn'))) {
|
|
throw new \RuntimeException('Falha ao retirar bundle.');
|
|
}
|
|
}
|
|
}
|
|
if (is_dir($this->path($prefix.'/previous-bundle'))) {
|
|
$this->manifest($this->path($prefix.'/previous-bundle'), $data['previous']['manifest_sha256']);
|
|
if (! rename($this->path($prefix.'/previous-bundle'), $this->path('packages/internal-requisitions'))) {
|
|
throw new \RuntimeException('Falha ao repor bundle anterior.');
|
|
}
|
|
}
|
|
$data['state'] = 'rolling-back';
|
|
$this->journal($id, $data);
|
|
foreach ($data['files'] as $file => $hashes) {
|
|
self::write($this->path($file), file_get_contents($this->path($prefix.'/before/'.$file)));
|
|
}
|
|
if ($rebuilt) {
|
|
foreach ($data['built_cache_files'] as $file => $hash) {
|
|
self::write($this->path($prefix.'/rollback-cache/'.$file), file_get_contents($this->path($file)));
|
|
unlink($this->path($file));
|
|
}
|
|
$data['cache_rollback'] = 'invalidated-rebuilt';
|
|
} else {
|
|
foreach ($data['cache_files'] as $file => $hash) {
|
|
self::write($this->path($file), file_get_contents($this->path($prefix.'/before/'.$file)));
|
|
}
|
|
}
|
|
$currentPath = $this->path('storage/app/private/module-deployments/current.json');
|
|
if ($data['previous'] !== null) {
|
|
self::write($currentPath, json_encode($data['previous'], JSON_THROW_ON_ERROR));
|
|
} elseif (is_file($currentPath)) {
|
|
unlink($currentPath);
|
|
}
|
|
$data['state'] = 'rolled-back';
|
|
$data['rollback_operator'] = $operator;
|
|
$data['rolled_back_at'] = gmdate('c');
|
|
$this->journal($id, $data);
|
|
|
|
return ['deployment_id' => $id, 'state' => 'rolled-back', 'restart_required' => true, 'maintenance_preserved' => true, 'data_schema_changed' => false];
|
|
});
|
|
}
|
|
}
|