dpl/operations.php

333 lines
18 KiB
PHP

<?php
/** Shared CLI helpers; loading this file does not bootstrap Laravel or open a connection. */
namespace DplOnboarding;
use App\Models\{Server, ServerGroup, User, Plan, Setting};
use Illuminate\Support\Facades\{DB, Cache};
use RuntimeException;
function ids(string $value): array
{
if (!preg_match('/^[1-9][0-9]*(,[1-9][0-9]*)*$/D', $value)) {
throw new RuntimeException('Expected explicit comma-separated positive IDs');
}
$ids = array_map('intval', explode(',', $value));
if (count($ids) !== count(array_unique($ids))) throw new RuntimeException('Duplicate IDs');
return $ids;
}
function readJson(string $path): array
{
if (!is_file($path) || is_link($path)) throw new RuntimeException('Required regular journal/manifest missing');
$data = json_decode(file_get_contents($path), true, flags: JSON_THROW_ON_ERROR);
if (!is_array($data)) throw new RuntimeException('Invalid JSON document');
return $data;
}
function writeJson(string $path, array $data): void
{
if (is_link($path)) throw new RuntimeException('Refusing journal symlink');
$tmp = tempnam(dirname($path), '.onboarding-');
if ($tmp === false) throw new RuntimeException('Cannot create journal');
try {
chmod($tmp, 0600);
$body = json_encode($data, JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) . "\n";
if (file_put_contents($tmp, $body) !== strlen($body) || !rename($tmp, $path)) {
throw new RuntimeException('Cannot persist journal');
}
} finally {
if (is_file($tmp)) unlink($tmp);
}
}
function manifest(string $dir): array
{
$m = readJson($dir . '/manifest.json');
foreach (['node_id','machine_id','group_id','test_user_id'] as $field) {
if (!isset($m[$field]) || !preg_match('/^[1-9][0-9]*$/D', (string)$m[$field])) {
throw new RuntimeException('Manifest missing positive object ID');
}
$m[$field] = (int)$m[$field];
}
if (!isset($m['key']) || !preg_match('/^[a-z0-9-]+$/D', $m['key'])) throw new RuntimeException('Invalid manifest key');
return $m;
}
function locked(string $dir, callable $fn): mixed
{
if (!is_dir($dir) || is_link($dir) || (fileperms($dir)&0077)!==0) throw new RuntimeException('Existing private backup directory required');
$path = $dir . '/operation.lock';
if (is_link($path)) throw new RuntimeException('Refusing lock symlink');
$h = fopen($path, 'c');
if ($h === false) throw new RuntimeException('Cannot open operation lock');
chmod($path, 0600);
try {
if (!flock($h, LOCK_EX | LOCK_NB)) throw new RuntimeException('Another operation is active');
return $fn();
} finally { flock($h, LOCK_UN); fclose($h); }
}
function boot(): void
{
require getcwd() . '/vendor/autoload.php';
$app = require getcwd() . '/bootstrap/app.php';
$app->make(\Illuminate\Contracts\Console\Kernel::class)->bootstrap();
}
final class PanelChange
{
private const MODELS = ['node'=>Server::class,'qa'=>User::class,'user'=>User::class,
'plan'=>Plan::class,'group'=>ServerGroup::class,'setting'=>Setting::class];
private const FIELDS = ['node'=>['group_ids'],'qa'=>['banned'],'user'=>['group_id'],
'plan'=>['group_id'],'group'=>['name'],'setting'=>['value']];
private static function normalize(string $field, mixed $value): mixed
{
return match ($field) {
'group_ids' => array_map('strval', $value ?? []),
'group_id' => $value === null ? null : (int)$value,
'banned','enabled','show' => (bool)$value,
default => $value,
};
}
private static function object(string $kind, int|string $id): mixed
{
$class = self::MODELS[$kind] ?? throw new RuntimeException('Unsupported journal object');
return $class::where($kind === 'setting' ? 'name' : 'id', $id)->lockForUpdate()->first();
}
private static function value(mixed $model, string $field): mixed
{
return $model === null ? null : self::normalize($field, $model->getAttribute($field));
}
private static function operation(string $kind, mixed $model, string $field, mixed $after, int|string|null $id = null): ?array
{
if (!in_array($field, self::FIELDS[$kind] ?? [], true)) throw new RuntimeException('Unsupported journal field');
$before = self::value($model, $field);
$after = self::normalize($field, $after);
if ($model !== null && $before === $after) return null;
return ['kind'=>$kind,'id'=>$id ?? $model->getKey(),'field'=>$field,
'before_exists'=>$model !== null,'before'=>$before,'after'=>$after];
}
private static function context(array $m): array
{
$node = self::object('node', $m['node_id']) ?? throw new RuntimeException('Manifest node missing');
$qa = self::object('qa', $m['test_user_id']) ?? throw new RuntimeException('Manifest QA missing');
if ((int)$node->machine_id !== $m['machine_id'] || $node->type !== 'anytls') {
throw new RuntimeException('Manifest node/machine mismatch');
}
if ($qa->is_admin || (int)$qa->group_id !== $m['group_id'] ||
!str_starts_with($qa->email, 'node-qa-' . $m['key'] . '-') ||
!str_ends_with($qa->email, '@example.invalid')) {
throw new RuntimeException('Manifest does not identify an isolated QA user');
}
return [$node, $qa];
}
private static function checkJournal(array $j, array $m): void
{
if (($j['version'] ?? null) !== 1 || ($j['manifest'] ?? null) !== $m || !is_array($j['operations'] ?? null)) {
throw new RuntimeException('Journal/manifest mismatch or unsupported version');
}
foreach ($j['operations'] as $op) {
if (!in_array($op['field'], self::FIELDS[$op['kind']] ?? [], true)) throw new RuntimeException('Invalid journal operation');
if ($op['kind']==='node' && $op['id']!==$m['node_id']) throw new RuntimeException('Wrong node in journal');
if ($op['kind']==='qa' && $op['id']!==$m['test_user_id']) throw new RuntimeException('Wrong QA in journal');
if ($op['kind']==='setting' && $op['id']!=='subscribe_url') throw new RuntimeException('Wrong setting in journal');
if ($j['request']['mode']==='append' && !in_array($op['kind'], ['node','qa'], true)) {
throw new RuntimeException('Append journal contains shared-resource writes');
}
}
}
private static function current(array $op): array
{
$model = self::object($op['kind'], $op['id']);
return [$model, $model !== null, self::value($model, $op['field'])];
}
private static function applyOperation(array $op): void
{
[$model, $exists, $value] = self::current($op);
if ($exists !== $op['before_exists'] || $value !== $op['before']) throw new RuntimeException('Object changed before application');
if (!$exists) {
if ($op['kind'] !== 'setting') throw new RuntimeException('Missing target');
$model = new Setting(['name'=>$op['id']]);
}
$model->setAttribute($op['field'], $op['after']);
// Keep model observers for node full-sync notifications; QA does not send notifications.
$op['kind'] === 'qa' ? $model->saveQuietly() : $model->save();
}
private static function flushSettings(array $ops): void
{
if (array_filter($ops, fn($op) => $op['kind']==='setting')) {
// Called after commit; clearing request-local state also makes retry recovery safe.
Cache::store('redis')->forget(\App\Support\Setting::CACHE_KEY);
app()->forgetInstance(\App\Support\Setting::class);
}
}
private static function notifyCommitted(array $m): void
{
// Explicitly repeat idempotent invalidations after commit. If an observer's
// after-commit callback failed, prepared+after recovery must still notify.
\App\Services\NodeSyncService::notifyFullSync($m['node_id']);
\App\Services\NodeSyncService::notifyMachineNodesChanged($m['machine_id']);
}
private static function verifyEvidence(array $request, array $m, mixed $node): void
{
$report = readJson($request['verification'] ?? throw new RuntimeException('Missing --verification live QA report'));
$at = $report['verified_at'] ?? 0;
if (($report['state'] ?? '') !== 'live_verified' || ($report['qa_user_id'] ?? null) !== $m['test_user_id'] ||
!is_int($at) || $at > time() || time()-$at > 900) {
throw new RuntimeException('Recent live verification for this manifest QA required');
}
$sni = $node->protocol_settings['tls']['server_name'] ?? null;
$targets = array_filter($report['results'] ?? [], fn($row) =>
($row['live'] ?? false) === true && ($row['strict_tls'] ?? false) === true &&
($row['relay'] ?? null) === $node->host && (int)($row['relay_port'] ?? 0) === (int)$node->port &&
($row['sni'] ?? null) === $sni);
if (count($targets)!==1) throw new RuntimeException('Live evidence endpoint/SNI mismatch');
}
public static function promote(string $dir, array $request, bool $apply): array
{
$mode = $request['mode'] ?? '';
if (!in_array($mode, ['append','initialize'], true)) throw new RuntimeException('Explicit append/initialize mode required');
$request['group'] = isset($request['group']) ? (int)$request['group'] : null;
if ($mode==='append' && (!$request['group'] || isset($request['plans']) || isset($request['subscribe-url']) || isset($request['group-name']))) {
throw new RuntimeException('Append requires group and forbids plans, subscribe-url and group-name');
}
if ($mode==='initialize') {
$request['plan_ids'] = ids($request['plans'] ?? '');
$url = $request['subscribe-url'] ?? '';
$parts = parse_url($url);
if (!is_array($parts) || ($parts['scheme'] ?? '')!=='https' || empty($parts['host']) || isset($parts['user']) || isset($parts['pass']) || isset($parts['query']) || isset($parts['fragment'])) {
throw new RuntimeException('Initialize requires an explicit HTTPS subscribe-url without credentials/query');
}
}
ksort($request);
$m = manifest($dir); $path = $dir . '/promotion-journal.json';
$result = DB::transaction(function () use ($dir,$request,$mode,$m,$path,$apply) {
[$node,$qa] = self::context($m);
if (is_file($path)) {
$j = readJson($path); self::checkJournal($j,$m);
if ($j['request'] !== $request) throw new RuntimeException('Existing operation has different parameters');
if (is_file($dir.'/rollback-journal.json')) throw new RuntimeException('Rollback started; use a new reviewed task');
$before = $after = true;
foreach ($j['operations'] as $op) {
[, $exists,$value] = self::current($op);
$before = $before && $exists===$op['before_exists'] && $value===$op['before'];
$after = $after && $exists && $value===$op['after'];
}
if (!$after && (!$before || $j['state']!=='prepared')) throw new RuntimeException('Journal conflict; inspect before retrying');
if (!$apply) return ['state'=>$after?'already_applied':'preview','journal'=>$j];
if (!$after) {
self::verifyEvidence($request,$m,$node);
if (!$node->is_online || (int)$qa->u+(int)$qa->d<=0 || $qa->banned) throw new RuntimeException('QA evidence no longer ready');
foreach ($j['operations'] as $op) self::applyOperation($op);
}
return ['state'=>'applied','journal'=>$j];
}
if (is_file($dir.'/promoted.json')) throw new RuntimeException('Legacy promotion exists without new journal; do not adopt automatically');
if (!$node->enabled || !$node->show || !$node->is_online || (int)$qa->u + (int)$qa->d <= 0 || $qa->banned) throw new RuntimeException('Fresh online/active QA traffic evidence missing');
if (self::value($node,'group_ids') !== [(string)$m['group_id']]) throw new RuntimeException('Node no longer in its isolated stage group');
$groupId = $request['group'] ?? $m['group_id'];
$group = self::object('group',$groupId) ?? throw new RuntimeException('Target group missing');
if ($mode==='append' && $groupId===$m['group_id']) throw new RuntimeException('Append requires an existing formal group');
$ops = [self::operation('node',$node,'group_ids',[(string)$groupId]), self::operation('qa',$qa,'banned',true)];
if ($mode==='initialize') {
$plans = Plan::whereIn('id',$request['plan_ids'])->orderBy('id')->lockForUpdate()->get();
if ($plans->count()!==count($request['plan_ids'])) throw new RuntimeException('Plan IDs do not all exist');
foreach ($plans as $plan) {
if ($plan->group_id !== null && (int)$plan->group_id!==$groupId) throw new RuntimeException('Plan belongs to another group');
$ops[] = self::operation('plan',$plan,'group_id',$groupId);
}
// Only the explicitly selected plans' unassigned users can change in initialize mode.
foreach (User::whereIn('plan_id',$request['plan_ids'])->whereNull('group_id')->orderBy('id')->lockForUpdate()->get() as $user) {
$ops[] = self::operation('user',$user,'group_id',$groupId);
}
if (isset($request['group-name'])) $ops[] = self::operation('group',$group,'name',$request['group-name']);
$setting = self::object('setting','subscribe_url');
$ops[] = self::operation('setting',$setting,'value',$request['subscribe-url'],'subscribe_url');
}
if ($apply) self::verifyEvidence($request,$m,$node);
$j = ['version'=>1,'state'=>'prepared','manifest'=>$m,'request'=>$request,
'operations'=>array_values(array_filter($ops)),
'node_guard'=>['enabled'=>(bool)$node->enabled,'show'=>(bool)$node->show]];
if (!$apply) return ['state'=>'preview','journal'=>$j];
writeJson($path,$j); // Durable intent before any DB write; recover prepared+after on retry.
foreach ($j['operations'] as $op) self::applyOperation($op);
return ['state'=>'applied','journal'=>$j];
},1);
if ($apply) {
self::flushSettings($result['journal']['operations']);
self::notifyCommitted($m);
$result['journal']['state']='applied'; writeJson($path,$result['journal']);
}
return $result;
}
public static function rollback(string $dir, bool $apply): array
{
$m = manifest($dir); $j = readJson($dir.'/promotion-journal.json'); self::checkJournal($j,$m);
$rollbackPath=$dir.'/rollback-journal.json';
$result=DB::transaction(function () use ($m,$j,$apply,$rollbackPath) {
[$node,$qa]=self::context($m);
if (!$qa->banned) throw new RuntimeException('QA must remain disabled during rollback');
if (is_file($rollbackPath)) {
$r=readJson($rollbackPath);
if (($r['promotion']??null)!==$j) throw new RuntimeException('Rollback journal mismatch');
} else {
// Rollback itself has a durable before/after journal, including node containment.
$undo=[];
foreach (array_reverse($j['operations']) as $op) {
if ($op['kind']==='qa') continue; // Never reactivate test users.
[$model,$exists,$value]=self::current($op);
if (!$exists || $value!==$op['after']) throw new RuntimeException('Rollback conflict: current value differs from recorded after');
$undo[]=['kind'=>$op['kind'],'id'=>$op['id'],'field'=>$op['field'],
'before_exists'=>true,'before'=>$op['after'],'after'=>$op['before'],
'delete'=>!$op['before_exists']];
}
foreach (['enabled','show'] as $field) {
if ((bool)$node->$field!==$j['node_guard'][$field]) throw new RuntimeException('Node containment field changed after promotion');
if ($node->$field) $undo[]=['kind'=>'node','id'=>$m['node_id'],'field'=>$field,
'before_exists'=>true,'before'=>true,'after'=>false,'delete'=>false];
}
if (!$qa->banned) throw new RuntimeException('QA reactivated after promotion; resolve conflict before rollback');
$r=['version'=>1,'state'=>'prepared','promotion'=>$j,'operations'=>$undo];
}
$before=$after=true;
foreach ($r['operations'] as $op) {
// Only rollback containment adds these two node fields.
if (!in_array($op['field'],self::FIELDS[$op['kind']]??[],true) && !($op['kind']==='node' && $op['id']===$m['node_id'] && in_array($op['field'],['enabled','show'],true))) {
throw new RuntimeException('Invalid rollback field');
}
[, $exists,$value]=self::current($op);
$before=$before && $exists && $value===$op['before'];
$after=$after && ($op['delete'] ? !$exists : ($exists && $value===$op['after']));
}
if (!$after && (!$before || $r['state']!=='prepared')) throw new RuntimeException('Rollback conflicts; no objects restored');
if (!$apply) return ['state'=>$after?'already_rolled_back':'preview','journal'=>$r];
if (!is_file($rollbackPath)) writeJson($rollbackPath,$r);
if (!$after) foreach ($r['operations'] as $op) {
[$model]=self::current($op);
if ($op['delete']) $model->delete();
else { $model->setAttribute($op['field'],$op['after']); $model->save(); }
}
return ['state'=>'rolled_back','journal'=>$r];
},1);
if ($apply) {
self::flushSettings($j['operations']);
self::notifyCommitted($m);
$result['journal']['state']='rolled_back';writeJson($rollbackPath,$result['journal']);
}
return $result;
}
}