#!/usr/bin/env python3 """Fresh Ghost 6.72 local API rehearsal. Never point --workdir at existing data. Requires a complete Ghost 6.72.0 installation (dependencies already installed) and Node matching Ghost's engine range. No npm install, real mail or payments. Credentials are random, memory-only and intentionally omitted from receipts. Raw local export/log/database files are private and must never be published. """ from pathlib import Path from urllib.request import Request, build_opener, HTTPCookieProcessor, ProxyHandler from urllib.error import HTTPError, URLError from http.cookiejar import CookieJar from datetime import datetime, timezone import argparse, hashlib, json, os, platform, secrets, shutil, signal, socket import subprocess, time, xml.etree.ElementTree as ET p=argparse.ArgumentParser(description=__doc__) p.add_argument('--ghost-dir',required=True,type=Path) p.add_argument('--node',required=True,type=Path) p.add_argument('--workdir',required=True,type=Path) p.add_argument('--port',type=int,default=18736) p.add_argument('--protect-db',action='append',type=Path,default=[]) a=p.parse_args();ghost=a.ghost_dir.resolve();node=a.node.resolve();work=a.workdir.resolve() assert json.loads((ghost/'package.json').read_text())['version']=='6.72.0' assert not work.exists(),'Work directory must not exist; no existing DB is ever reused.' with socket.socket() as s:s.bind(('127.0.0.1',a.port)) work.mkdir(parents=True,mode=0o700);work.chmod(0o700) (work/'current').symlink_to(ghost,target_is_directory=True) def sha(data):return hashlib.sha256(data).hexdigest() protected={str(x.resolve()):sha(x.read_bytes()) for x in a.protect_db} content=work/'content';content.mkdir(mode=0o700) for name in ['data','logs','settings','images','files','media','public','adapters']:(content/name).mkdir(mode=0o700) shutil.copytree(ghost/'content/themes',content/'themes',symlinks=False) base=f'http://127.0.0.1:{a.port}';api=base+'/ghost/api/admin/' cfg={'url':base,'server':{'host':'127.0.0.1','port':a.port,'shutdownTimeout':5000},'database':{'client':'better-sqlite3','connection':{'filename':str(content/'data/rehearsal.db')}},'paths':{'contentPath':str(content)},'privacy':{'useTinfoil':True},'security':{'staffDeviceVerification':False},'mail':{'transport':'stub'},'logging':{'level':'error','transports':['stdout']}} (work/'config.development.json').write_text(json.dumps(cfg,indent=2)+'\n');(work/'config.development.json').chmod(0o600) guard=work/'loopback-only.cjs' guard.write_text(r'''const net=require('node:net'),fs=require('node:fs'); const original=net.Socket.prototype.connect; net.Socket.prototype.connect=function(...args){ const first=Array.isArray(args[0])?args[0][0]:args[0]; const opts=first&&typeof first==='object'?first:{}; const host=String(opts.host||opts.hostname||(typeof args[1]==='string'?args[1]:'localhost')); const localPath=opts.path||(typeof first==='string'&&Number.isNaN(Number(first))); if(!localPath&&!['127.0.0.1','localhost','::1','[::1]'].includes(host)){ fs.appendFileSync(process.env.LOCAL_NETWORK_DENIALS,JSON.stringify({host})+'\n',{mode:0o600}); throw Object.assign(new Error('Local rehearsal blocks non-loopback connections'),{code:'ELOCALNETBLOCK'}); } return original.apply(this,args); }; ''');guard.chmod(0o600) denials=work/'network-denials.jsonl';denials.touch(mode=0o600) email='local-owner@example.invalid';password=secrets.token_urlsafe(40)+'A7!' jar=CookieJar();admin=build_opener(ProxyHandler({}),HTTPCookieProcessor(jar));public=build_opener(ProxyHandler({})) checks=[];requests=[];samples=[];proc=None;boots=[];rawlog=None def check(name,ok): checks.append({'check':name,'pass':bool(ok)}) assert ok,name def request(path,method='GET',body=None,authenticated=True,label=None): url=(api+path) if not path.startswith('/') else base+path headers={'Accept-Version':'v6.0','Origin':base,'Referer':base+'/ghost/'} if body is not None:headers['Content-Type']='application/json' data=json.dumps(body).encode() if body is not None else None start=time.perf_counter() try: response=(admin if authenticated else public).open(Request(url,data=data,headers=headers,method=method),timeout=15) status=response.status;payload=response.read();ctype=response.headers.get('Content-Type','') except HTTPError as e:status=e.code;payload=e.read();ctype=e.headers.get('Content-Type','') elapsed=(time.perf_counter()-start)*1000 requests.append({'step':label or path.split('?')[0],'method':method,'status':status,'milliseconds':round(elapsed,3),'bytes':len(payload),'content_type':ctype}) return status,payload def parsed(path,method='GET',body=None,label=None): status,data=request(path,method,body,label=label) if status not in [200,201,204]: (work/'private-api-error.txt').write_bytes(data);(work/'private-api-error.txt').chmod(0o600) raise RuntimeError(f'API request failed: {label or path.split("?")[0]} status {status}; private error retained') return json.loads(data) if data else None def resource_sample(phase): result=subprocess.check_output(['ps','-p',str(proc.pid),'-o','rss=,%cpu='],text=True).strip().split() samples.append({'phase':phase,'rss_kib':int(result[0]),'cpu_percent_ps_snapshot':float(result[1])}) def start(): global proc,rawlog env={k:v for k,v in os.environ.items() if k in ['PATH','LANG','LC_ALL','TMPDIR']} env.update({'NODE_ENV':'development','LOCAL_NETWORK_DENIALS':str(denials)}) rawlog=(work/'private-ghost.log').open('ab');os.chmod(work/'private-ghost.log',0o600) clock=time.perf_counter();proc=subprocess.Popen([str(node),'--require',str(guard),str(ghost/'index.js')],cwd=work,env=env,stdout=rawlog,stderr=subprocess.STDOUT,start_new_session=True) for _ in range(180): if proc.poll() is not None:raise RuntimeError('Owned Ghost process exited; inspect private log') try: status,data=request('authentication/setup/',authenticated=False,label='readiness') if status==200: boots.append(round(time.perf_counter()-clock,3));return json.loads(data) except (URLError,ConnectionError,TimeoutError):pass time.sleep(.25) raise RuntimeError('Ghost did not become ready within 45 seconds') def stop(): global proc,rawlog if proc and proc.poll() is None: proc.send_signal(signal.SIGTERM) try:proc.wait(timeout=12) except subprocess.TimeoutExpired:proc.kill();proc.wait(timeout=5) if rawlog:rawlog.close();rawlog=None def login(): status,_=request('session/','POST',{'username':email,'password':password},label='session-login') check('session login accepts fresh local account',status in [200,201,204] and len(jar)>0) def rss(): status,data=request('/rss/',authenticated=False,label='public-rss') check('RSS responds 200',status==200) root=ET.fromstring(data);return [(i.findtext('link'),ET.tostring(i,encoding='unicode')) for i in root.findall('./channel/item')] def record_export(data): doc=json.loads(data);data=doc['db'][0]['data'];posts=data['posts'] selected=[{'slug':x['slug'],'status':x['status'],'fixture_v2_present':'SOLO_GHOST_PUBLISHED_V2' in (x.get('lexical') or '')+(x.get('mobiledoc') or ''),'draft_marker_present':'SOLO_GHOST_PRIVATE_DRAFT' in (x.get('lexical') or '')+(x.get('mobiledoc') or '')} for x in posts if x['slug'] in ['solo-local-rehearsal','solo-private-next-note']] return {'format':'native Ghost GET /ghost/api/admin/db/ JSON','meta_version':doc['db'][0]['meta'].get('version'),'tables':len(data),'fixture_posts':selected,'raw_export_not_public':True} published=None;private_draft=None;export=None;owned_pids=[];workflow_complete=False started=datetime.now(timezone.utc).isoformat() try: setup=start();owned_pids.append(proc.pid);check('fresh database starts unconfigured',setup['setup'][0]['status'] is False) resource_sample('ready-before-setup') parsed('authentication/setup/','POST',{'setup':[{'name':'Local Rehearsal Owner','email':email,'password':password,'blogTitle':'Disposable Ghost Workflow','description':'Local API rehearsal; no subscription or customer service.'}]},'fresh-owner-setup') login();check('fresh setup becomes configured',parsed('authentication/setup/')['setup'][0]['status'] is True) status,_=request('posts/',authenticated=False,label='anonymous-admin-posts');check('anonymous admin API denied',status in [401,403]) first={'title':'Disposable operating note','slug':'solo-local-rehearsal','status':'draft','html':'
SOLO_GHOST_DRAFT_V1 — a disposable handoff note.
','tags':['local-rehearsal']} draft=parsed('posts/?source=html','POST',{'posts':[first]},'create-draft')['posts'][0] check('created post is draft',draft['status']=='draft') status,_=request('/solo-local-rehearsal/',authenticated=False,label='draft-public-route');check('draft public route is 404',status==404) check('draft excluded from RSS',not any('solo-local-rehearsal' in link for link,_ in rss())) publish={'updated_at':draft['updated_at'],'status':'published','html':'SOLO_GHOST_PUBLISHED_V2 — revised after the draft check.
Keep the content export separate from media and operating configuration.
'} published=parsed('posts/'+draft['id']+'/?source=html','PUT',{'posts':[publish]},'publish-edited-post')['posts'][0] check('published status returned',published['status']=='published') status,page=request('/solo-local-rehearsal/',authenticated=False,label='published-public-route');check('published route has revised marker',status==200 and b'SOLO_GHOST_PUBLISHED_V2' in page and b'SOLO_GHOST_DRAFT_V1' not in page) feed=rss();check('published route and revised content enter RSS',any('solo-local-rehearsal' in link and 'SOLO_GHOST_PUBLISHED_V2' in item for link,item in feed)) private_draft=parsed('posts/?source=html','POST',{'posts':[{'title':'Private next note','slug':'solo-private-next-note','status':'draft','html':'SOLO_GHOST_PRIVATE_DRAFT — never published.
'}]},'create-second-draft')['posts'][0] check('second draft excluded from RSS',not any('solo-private-next-note' in link for link,_ in rss())) status,raw=request('db/',label='native-content-export');check('native export responds 200',status==200) export_path=work/'private-native-export.json';export_path.write_bytes(raw);export_path.chmod(0o600) export=record_export(raw);check('native export contains published and draft fixtures',{(x['slug'],x['status']) for x in export['fixture_posts']}=={('solo-local-rehearsal','published'),('solo-private-next-note','draft')}) check('native export retains both actual content markers',any(x['fixture_v2_present'] for x in export['fixture_posts']) and any(x['draft_marker_present'] for x in export['fixture_posts'])) for _ in range(3):resource_sample('workflow-complete');time.sleep(1) stop();jar.clear() setup=start();owned_pids.append(proc.pid);check('restart retains setup',setup['setup'][0]['status'] is True) status,page=request('/solo-local-rehearsal/',authenticated=False,label='published-after-restart');check('restart retains published route/content',status==200 and b'SOLO_GHOST_PUBLISHED_V2' in page) login();after=parsed('posts/'+private_draft['id']+'/?formats=html','GET',label='draft-after-restart')['posts'][0] check('restart retains private draft/status',after['status']=='draft' and 'SOLO_GHOST_PRIVATE_DRAFT' in after['html']) status,_=request('/solo-private-next-note/',authenticated=False,label='private-draft-after-restart');check('restart draft remains 404',status==404) feed=rss();check('restart RSS contains only published fixture',any('solo-local-rehearsal' in link for link,_ in feed) and not any('solo-private-next-note' in link for link,_ in feed)) resource_sample('after-restart') workflow_complete=True finally: stop() with socket.socket() as s:closed=s.connect_ex(('127.0.0.1',a.port))!=0 unchanged=all(x.exists() and sha(x.read_bytes())==protected[str(x.resolve())] for x in a.protect_db) result={'date':datetime.now(timezone.utc).date().isoformat(),'started_at_utc':started,'finished_at_utc':datetime.now(timezone.utc).isoformat(),'status':'PASS' if workflow_complete and checks and all(x['pass'] for x in checks) and closed and unchanged else 'FAIL','workflow_complete':workflow_complete,'ghost_version':'6.72.0','node_version':subprocess.check_output([str(node),'--version'],text=True).strip(),'platform':{'system':platform.system(),'release':platform.release(),'machine':platform.machine(),'python':platform.python_version()},'development_mode':True,'database':'fresh better-sqlite3; not production MySQL','listen':'127.0.0.1 only','mail':'stub transport; no delivery test','network':'owned Node process blocks non-loopback connections','blocked_external_connect_attempts':len(denials.read_text().splitlines()),'startup_seconds':boots,'resources':samples,'resource_limitations':'ps snapshots of the owned Node server RSS/CPU, not peak startup memory, host capacity, benchmark or VPS forecast','export':export,'checks':checks,'requests':[x for x in requests if x['step']!='readiness'],'cleanup':{'owned_processes_stopped':all(subprocess.run(['kill','-0',str(pid)],stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL).returncode!=0 for pid in owned_pids),'loopback_port_closed':closed,'protected_existing_database_fingerprints_unchanged':unchanged},'not_tested':['native import/restore','newsletter or sign-in email delivery','member checkout or paid subscription','HTTPS/reverse proxy','concurrent load','theme/responsive browser QA','production deployment or VPS resource fit']} (work/'sanitized-observations.json').write_text(json.dumps(result,indent=2)+'\n');(work/'sanitized-observations.json').chmod(0o600) print(json.dumps({'status':result['status'],'checks':len(checks),'startup_seconds':boots,'requests':len(result['requests']),'cleanup':result['cleanup']},indent=2))