| 1 | #!/usr/bin/env python3 |
| 2 | """Exercise Rust file operations on disposable ZFS datasets in the VM.""" |
| 3 | import argparse,json,os,shlex,subprocess,tempfile,time,urllib.request,urllib.error,uuid |
| 4 | from pathlib import Path |
| 5 | parser=argparse.ArgumentParser(description=__doc__) |
| 6 | parser.add_argument('binary') |
| 7 | parser.add_argument('--pool',default='studio-demo') |
| 8 | args=parser.parse_args() |
| 9 | run=lambda *argv:subprocess.run(argv,check=True,capture_output=True,text=True) |
| 10 | dataset=args.pool+'/dashboard-test-'+uuid.uuid4().hex[:8] |
| 11 | unit='studio-dashboard-files-test' |
| 12 | with tempfile.TemporaryDirectory(prefix='studio-files-test-') as temporary: |
| 13 | root=Path(temporary)/'store';media=root/'media';docs=root/'work/docs' |
| 14 | variables=dict(v.split('=',1) for v in shlex.split(run('systemctl','show','-P','Environment','studio-dashboard').stdout)) |
| 15 | for key in ['STUDIO_INDEX_POOL','STUDIO_YT_STATE']:variables.pop(key,None) |
| 16 | variables.update(PORT='7074',STUDIO_METRICS='follow',STUDIO_STORE_ROOT=str(root),STUDIO_MEDIA_ROOT=str(media),STUDIO_DATA_DIR=temporary+'/state',STUDIO_FILES_WRITABLE='1',STUDIO_INDEX_POOL=dataset,STUDIO_INDEX_DIR=temporary+'/index') |
| 17 | proof={'Studio-Proxy-Token':Path(variables['STUDIO_PROXY_TOKEN_FILE']).read_text().strip()} if 'STUDIO_PROXY_TOKEN_FILE' in variables else {} |
| 18 | def start(): |
| 19 | run('systemd-run','--unit='+unit,'--collect','--property=CPUWeight=1000','--property=CPUQuota=100%','--property=PrivateMounts=yes',*['--setenv='+k+'='+v for k,v in variables.items()],str(Path(args.binary).resolve())) |
| 20 | deadline=time.monotonic()+120 |
| 21 | while time.monotonic()<deadline: |
| 22 | try:call('/api/me');return |
| 23 | except (OSError,AssertionError):time.sleep(.2) |
| 24 | raise RuntimeError('fixture did not start') |
| 25 | def call(path,body=None,status=200): |
| 26 | request=urllib.request.Request('http://127.0.0.1:7074'+path,data=json.dumps(body).encode() if body is not None else None,headers={'User-Name':'fixture','User-Groups':'infra-admin','Content-Type':'application/json',**proof}) |
| 27 | try:response=urllib.request.urlopen(request,timeout=30) |
| 28 | except urllib.error.HTTPError as error:response=error |
| 29 | with response: |
| 30 | data=response.read();assert response.status==status,(path,response.status,data[:300]) |
| 31 | return json.loads(data) if data and response.headers.get('content-type','').startswith('application/json') else data |
| 32 | def undo(op,prefix='/api/media'): |
| 33 | call(prefix+'/ops/'+op['id']+'/undo',{},204) |
| 34 | try: |
| 35 | for name,mount in [(dataset,root),(dataset+'/media',media),(dataset+'/docs',docs)]: |
| 36 | run('zfs','create','-o','mountpoint='+str(mount),name) |
| 37 | for name,size in [('shows/a/file.txt',120),('shows/a/two.txt',250),('seed/x.iso',10),('seed/y.iso',20)]: |
| 38 | file=media/name;file.parent.mkdir(parents=True,exist_ok=True);file.write_text('x'*size) |
| 39 | (docs/'notes').write_text('private fixture');(root/'local').write_text('top') |
| 40 | start() |
| 41 | deadline=time.monotonic()+120 |
| 42 | while time.monotonic()<deadline: |
| 43 | if list(Path(temporary+'/index').glob('*.db')):break |
| 44 | time.sleep(.2) |
| 45 | assert list(Path(temporary+'/index').glob('*.db')) |
| 46 | tree=call('/api/media/tree',{'path':'shows','expanded':'all'}) |
| 47 | assert tree['complete'] and tree['sizes']['shows']['size']==370 |
| 48 | assert call('/api/media/peek?path=shows/a/file.txt')['text']=='x'*120 |
| 49 | assert any(entry['name']=='seed' for entry in call('/api/media/list')['entries']) |
| 50 | call('/api/media/list?path=../work',status=403) |
| 51 | (media/'escape').symlink_to(docs,target_is_directory=True) |
| 52 | call('/api/media/list?path=escape',status=403) |
| 53 | op=call('/api/media/rename',{'renames':[{'path':'seed/x.iso','name':'first.iso'},{'path':'seed/y.iso','name':'second.iso'}]}) |
| 54 | assert sorted(p.name for p in (media/'seed').iterdir())==['first.iso','second.iso'];undo(op) |
| 55 | call('/api/media/rename',{'renames':[{'path':'seed/x.iso','name':'y.iso'}]},409) |
| 56 | assert (media/'seed/x.iso').exists() |
| 57 | op=call('/api/media/move',{'paths':['shows/a','shows/a/file.txt'],'to':'seed'}) |
| 58 | assert (media/'seed/a/file.txt').exists();undo(op);assert (media/'shows/a/file.txt').exists() |
| 59 | for name in ['media','work','work/docs']: |
| 60 | call('/api/storage/files/delete',{'paths':[name]},409) |
| 61 | call('/api/storage/files/move',{'paths':['local'],'to':'media'},409) |
| 62 | op=call('/api/storage/files/delete',{'paths':['media/seed/x.iso','work/docs/notes']}) |
| 63 | assert not (media/'seed/x.iso').exists() and not (docs/'notes').exists() |
| 64 | run('systemctl','stop',unit);start() |
| 65 | assert call('/api/storage/files/ops')[0]['id']==op['id'];undo(op,'/api/storage/files') |
| 66 | assert (media/'seed/x.iso').read_text()=='x'*10 and (docs/'notes').read_text()=='private fixture' |
| 67 | op=call('/api/media/folder',{'path':'','name':'new'}) |
| 68 | assert any(entry['name']=='new' for entry in call('/api/media/list')['entries']) |
| 69 | deadline=time.monotonic()+120 |
| 70 | while time.monotonic()<deadline: |
| 71 | if call('/api/storage/files/map?width=800&height=600&path=media/new')['tree'] is not None:break |
| 72 | time.sleep(.2) |
| 73 | else:raise AssertionError('file changes did not wake the index') |
| 74 | (media/'new/extra').write_text('new content') |
| 75 | call('/api/media/ops/'+op['id']+'/undo',{},409);assert (media/'new/extra').exists() |
| 76 | (media/'new/extra').unlink();undo(op) |
| 77 | assert not any(entry['name']=='new' for entry in call('/api/media/list')['entries']) |
| 78 | run('systemctl','stop',unit);variables.pop('STUDIO_FILES_WRITABLE');start() |
| 79 | call('/api/media/folder',{'path':'','name':'denied'},501);assert not (media/'denied').exists() |
| 80 | print('PASS: tree totals, text preview, path confinement, bulk rename, atomic clash refusal, collapsed move, dataset protection, cross-dataset refusal, multi-dataset delete/undo, persistent journal, index wake after mutation, occupied-folder refusal, read-only mode') |
| 81 | finally: |
| 82 | subprocess.run(['systemctl','stop',unit],capture_output=True) |
| 83 | subprocess.run(['zfs','destroy','-r',dataset],check=True,capture_output=True) |