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; } }