1#!/usr/bin/env python3
2"""Exercise Rust file operations on disposable ZFS datasets in the VM."""
3import argparse,json,os,shlex,subprocess,tempfile,time,urllib.request,urllib.error,uuid
4from pathlib import Path
5parser=argparse.ArgumentParser(description=__doc__)
6parser.add_argument('binary')
7parser.add_argument('--pool',default='studio-demo')
8args=parser.parse_args()
9run=lambda *argv:subprocess.run(argv,check=True,capture_output=True,text=True)
10dataset=args.pool+'/dashboard-test-'+uuid.uuid4().hex[:8]
11unit='studio-dashboard-files-test'
12with 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)