46 lines
9.4 KiB
PHP
46 lines
9.4 KiB
PHP
<?php
|
|
ini_set('zend.exception_ignore_args','1');
|
|
$app=(static fn($p)=>require $p)(__DIR__.'/portal-workflow-upgrade-runtime.php');
|
|
$root=dirname(__DIR__);$dir=$root.'/storage/app/private/investmoz-v2/portal-audit';
|
|
$plan=json_decode(file_get_contents($dir.'/workflow-upgrade-latest.json'),true,512,JSON_THROW_ON_ERROR);
|
|
$code=json_decode(file_get_contents($dir.'/workflow-upgrade-code-20261011.json'),true,512,JSON_THROW_ON_ERROR);
|
|
$pre=json_decode(file_get_contents($dir.'/workflow-upgrade-preflight-20261011.json'),true,512,JSON_THROW_ON_ERROR);
|
|
$source=Illuminate\Support\Facades\DB::connection()->getPdo();$source->exec('BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY');
|
|
$identity=$source->query('SELECT current_database() AS database,inet_server_port() AS port')->fetch(PDO::FETCH_ASSOC);
|
|
throw_unless($identity['database']===$plan['target']['database']&&(int)$identity['port']===55441,RuntimeException::class,'Exact isolated backup source required');
|
|
$preserved=0;$transient=['"public"."sessions"','"public"."cache"','"public"."cache_locks"'];
|
|
foreach($pre['original_rows']as$table=>$hashes){if(in_array($table,$transient,true))continue;
|
|
$columns=implode(',',array_map(fn($c)=>'"'.str_replace('"','""',$c).'"',$pre['original_columns'][$table]));
|
|
$rows=$source->query('SELECT row_to_json(t)::text FROM (SELECT '.$columns.' FROM '.$table.') t')->fetchAll(PDO::FETCH_COLUMN);$current=array_count_values(array_map(fn($r)=>hash('sha256',$r),$rows));
|
|
foreach(array_count_values($hashes)as$hash=>$count)throw_unless(($current[$hash]??0)>=$count,RuntimeException::class,'Original non-transient data altered in upgrade clone');$preserved++;
|
|
}
|
|
$ledger=$source->query("SELECT migration FROM migrations WHERE migration IN ('2026_10_09_030000_extend_opportunity_resources','2026_10_10_040000_create_portal_profile_authorization','2026_10_09_190000_prepare_investor_path','2026_10_10_050000_extend_organization_country') ORDER BY migration")->fetchAll(PDO::FETCH_COLUMN);
|
|
throw_unless($ledger===['2026_10_09_030000_extend_opportunity_resources','2026_10_10_040000_create_portal_profile_authorization','2026_10_10_050000_extend_organization_country'],RuntimeException::class,'Exact upgrade ledger required, without190000');
|
|
throw_unless(config('portal.organization_country_enabled')&&$source->query('SELECT count(*) FROM organizations')->fetchColumn()==0&&$source->query('SELECT count(*) FROM memberships')->fetchColumn()==0,RuntimeException::class,'COUNTRY02/legitimate master baseline altered');
|
|
throw_unless($source->query("SELECT count(*) FROM audit_events WHERE source_system='opportunity-resources-install'")->fetchColumn()==1&&$source->query("SELECT count(*) FROM audit_events WHERE source_system='portal-profile-installation' AND action='install'")->fetchColumn()==1,RuntimeException::class,'Installation/audit duplicated');
|
|
$stamp=gmdate('Ymd_His');$name='workflow_upgrade_restore_'.$stamp.'_test';$folder=$dir.'/workflow-upgrade-backups/'.$name;mkdir($folder,0700,true);
|
|
$run=static function(array $args):void{$p=new Symfony\Component\Process\Process($args,null,['PGPASSWORD'=>'']);$p->setTimeout(300);$p->run();throw_unless($p->isSuccessful(),RuntimeException::class,'QA snapshot/restore command failed');};$bin='C:/Program Files/PostgreSQL/17/bin/';
|
|
$snapshot=$source->query('SELECT pg_export_snapshot()')->fetchColumn();$dump=$folder.'/database.dump';
|
|
$run([$bin.'pg_dump.exe','-h','127.0.0.1','-p','55441','-U','postgres','-d',$identity['database'],'-Fc','--snapshot='.$snapshot,'-f',$dump]);
|
|
$admin=new PDO('pgsql:host=127.0.0.1;port=55441;dbname=postgres','postgres','');throw_unless((int)$admin->query('SELECT inet_server_port()')->fetchColumn()===55441,RuntimeException::class,'Isolated restore server required');$admin->exec('CREATE DATABASE "'.$name.'"');
|
|
$run([$bin.'pg_restore.exe','-h','127.0.0.1','-p','55441','-U','postgres','-d',$name,'--exit-on-error','--no-owner','--no-privileges',$dump]);
|
|
$restore=new PDO('pgsql:host=127.0.0.1;port=55441;dbname='.$name,'postgres','');$restore->exec('BEGIN READ ONLY');$tables=[];
|
|
foreach($source->query("SELECT schemaname,tablename FROM pg_tables WHERE schemaname NOT IN ('pg_catalog','information_schema') ORDER BY schemaname,tablename")->fetchAll(PDO::FETCH_ASSOC)as$t){$table='"'.$t['schemaname'].'"."'.$t['tablename'].'"';$query='SELECT row_to_json(t)::text FROM '.$table.' t ORDER BY row_to_json(t)::text';$a=$source->query($query)->fetchAll(PDO::FETCH_COLUMN);$b=$restore->query($query)->fetchAll(PDO::FETCH_COLUMN);throw_unless($a===$b,RuntimeException::class,'Restored upgrade table differs');$tables[$table]=['count'=>count($a),'sha256'=>hash('sha256',implode("\n",$a))];}
|
|
$sql="SELECT * FROM pg_sequences WHERE schemaname NOT IN ('pg_catalog','information_schema') ORDER BY schemaname,sequencename";$seq=$source->query($sql)->fetchAll(PDO::FETCH_ASSOC);throw_unless($seq===$restore->query($sql)->fetchAll(PDO::FETCH_ASSOC),RuntimeException::class,'Restored upgrade sequence differs');
|
|
$files=$folder.'/files.zip';$zip=new ZipArchive;throw_unless($zip->open($files,ZipArchive::CREATE|ZipArchive::EXCL)===true,RuntimeException::class,'New QA archive required');$manifest=[];$privateRoot=realpath(storage_path('app/private'));
|
|
foreach($source->query('SELECT DISTINCT storage_key FROM documents WHERE storage_key IS NOT NULL')->fetchAll(PDO::FETCH_COLUMN)as$key){$path=realpath(storage_path('app/private/'.$key));throw_unless($path&&str_starts_with(strtolower($path),strtolower($privateRoot.DIRECTORY_SEPARATOR)),RuntimeException::class,'Referenced original missing/outside private root');$entry='private/'.str_replace('\\','/',$key);$zip->addFile($path,$entry);$manifest[$entry]=hash_file('sha256',$path);}
|
|
$zip->addFile($plan['source'].'/.env','runtime/clone.env');$manifest['runtime/clone.env']=hash_file('sha256',$plan['source'].'/.env');
|
|
foreach($code['code_files']as$key=>$hash){throw_unless(hash_file('sha256',$plan['source'].'/'.$key)===$hash,RuntimeException::class,'Candidate code changed');$entry='code/'.$key;$zip->addFile($plan['source'].'/'.$key,$entry);$manifest[$entry]=$hash;}
|
|
$build=json_decode(file_get_contents($dir.'/workflow-upgrade-production-build-20261011.json'),true,512,JSON_THROW_ON_ERROR);foreach($build['assets']as$key=>$hash){$path=$plan['source'].'/public/build/'.$key;throw_unless(hash_file('sha256',$path)===$hash,RuntimeException::class,'Production bundle changed');$entry='build/'.$key;$zip->addFile($path,$entry);$manifest[$entry]=$hash;}$zip->close();
|
|
$z=new ZipArchive;$z->open($files);$restored=$folder.'/files-restored';mkdir($restored,0700,true);foreach($manifest as$key=>$hash){throw_unless(!str_contains($key,':')&&!array_intersect(explode('/',$key),['.','..']),RuntimeException::class,'Archive path refused');$path=$restored.'/'.$key;if(!is_dir(dirname($path)))mkdir(dirname($path),0700,true);file_put_contents($path,$z->getFromName($key));throw_unless(hash_file('sha256',$path)===$hash,RuntimeException::class,'Restored private bytes differ');}$z->close();
|
|
$mfa=0;foreach($restore->query('SELECT two_factor_secret,two_factor_recovery_codes FROM users WHERE two_factor_secret IS NOT NULL')->fetchAll(PDO::FETCH_ASSOC)as$row){foreach($row as$value){if($value!==null){$plain=app('encrypter')->decrypt($value,false);throw_unless(is_string($plain)&&$plain!=='',RuntimeException::class,'Restored MFA envelope unreadable');unset($plain);}}$mfa++;}
|
|
$source->exec('ROLLBACK');$restore->exec('ROLLBACK');
|
|
$proof=['result'=>'passed','verified_at'=>gmdate('c'),'target'=>$identity,'restored'=>['database'=>$name,'port'=>55441],'code_revision'=>$code['code_revision'],'code_manifest'=>$code['code_files'],
|
|
'dump_path'=>$dump,'dump_sha256'=>hash_file('sha256',$dump),'sha256'=>hash_file('sha256',$dump),'files_path'=>$files,'files_sha256'=>hash_file('sha256',$files),'restore_verified'=>true,'files_verified'=>true,'compatibility_verified'=>true,'reversal_verified'=>true,'qa_passed'=>true,
|
|
'table_count'=>count($tables),'sequence_count'=>count($seq),'file_count'=>count($manifest),'original_non_transient_tables_preserved'=>$preserved,'original_users_and_privileges_not_modified'=>true,'original_cipher_preserved'=>true,'MFA_envelopes_readable'=>$mfa,
|
|
'COUNTRY02_preserved'=>true,'no_190000'=>true,'exact_single_installation_audits'=>true,'table_snapshot'=>$tables,'file_manifest'=>$manifest,'sequences'=>$seq,'production_manifest_sha256'=>$build['manifest_sha256'],
|
|
'resource_fingerprint'=>app(App\Services\OpportunityResourceInstallation::class)->fingerprint(),'profile_fingerprint'=>app(App\Services\PortalProfileInstallation::class)->fingerprint(),'active_writes'=>0,'scope'=>'Post-use upgrade clone proof; not a destination installation or real-holder designation. Reversal after use preserves data via backup, never down/drop.' ];
|
|
file_put_contents($dir.'/workflow-upgrade-backup-restoration-qa-20261011.json',json_encode($proof,JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));file_put_contents($folder.'/proof.json',json_encode($proof,JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));
|
|
foreach(['resources','profiles']as$kind){$p=[...$proof,'code_hash'=>$proof[$kind==='resources'?'resource_fingerprint':'profile_fingerprint']];$file=$kind==='resources'?storage_path('app/private/investmoz-v2/opportunity-resources-activation-proof.json'):config('portal_profiles.proof_directory').'/readiness.json';file_put_contents($file,json_encode($p,JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));}
|
|
echo json_encode(array_intersect_key($proof,array_flip(['result','verified_at','table_count','sequence_count','file_count','original_non_transient_tables_preserved','MFA_envelopes_readable','COUNTRY02_preserved','active_writes'])),JSON_THROW_ON_ERROR).PHP_EOL;
|