diff --git a/deploy/homologacao/.gitignore b/deploy/homologacao/.gitignore new file mode 100644 index 00000000..c18dd8d8 --- /dev/null +++ b/deploy/homologacao/.gitignore @@ -0,0 +1 @@ +__pycache__/ diff --git a/deploy/homologacao/delivery.example.json b/deploy/homologacao/delivery.example.json new file mode 100644 index 00000000..fd64ea1b --- /dev/null +++ b/deploy/homologacao/delivery.example.json @@ -0,0 +1,17 @@ +{ + "repository": "nala/Investidor-V2", + "branch": "main", + "target_url": "https://teste-investidor.tempo.co.mz", + "legacy_root": "/var/www/html/teste-investidor.tempo.co.mz", + "state_root": "/var/lib/investmoz-deploy", + "release_root": "/var/www/investmoz-homologacao/releases", + "current_link": "/var/www/html/teste-investidor.tempo.co.mz/current", + "shared_assets": "/var/www/investmoz-homologacao/shared/build-assets", + "php": "/usr/bin/php8.4", + "schema_contract": "/etc/investmoz-deploy/schema-contract.json", + "ci_environment": "/etc/investmoz-deploy/ci-environment.json", + "postgis_image": "postgis/postgis:17-3.5", + "test_database": "investmoz_ci_test", + "qa_port": 55441, + "retention": "No automatic deletion of releases or data" +} diff --git a/deploy/homologacao/pipeline.py b/deploy/homologacao/pipeline.py new file mode 100644 index 00000000..a246cf62 --- /dev/null +++ b/deploy/homologacao/pipeline.py @@ -0,0 +1,326 @@ +#!/usr/bin/env python3 +"""Gitea -> isolated verification/build -> immutable release -> atomic switch. + +Runs as the existing deployment OS user. Never resets the served checkout, +copies a database, runs production migrations, imports content or changes grants. +The installed copy and /etc configuration are controlled by the server operator. +""" +from __future__ import annotations +import argparse, base64, contextlib, datetime, hashlib, io, json, os, re +import shutil, socket, subprocess, tarfile, time, urllib.request, uuid +from pathlib import Path + +SHA = re.compile(r'^[0-9a-f]{40}$') + +def digest(path: Path) -> str: + return hashlib.sha256(path.read_bytes()).hexdigest() + +def write_json(path: Path, value: dict) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + temporary = path.with_name('.'+path.name+'.'+uuid.uuid4().hex) + temporary.write_text(json.dumps(value, indent=2)+'\n', encoding='utf-8') + os.chmod(temporary, 0o640) + os.replace(temporary, path) + +def source_contract(root: Path) -> dict[str, str]: + return {p.relative_to(root).as_posix(): hashlib.sha256(p.read_bytes().replace(b'\r\n', b'\n')).hexdigest() + for p in sorted((root/'database/migrations').glob('*.php'))} + +def tree_hashes(root: Path) -> dict[str, str]: + result = {} + for directory, folders, files in os.walk(root, followlinks=False): + parent = Path(directory) + folders[:] = [name for name in folders if not (parent/name).is_symlink() + and name not in ['.git', 'node_modules'] + and (parent/name).relative_to(root).as_posix() not in ['storage', 'bootstrap/cache']] + for name in files: + path=parent/name;rel=path.relative_to(root).as_posix() + if path.is_symlink() or rel=='.env' or rel.startswith('public/build/assets/'):continue + result[rel]=digest(path) + return result + +def refuse_drift(root: Path, expected: dict[str, str]) -> None: + actual = tree_hashes(root) + if actual != expected: + changed = sorted(p for p in actual.keys() | expected.keys() if actual.get(p) != expected.get(p)) + raise RuntimeError('Source/build drift requires reconciliation: '+', '.join(changed[:12])) + +def archive_source(data: bytes, root: Path) -> None: + with tarfile.open(fileobj=io.BytesIO(data)) as archive: + members = archive.getmembers() + for item in members: + path = (root/item.name).resolve() + if not path.is_relative_to(root.resolve()) or not (item.isfile() or item.isdir()): + raise RuntimeError('Unsafe source archive entry') + if item.name == '.env' or item.name == 'public/hot' or item.name.startswith('storage/app/private/'): + raise RuntimeError('Private/runtime files must not be distributed by Git') + # Debian's installed Python predates tarfile extraction filters. Validate + # every entry first, then create only ordinary directories/files ourselves. + for item in members: + path=root/item.name + if item.isdir(): + path.mkdir(mode=0o750,parents=True,exist_ok=True) + else: + path.parent.mkdir(mode=0o750,parents=True,exist_ok=True) + source=archive.extractfile(item) + if source is None:raise RuntimeError('Missing archive source bytes') + with source, path.open('xb') as destination:shutil.copyfileobj(source,destination) + os.chmod(path,0o644 | (item.mode & 0o111)) + +def point_current(link: Path, target: Path) -> None: + if link.exists() and not link.is_symlink(): + raise RuntimeError('Current path is not a symlink; existing directory preserved') + temporary = link.with_name('.'+link.name+'.'+uuid.uuid4().hex) + temporary.symlink_to(target, target_is_directory=True) + os.replace(temporary, link) + +def promote(link: Path, candidate: Path, health) -> Path | None: + previous = link.resolve() if link.is_symlink() else None + point_current(link, candidate) + try: + health() + except Exception: + if previous is not None: + point_current(link, previous) + else: + link.unlink() + raise + return previous + +class Delivery: + def __init__(self, config: dict): + self.c = config + self.state = Path(config['state_root']) + self.releases = Path(config['release_root']) + self.current = Path(config['current_link']) + self.legacy = Path(config['legacy_root']) + self.mirror = self.state/'repository.git' + self.log = None + self.process_env = dict(os.environ) + # Do not inherit an application/CI connection accidentally from the calling shell. + for key in list(self.process_env): + if key.startswith(('DB_', 'APP_', 'MAIL_', 'CACHE_', 'SESSION_', 'QUEUE_', 'VITE_', 'PORTAL_', 'INVESTMOZ_')): + self.process_env.pop(key) + + def run(self, argv, cwd=None, env=None, timeout=900, log_output=True) -> bytes: + result = subprocess.run([str(x) for x in argv], cwd=cwd, env=env or self.process_env, + stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=timeout) + if self.log and log_output: + self.log.write(result.stdout); self.log.flush() + if result.returncode: + raise RuntimeError('Verification command failed: '+str(argv[0])+' (see protected build log)') + return result.stdout + + def git(self, *args) -> str: + return self.run(['git', '--git-dir', self.mirror, *args], timeout=120).decode().strip() + + def latest(self) -> str: + branch = self.c['branch'] + self.git('check-ref-format', 'refs/heads/'+branch) + self.git('fetch', '--no-tags', 'origin', '+refs/heads/'+branch+':refs/heads/delivery-target') + target = self.git('rev-parse', 'refs/heads/delivery-target') + if not SHA.fullmatch(target): + raise RuntimeError('Invalid fetched revision') + return target + + def ci_env(self) -> dict: + flags = json.loads(Path(self.c['ci_environment']).read_text()) + # This file contains only reviewed non-secret feature booleans. + if not all(re.fullmatch(r'(PORTAL_|INVESTMOZ_)[A-Z0-9_]+', k) and v in ['true', 'false'] for k,v in flags.items()): + raise RuntimeError('Unexpected CI feature setting') + env = {**self.process_env, **flags} + env.update(APP_ENV='testing', APP_DEBUG='false', APP_URL='http://127.0.0.1', + APP_KEY='base64:'+base64.b64encode(os.urandom(32)).decode(), APP_NAME='InvesteMoz', + DB_CONNECTION='pgsql', DB_URL='', DB_HOST='127.0.0.1', DB_PORT='55441', + DB_DATABASE=self.c['test_database'], DB_USERNAME='investmoz_ci', + DB_PASSWORD='isolated-ci-only', MAIL_MAILER='array', CACHE_STORE='array', + SESSION_DRIVER='array', QUEUE_CONNECTION='sync', + INVESTMOZ_HOMOLOGATION_ONLY='false', INVESTMOZ_SCHEMA_TESTS='1', + PORTAL_NOTIFICATIONS_ENABLED='false', VITE_APP_NAME='InvesteMoz', + NODE_OPTIONS='--max-old-space-size=1536') + if self.c['qa_port'] != 55441 or not env['DB_DATABASE'].endswith('_test'): + raise RuntimeError('Refuse a non-isolated test target') + return env + + @contextlib.contextmanager + def qa_database(self): + with socket.socket() as probe: + if probe.connect_ex(('127.0.0.1', 55441)) == 0: + raise RuntimeError('QA port already occupied; do not reuse or stop another database') + name = 'investmoz-ci-'+uuid.uuid4().hex[:16] + # Existing Docker; private loopback only, PostgreSQL itself listens on the guarded QA port. + cid = self.run(['docker','run','--detach','--name',name,'--label','investmoz.scope=homologacao-ci','--memory=768m','--cpus=1', + '--publish','127.0.0.1:55441:55441','--env','POSTGRES_USER=investmoz_ci', + '--env','POSTGRES_PASSWORD=isolated-ci-only','--env','POSTGRES_DB='+self.c['test_database'], + self.c['postgis_image'],'postgres','-p','55441'], timeout=180).decode().strip().splitlines()[-1] + if not re.fullmatch(r'[0-9a-f]{64}', cid): + raise RuntimeError('Cannot identify isolated QA container') + try: + for _ in range(60): + ready = subprocess.run(['docker','exec',cid,'pg_isready','-p','55441','-U','investmoz_ci'], + stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL) + if ready.returncode == 0: break + time.sleep(1) + else: raise RuntimeError('Isolated database did not start') + identity = self.run(['docker','exec',cid,'psql','-p','55441','-U','investmoz_ci','-d',self.c['test_database'], + '-X','-At','-c','SELECT current_database(), current_setting(\'port\');']).decode().strip() + if identity != self.c['test_database']+'|55441': + raise RuntimeError('QA identity verification failed') + yield + finally: + # Only the newly created, fully identified CI container and its anonymous volumes. + self.run(['docker','rm','--force','--volumes',cid], timeout=60) + + def build(self, target: str, release: Path) -> None: + data = self.run(['git','--git-dir',self.mirror,'archive','--format=tar',target], timeout=120,log_output=False) + # Binary archives must never be appended to text build logs. + archive_source(data, release) + expected = json.loads(Path(self.c['schema_contract']).read_text()) + if source_contract(release) != expected: + raise RuntimeError('Schema source changed: explicit compatibility/migration decision required; no production SQL executed') + env = self.ci_env() + bindir = self.state/'bin';bindir.mkdir(exist_ok=True) + php = bindir/'php' + if not php.exists(): php.symlink_to(self.c['php']) + env['PATH'] = str(bindir)+os.pathsep+self.process_env.get('PATH','/usr/bin:/bin') + (release/'.env').write_text('APP_ENV=testing\n') + (release/'bootstrap/cache').mkdir(parents=True,exist_ok=True) + for directory in ['app/private','app/public','framework/cache/data','framework/sessions','framework/views','logs']: + (release/'storage'/directory).mkdir(parents=True,exist_ok=True) + with self.qa_database(): + self.run(['composer','install','--no-interaction','--prefer-dist','--no-scripts'],cwd=release,env=env) + self.run([self.c['php'],'artisan','package:discover','--ansi'],cwd=release,env=env) + # The guard is verified against the actual PostgreSQL identity, not only environment strings. + guard = "require 'vendor/autoload.php'; $app=require 'bootstrap/app.php'; $app->make(Illuminate\\Contracts\\Console\\Kernel::class)->bootstrap(); if (!App\\Support\\IsolatedQaDatabase::allowsSchemaChanges()) exit(91);" + self.run([self.c['php'],'-r',guard],cwd=release,env=env) + self.run([self.c['php'],'artisan','migrate','--force','--no-interaction'],cwd=release,env=env) + self.run(['npm','ci','--no-audit','--no-fund'],cwd=release,env=env) + self.run(['npm','run','build'],cwd=release,env=env) + self.run(['npm','run','types:check'],cwd=release,env=env) + self.run([self.c['php'],'tools/portal-diagnostics-deployment-guard.php'],cwd=release,env=env) + self.run([self.c['php'],'vendor/bin/phpunit','--configuration=phpunit.xml','--testsuite','Unit','--do-not-cache-result'],cwd=release,env=env) + self.run([self.c['php'],'vendor/bin/phpunit','--configuration=phpunit.investmoz.xml','--do-not-cache-result'],cwd=release,env=env) + tests=sorted((release/'tests/frontend').glob('*.test.mjs')) + self.run(['node','--test',*tests],cwd=release,env=env) + # Final production vendor from the same lock, before linking any real configuration. + self.run(['composer','install','--no-dev','--no-scripts','--no-interaction','--prefer-dist','--optimize-autoloader'],cwd=release,env=env) + self.run(['composer','check-platform-reqs','--no-dev'],cwd=release,env=env) + if (release/'public/hot').exists(): raise RuntimeError('Development Vite marker must not ship') + manifest=json.loads((release/'public/build/manifest.json').read_text()) + for entry in manifest.values(): + for path in [entry.get('file'),*entry.get('css',[]),*entry.get('assets',[])]: + if path and not (release/'public/build'/path).is_file(): raise RuntimeError('Missing compiled asset') + # Discard only this release's synthetic runtime, retaining it privately outside the served tree. + (release/'storage').rename(self.state/'qa-runtime'/release.name) + (release/'.env').unlink() + for path in (release/'bootstrap/cache').glob('*.php'): path.unlink() + (release/'storage').symlink_to(self.legacy/'storage',target_is_directory=True) + (release/'.env').symlink_to(self.legacy/'.env') + if (release/'public/storage').exists():raise RuntimeError('Unexpected distributed public storage') + (release/'public/storage').symlink_to(self.legacy/'storage/app/public',target_is_directory=True) + shared=Path(self.c['shared_assets']);shared.mkdir(parents=True,exist_ok=True) + assets=release/'public/build/assets' + for path in assets.rglob('*'): + if not path.is_file():continue + dest=shared/path.relative_to(assets);dest.parent.mkdir(parents=True,exist_ok=True) + if dest.exists() and digest(dest)!=digest(path):raise RuntimeError('Immutable asset filename collision') + if not dest.exists():shutil.copy2(path,dest) + # Nginx serves this shared immutable asset pool; old browser bundles remain available. + write_json(release/'public/build/revision.json',{'commit':target,'branch':self.c['branch'], + 'built_at':datetime.datetime.now(datetime.timezone.utc).isoformat(), + 'manifest_sha256':digest(release/'public/build/manifest.json')}) + for directory in [release/'bootstrap/cache']: + os.chmod(directory,0o2770) + + def health(self, target: str) -> None: + request=urllib.request.Request(self.c['target_url']+'/build/revision.json',headers={'User-Agent':'Mozilla/5.0 InvesteMoz-Delivery','Cache-Control':'no-cache'}) + with urllib.request.urlopen(request,timeout=20) as response:info=json.load(response) + if info.get('commit')!=target:raise RuntimeError('Served commit differs from built commit') + request=urllib.request.Request(self.c['target_url']+'/',headers={'User-Agent':'Mozilla/5.0 InvesteMoz-Delivery','Cache-Control':'no-cache'}) + with urllib.request.urlopen(request,timeout=20) as response: + if response.status!=200 or response.headers.get('WWW-Authenticate'):raise RuntimeError('Public access regression') + + def deliver(self, target: str) -> dict: + installed_file=self.state/'installed.json' + installed=json.loads(installed_file.read_text()) + if installed.get('commit')==target: + refuse_drift(self.current.resolve(),installed['hashes']) + return {'status':'already_installed','commit':target} + refuse_drift(self.current.resolve(),installed['hashes']) + self.releases.mkdir(parents=True,exist_ok=True) + release=self.releases/(target+'-'+uuid.uuid4().hex[:8]);release.mkdir(mode=0o750) + self.build(target,release) + if self.latest()!=target: + latest=self.git('rev-parse','refs/heads/delivery-target') + write_json(self.state/'requests'/(latest+'.json'),{'commit':latest,'reason':'newer integration during build'}) + return {'status':'superseded','commit':target,'newer_commit':latest} + previous=promote(self.current,release,lambda:self.health(target)) + try: + self.run([self.c['php'],str(release/'artisan'),'queue:restart'],cwd=release) + write_json(self.state/'previous.json',installed) + info={'status':'installed','commit':target,'release':str(release),'previous_release':str(previous), + 'hashes':tree_hashes(release),'database_migrations':0,'content_imports':0} + write_json(installed_file,info) + except Exception: + if previous is not None:point_current(self.current,previous) + raise + return {key:value for key,value in info.items() if key!='hashes'} + + def process(self) -> None: + import fcntl + self.state.mkdir(parents=True,exist_ok=True) + for directory in ['reports','logs','processed','qa-runtime']: (self.state/directory).mkdir(exist_ok=True) + with (self.state/'delivery.lock').open('a') as lock: + fcntl.flock(lock,fcntl.LOCK_EX) + for _ in range(3): + requests=sorted((self.state/'requests').glob('*.json')) + if not requests:return + for path in requests: + if not SHA.fullmatch(path.stem):raise RuntimeError('Unexpected queue entry') + path.rename(self.state/'processed'/(path.stem+'-'+uuid.uuid4().hex[:8]+'.json')) + target=None;report={} + with (self.state/'logs'/(str(time.time_ns())+'.log')).open('wb') as self.log: + try: + target=self.latest();report=self.deliver(target) + except Exception as error: + report={'status':'failed','commit':target,'error':str(error),'active_release_preserved':True} + finally:self.log=None + report.update(requested_commits=[p.stem for p in requests],finished_at=datetime.datetime.now(datetime.timezone.utc).isoformat()) + write_json(self.state/'reports'/((target or 'fetch-failed')+'-'+str(time.time_ns())+'.json'),report) + print(json.dumps(report),flush=True) + + def rollback(self) -> dict: + import fcntl + with (self.state/'delivery.lock').open('a') as lock: + fcntl.flock(lock,fcntl.LOCK_EX) + installed=json.loads((self.state/'installed.json').read_text()) + previous=json.loads((self.state/'previous.json').read_text()) + refuse_drift(self.current.resolve(),installed['hashes']) + prior=Path(previous['release']) + refuse_drift(prior,previous['hashes']) + current=self.current.resolve() + promote(self.current,prior,lambda:self.health(previous['commit'])) + try: + self.run([self.c['php'],str(prior/'artisan'),'queue:restart'],cwd=prior) + write_json(self.state/'installed.json',previous) + write_json(self.state/'previous.json',installed) + except Exception: + point_current(self.current,current) + write_json(self.state/'installed.json',installed) + raise + report={'status':'rolled_back','commit':previous['commit'],'release':str(prior), + 'database_restored':False,'shared_data_preserved':True, + 'finished_at':datetime.datetime.now(datetime.timezone.utc).isoformat()} + write_json(self.state/'reports'/('rollback-'+str(time.time_ns())+'.json'),report) + return report + +def main(): + parser=argparse.ArgumentParser();parser.add_argument('--config',default='/etc/investmoz-deploy/delivery.json');parser.add_argument('--rollback',action='store_true');args=parser.parse_args() + config=json.loads(Path(args.config).read_text()) + if config['target_url']!='https://teste-investidor.tempo.co.mz':raise SystemExit('Exact homologation target required') + if Path(config['legacy_root']).resolve()!=Path('/var/www/html/teste-investidor.tempo.co.mz'):raise SystemExit('Unexpected persistent application target') + runner=Delivery(config) + if args.rollback:print(json.dumps(runner.rollback()),flush=True) + else:runner.process() + +if __name__=='__main__':main() diff --git a/deploy/homologacao/test_pipeline.py b/deploy/homologacao/test_pipeline.py new file mode 100644 index 00000000..de328fd4 --- /dev/null +++ b/deploy/homologacao/test_pipeline.py @@ -0,0 +1,108 @@ +import importlib.util, io, json, os, tarfile, tempfile, unittest, threading, time +from pathlib import Path + +spec=importlib.util.spec_from_file_location('delivery',Path(__file__).with_name('pipeline.py')) +delivery=importlib.util.module_from_spec(spec);spec.loader.exec_module(delivery) + +class PreservationTests(unittest.TestCase): + def test_archive_extracts_only_source_bytes(self): + with tempfile.TemporaryDirectory() as d: + buf=io.BytesIO() + with tarfile.open(fileobj=buf,mode='w') as archive: + item=tarfile.TarInfo('app/source.php');item.size=4;archive.addfile(item,io.BytesIO(b'code')) + delivery.archive_source(buf.getvalue(),Path(d)) + self.assertEqual((Path(d)/'app/source.php').read_bytes(),b'code') + def test_archive_refuses_private_data_and_escaping_links(self): + for name,kind in [('.env','file'),('storage/app/private/secret.pdf','file'),('../outside','file'),('public/escape','link')]: + with self.subTest(name=name),tempfile.TemporaryDirectory() as d: + buf=io.BytesIO() + with tarfile.open(fileobj=buf,mode='w') as archive: + item=tarfile.TarInfo(name) + if kind=='link':item.type=tarfile.SYMTYPE;item.linkname='/etc/passwd' + else:item.size=1 + archive.addfile(item,None if kind=='link' else io.BytesIO(b'x')) + with self.assertRaises(RuntimeError):delivery.archive_source(buf.getvalue(),Path(d)) + + def test_source_edit_and_unlisted_file_stop_promotion(self): + with tempfile.TemporaryDirectory() as d: + root=Path(d);(root/'app').mkdir();(root/'app/code.php').write_text('before') + original=delivery.tree_hashes(root);(root/'app/code.php').write_text('programmer change') + with self.assertRaises(RuntimeError):delivery.refuse_drift(root,original) + (root/'app/code.php').write_text('before');(root/'app/new.php').write_text('extra') + with self.assertRaises(RuntimeError):delivery.refuse_drift(root,original) + + def test_runtime_data_not_part_of_source_drift(self): + with tempfile.TemporaryDirectory() as d: + root=Path(d);(root/'app').mkdir();(root/'app/code.php').write_text('same') + original=delivery.tree_hashes(root) + (root/'storage/app/private').mkdir(parents=True);(root/'storage/app/private/new.pdf').write_bytes(b'later data') + (root/'.env').write_text('APP_KEY=not-distributed') + delivery.refuse_drift(root,original) + + def test_schema_change_stops_before_database_or_build_commands(self): + with tempfile.TemporaryDirectory() as d: + root=Path(d);contract=root/'schema.json';contract.write_text('{}');release=root/'release';release.mkdir() + archive=io.BytesIO() + with tarfile.open(fileobj=archive,mode='w') as tar: + item=tarfile.TarInfo('database/migrations/new.php');item.size=4;tar.addfile(item,io.BytesIO(b'code')) + config={'state_root':d,'release_root':d,'current_link':str(root/'current'),'legacy_root':d,'schema_contract':str(contract)} + runner=delivery.Delivery(config);calls=[] + def source_only(argv,**kwargs):calls.append(argv);return archive.getvalue() + runner.run=source_only + with self.assertRaisesRegex(RuntimeError,'Schema source changed'):runner.build('a'*40,release) + self.assertEqual(len(calls),1);self.assertIn('archive',calls[0]) + + @unittest.skipIf(os.name=='nt','Atomic release promotion is exercised on the Linux destination clone') + def test_failed_health_restores_previous_release_and_later_shared_data(self): + with tempfile.TemporaryDirectory() as d: + root=Path(d);old=root/'old';new=root/'new';old.mkdir();new.mkdir() + shared=root/'private';shared.mkdir();record=shared/'later.txt';record.write_text('new data') + link=root/'current';link.symlink_to(old,target_is_directory=True) + def failed():raise RuntimeError('observed HTTP failure') + with self.assertRaises(RuntimeError):delivery.promote(link,new,failed) + self.assertEqual(link.resolve(),old);self.assertEqual(record.read_text(),'new data') + + def test_qa_refuses_unapproved_port_or_database(self): + with tempfile.TemporaryDirectory() as d: + root=Path(d);flags=root/'flags.json';flags.write_text('{}') + config={'state_root':d,'release_root':d,'current_link':str(root/'current'),'legacy_root':d, + 'ci_environment':str(flags),'qa_port':5432,'test_database':'teste_investidor'} + with self.assertRaises(RuntimeError):delivery.Delivery(config).ci_env() + + @unittest.skipIf(os.name=='nt','Linux symlinks and flock') + def test_explicit_rollback_preserves_later_private_records(self): + with tempfile.TemporaryDirectory() as d: + root=Path(d);old=root/'old';new=root/'new';old.mkdir();new.mkdir() + (old/'code').write_text('old');(new/'code').write_text('new') + shared=root/'storage';shared.mkdir();(shared/'later').write_text('keep') + link=root/'current';link.symlink_to(new,target_is_directory=True) + installed={'release':str(new),'commit':'b'*40,'hashes':delivery.tree_hashes(new)} + previous={'release':str(old),'commit':'a'*40,'hashes':delivery.tree_hashes(old)} + delivery.write_json(root/'installed.json',installed);delivery.write_json(root/'previous.json',previous) + config={'state_root':d,'release_root':d,'current_link':str(link),'legacy_root':d,'php':'php'} + runner=delivery.Delivery(config);runner.run=lambda *a,**k:b'';runner.health=lambda target:self.assertEqual(target,'a'*40) + self.assertEqual(runner.rollback()['status'],'rolled_back') + self.assertEqual(link.resolve(),old);self.assertEqual((shared/'later').read_text(),'keep') + + @unittest.skipIf(os.name=='nt','Linux flock and systemd target') + def test_concurrent_runner_and_new_event_do_not_lose_or_overlap_jobs(self): + with tempfile.TemporaryDirectory() as d: + root=Path(d);(root/'requests').mkdir();head=root/'head';head.write_text('a'*40) + delivery.write_json(root/'requests'/('a'*40+'.json'),{'commit':'a'*40}) + entered=threading.Event();resume=threading.Event();calls=[];active=[0,0];guard=threading.Lock() + config={'state_root':d,'release_root':d,'current_link':str(root/'current'),'legacy_root':d} + class Fake(delivery.Delivery): + def latest(self):return head.read_text() + def deliver(self,target): + with guard:active[0]+=1;active[1]=max(active[1],active[0]) + if target=='a'*40:entered.set();resume.wait(3) + calls.append(target) + with guard:active[0]-=1 + return {'status':'installed','commit':target} + first=threading.Thread(target=Fake(config).process);first.start();self.assertTrue(entered.wait(3)) + head.write_text('b'*40);delivery.write_json(root/'requests'/('b'*40+'.json'),{'commit':'b'*40}) + second=threading.Thread(target=Fake(config).process);second.start();resume.set();first.join(5);second.join(5) + self.assertFalse(first.is_alive() or second.is_alive());self.assertEqual(active[1],1) + self.assertEqual(calls,['a'*40,'b'*40]);self.assertEqual(list((root/'requests').glob('*.json')),[]) + +if __name__=='__main__':unittest.main() diff --git a/deploy/homologacao/test_webhook.py b/deploy/homologacao/test_webhook.py new file mode 100644 index 00000000..b8bdffb0 --- /dev/null +++ b/deploy/homologacao/test_webhook.py @@ -0,0 +1,41 @@ +"""Synthetic, loopback-only receiver rehearsal. Never calls the site's /deploy.""" +import hashlib,hmac,json,os,socket,subprocess,tempfile,time,unittest,urllib.request,urllib.error +from pathlib import Path + +class ReceiverTests(unittest.TestCase): + def test_signature_scope_replay_and_no_command_dispatch(self): + source=Path(__file__).with_name('webhook.php') + with tempfile.TemporaryDirectory(prefix='investmoz-webhook-QA-') as directory: + root=Path(directory);state=root/'state';(state/'requests').mkdir(parents=True);(state/'incoming').mkdir() + secret=root/'synthetic-secret';secret.write_text('SYNTHETIC-LOCAL-QA-ONLY') + config=root/'config.json';config.write_text(json.dumps({'webhook_secret_file':str(secret),'repository':'nala/Investidor-V2','branch':'homologacao','state_root':str(state)})) + env={**os.environ,'INVESTMOZ_DELIVERY_CONFIG':str(config)} + with socket.socket() as reserve:reserve.bind(('127.0.0.1',0));port=reserve.getsockname()[1] + with (root/'php.log').open('wb') as log: + proc=subprocess.Popen(['php8.4','-S','127.0.0.1:'+str(port),'-t',str(source.parent)],env=env,stdout=log,stderr=log) + try: + for _ in range(60): + if proc.poll() is not None:self.fail('Isolated PHP did not start') + with socket.socket() as probe: + if probe.connect_ex(('127.0.0.1',port))==0:break + time.sleep(0.05) + def post(payload,valid=True,event='push'): + data=json.dumps(payload).encode();signature=hmac.new(secret.read_bytes(),data,hashlib.sha256).hexdigest() if valid else '0'*64 + req=urllib.request.Request('http://127.0.0.1:'+str(port)+'/webhook.php',data=data,headers={'Content-Type':'application/json','X-Gitea-Event':event,'X-Gitea-Signature':signature}) + try:res=urllib.request.urlopen(req,timeout=5) + except urllib.error.HTTPError as error:res=error + return res.status + valid={'repository':{'full_name':'nala/Investidor-V2'},'ref':'refs/heads/homologacao','after':'a'*40} + self.assertEqual(post(valid,False),403) + self.assertEqual(post({**valid,'ref':'refs/heads/main'}),204) + self.assertEqual(post({**valid,'repository':{'full_name':'someone/other'}}),204) + self.assertEqual(post({**valid,'after':'a; touch /tmp/not-a-command'}),400) + self.assertEqual(list((state/'requests').iterdir()),[]) + self.assertEqual(post(valid),202);self.assertEqual(post(valid),202) + files=list((state/'requests').glob('*.json'));self.assertEqual(len(files),1) + self.assertEqual(json.loads(files[0].read_text())['commit'],'a'*40) + self.assertEqual(list((state/'incoming').iterdir()),[]) + finally: + proc.terminate();proc.wait(timeout=5) + +if __name__=='__main__':unittest.main() diff --git a/deploy/homologacao/teste-investidor-deploy.path b/deploy/homologacao/teste-investidor-deploy.path new file mode 100644 index 00000000..dbe404ea --- /dev/null +++ b/deploy/homologacao/teste-investidor-deploy.path @@ -0,0 +1,9 @@ +[Unit] +Description=Queue approved integrations for InvesteMoz homologation + +[Path] +DirectoryNotEmpty=/var/lib/investmoz-deploy/requests +Unit=teste-investidor-deploy.service + +[Install] +WantedBy=multi-user.target diff --git a/deploy/homologacao/teste-investidor-deploy.service b/deploy/homologacao/teste-investidor-deploy.service new file mode 100644 index 00000000..4eff9a8d --- /dev/null +++ b/deploy/homologacao/teste-investidor-deploy.service @@ -0,0 +1,18 @@ +[Unit] +Description=InvesteMoz homologation CI and atomic delivery +After=network-online.target docker.service +Wants=network-online.target + +[Service] +Type=oneshot +User=mario +Group=www-data +WorkingDirectory=/var/lib/investmoz-deploy +ExecStart=/usr/bin/python3 /var/www/deploy/investmoz-pipeline.py --config /etc/investmoz-deploy/delivery.json +UMask=0027 +TimeoutStartSec=3600 +Nice=10 +CPUQuota=150% +MemoryMax=4G +NoNewPrivileges=true + diff --git a/deploy/homologacao/webhook.php b/deploy/homologacao/webhook.php new file mode 100644 index 00000000..dc461c1a --- /dev/null +++ b/deploy/homologacao/webhook.php @@ -0,0 +1,41 @@ + $commit, 'ref' => $payload['ref'], 'received_at' => gmdate(DATE_ATOM)], JSON_THROW_ON_ERROR); +if (file_put_contents($temporary, $data) === false || !rename($temporary, $file)) { + @unlink($temporary); http_response_code(503); exit('Queue unavailable'); +} +// systemd.path observes the queue even if a previous build is still active. +http_response_code(202); +header('Content-Type: application/json'); +echo json_encode(['accepted' => true, 'commit' => $commit]);