Files
SGF/scripts/sgf-compose-install.php
2026-11-11 09:00:17 +02:00

61 lines
8.8 KiB
PHP

<?php
// Explicit, local composition of the two EXISTING books. No server/database/role creation.
use Illuminate\Support\Facades\DB;
use App\Shared\Installation\CompositionSchema;
use Symfony\Component\Process\Process;
$repo=dirname(__DIR__);$mode=$argv[1]??'--plan';$book=$argv[2]??'8085';
if(!in_array($mode,['--plan','--apply','--verify'],true)||!in_array($book,['regression','8085'],true))throw new LogicException('Use --plan|--apply|--verify regression|8085');
$tag=$book==='8085'?'cc50a276c10c':'b4093c6f6707';$run=json_decode(file_get_contents('C:/laragon/data/sgf-tempo/run/modular-regression-current.json'),true);
if($run['id']!=='2576427d1939'||(int)$run['port']!==55439)throw new LogicException('Known cluster only');
$s=json_decode(file_get_contents($run['root'].'/private/receipt-composition-'.$tag.'/state.json'),true);
if($s['tag']!==$tag||($book==='8085'&&(!($s['singleInstallation']??false)||json_decode(file_get_contents($s['root'].'/demo-server.json'),true)['port']!==8085)))throw new LogicException('Existing installation identity changed');
$private=$s['root'].'/composition-installs/'.date('Ymd-His').'-'.bin2hex(random_bytes(4));mkdir($private,0700,true);
foreach($s['env'] as $key=>$value)if(preg_match('/^(APP_|DB_|SGF_|SESSION_|CACHE_|QUEUE_|BCRYPT_)/',$key)){putenv($key.'='.$value);$_ENV[$key]=$value;$_SERVER[$key]=$value;}
foreach(['APP_BASE_PATH'=>$repo,'APP_CONFIG_CACHE'=>$private.'/config.php','APP_ROUTES_CACHE'=>$private.'/routes.php','APP_SERVICES_CACHE'=>$private.'/services.php','APP_PACKAGES_CACHE'=>$private.'/packages.php','SESSION_DRIVER'=>'array','CACHE_STORE'=>'array'] as $key=>$value){putenv($key.'='.$value);$_ENV[$key]=$value;$_SERVER[$key]=$value;}
require $repo.'/vendor/autoload.php';$app=require $repo.'/bootstrap/app.php';$app->addAbsoluteCachePathPrefix('C:');$app->useStoragePath($private.'/storage');foreach(['logs','framework/views','framework/sessions','framework/cache/data'] as $dir)mkdir($private.'/storage/'.$dir,0700,true);
$app->make(Illuminate\Contracts\Console\Kernel::class)->bootstrap();$db=DB::connection();$identity=CompositionSchema::identity();
$files=['app/Shared/Installation/CompositionSchema.php','app/Shared/Installation/CompositionSchemaConnection.php','app/Shared/Installation/CompositionSchemaBuilder.php','app/Shared/Installation/CompositionRuntime.php','app/Modules/Services/ServiceCalculation.php','app/Shared/Testing/TestingDatabaseGuard.php','app/Providers/AppServiceProvider.php','config/sgf_single_installation.php'];
foreach(CompositionSchema::MIGRATIONS as $file)$files[]='database/migrations/'.$file;
foreach(glob($repo.'/database/sql/*.sql') as $file)$files[]='database/sql/'.basename($file);
$pins=[];foreach($files as $file)$pins[$file]=hash_file('sha256',$repo.'/'.$file);
$schema=new CompositionSchema;$plan=['book'=>$book,'composition'=>$tag,'mode'=>$mode,'source_hash'=>$schema->fingerprint(),'files'=>$pins,'new_access'=>false,'new_business_operations'=>false,'connector_required'=>false,'runtime_enabled'=>false];
file_put_contents($private.'/plan.json',json_encode($plan,JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));
if($mode==='--plan'){echo json_encode(['plan'=>$private.'/plan.json','files'=>count($files),'database_writes'=>0]).PHP_EOL;exit;}
if($mode==='--verify'){
$stored=$db->getSchemaBuilder()->hasTable('sgf_composition_schema')?$db->table('sgf_composition_schema')->where('id',1)->value('source_hash'):null;
$copy=true;foreach($pins as $file=>$pin)if(!is_file($s['app'].'/'.$file)||!hash_equals($pin,hash_file('sha256',$s['app'].'/'.$file)))$copy=false;
$valid=is_string($stored)&&hash_equals($schema->fingerprint(),$stored)&&$copy;
echo json_encode(['passed'=>$valid,'composition'=>$tag,'code_matches'=>$copy,'database_writes'=>0,'runtime_enabled'=>false]).PHP_EOL;exit($valid?0:2);
}
// Snapshot pre-existing records and sequences, including every permission and association.
$tables=array_column($db->select("SELECT tablename FROM pg_tables WHERE schemaname='public' ORDER BY tablename"),'tablename');
$functionsBefore=array_column($db->select("SELECT oid FROM pg_proc WHERE pronamespace='public'::regnamespace"),'oid');
$sequencesBefore=array_column($db->select("SELECT sequencename FROM pg_sequences WHERE schemaname='public'"),'sequencename');
$snapshot=function()use($db,$tables){$out=[];foreach($tables as $table){if(!preg_match('/^[a-z_][a-z0-9_]*$/D',$table))throw new LogicException('Table');$out[$table]=(array)$db->selectOne('SELECT count(*) rows,md5(coalesce(string_agg(row_to_json(t)::text,chr(10) ORDER BY row_to_json(t)::text),\'\')) hash FROM "'.$table.'" t');}$seq=$db->select("SELECT sequencename,last_value FROM pg_sequences WHERE schemaname='public' AND sequencename=ANY(?::text[]) ORDER BY sequencename",['{'.implode(',',array_column($db->select("SELECT sequencename FROM pg_sequences WHERE schemaname='public'"),'sequencename')).'}']);return ['tables'=>$out,'sequences'=>$seq];};
$before=$snapshot();file_put_contents($private.'/before.json',json_encode($before,JSON_THROW_ON_ERROR));
$process=new Process(['C:/Program Files/PostgreSQL/17/bin/pg_dump.exe','-h','127.0.0.1','-p','55439','-U',$s['env']['DB_USERNAME'],'-d',$s['env']['DB_DATABASE'],'--format=custom','--no-owner','--no-privileges','--file',$private.'/host-before.dump'],$repo,['PGPASSWORD'=>$s['env']['DB_PASSWORD']]);$process->setTimeout(120);$process->mustRun();
$original=[];foreach($files as $file){$dest=$s['app'].'/'.$file;$original[$file]=is_file($dest)?hash_file('sha256',$dest):null;if(is_file($dest)){$backup=$private.'/code-before/'.$file;if(!is_dir(dirname($backup)))mkdir(dirname($backup),0700,true);copy($dest,$backup);}}
$copied=[];
try{
$result=$schema->run(true,function($connection)use($files,$pins,$original,$repo,$s,&$copied,$functionsBefore,$tables,$sequencesBefore){
// New PostgreSQL functions otherwise inherit implicit PUBLIC EXECUTE.
// Remove only defaults on NEW objects; existing ACLs remain untouched.
$restricted=0;
foreach($connection->select("SELECT oid,oid::regprocedure::text signature FROM pg_proc WHERE pronamespace='public'::regnamespace") as $fn)if(!in_array($fn->oid,$functionsBefore,true)){$connection->statement('REVOKE ALL ON FUNCTION '.$fn->signature.' FROM PUBLIC');$restricted++;}
foreach($connection->select("SELECT tablename FROM pg_tables WHERE schemaname='public'") as $row)if(!in_array($row->tablename,$tables,true))$connection->statement('REVOKE ALL ON TABLE public.'.'"'.str_replace('"','""',$row->tablename).'" FROM PUBLIC');
foreach($connection->select("SELECT sequencename FROM pg_sequences WHERE schemaname='public'") as $row)if(!in_array($row->sequencename,$sequencesBefore,true))$connection->statement('REVOKE ALL ON SEQUENCE public.'.'"'.str_replace('"','""',$row->sequencename).'" FROM PUBLIC');
foreach($files as $file){$dest=$s['app'].'/'.$file;$now=is_file($dest)?hash_file('sha256',$dest):null;if($now!==$original[$file])throw new LogicException('Concurrent code modification; preserve it');if(!is_dir(dirname($dest)))mkdir(dirname($dest),0700,true);if(!copy($repo.'/'.$file,$dest)||!hash_equals($pins[$file],hash_file('sha256',$dest)))throw new RuntimeException('Code copy incomplete');$copied[]=$file;}
return ['copied_files'=>count($copied),'new_grants'=>0,'new_documents'=>0,'new_function_public_defaults_removed'=>$restricted,'runtime_enabled'=>false];
});
}catch(Throwable $e){
foreach(array_reverse($copied) as $file){$dest=$s['app'].'/'.$file;if(!is_file($dest)||!hash_equals($pins[$file],hash_file('sha256',$dest)))continue;if($original[$file]===null)unlink($dest);else copy($private.'/code-before/'.$file,$dest);}
file_put_contents($private.'/failure.json',json_encode(['failed'=>true,'type'=>$e::class,'database_rollback_requested'=>true,'code_recovery_attempted'=>true]));throw $e;
}
$after=$snapshot();$preserved=$before['tables']===$after['tables'];
// Restrict sequence comparison to the original names; newly installed sequences are expected.
$oldSeq=[];foreach($before['sequences'] as $row)$oldSeq[$row->sequencename]=$row->last_value;$newSeq=[];foreach($after['sequences'] as $row)if(array_key_exists($row->sequencename,$oldSeq))$newSeq[$row->sequencename]=$row->last_value;$preserved=$preserved&&$oldSeq===$newSeq;
file_put_contents($private.'/after.json',json_encode($after,JSON_THROW_ON_ERROR));
$result=['passed'=>$preserved,'composition'=>$tag,'schema'=>$result,'previous_rows_sequences_permissions_associations_preserved'=>$preserved,'code_files'=>count($files),'runtime_enabled'=>false,'business_operations'=>0,'cache_touched'=>false,'connector_required'=>false,'backup_sha256'=>hash_file('sha256',$private.'/host-before.dump')];
file_put_contents($private.'/result.json',json_encode($result,JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));echo json_encode(['passed'=>$preserved,'composition'=>$tag,'new_tables'=>count($result['schema']['new_tables']??[]),'new_grants'=>0,'runtime_enabled'=>false,'proof'=>$private.'/result.json']).PHP_EOL;exit($preserved?0:2);