Implement bounded browser service, consumer evidence and managed package
Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a13590-a16f-7f60-be60-fd71627bc99d
This commit is contained in:
parent
386b0c0f04
commit
ccf46d8c59
81 changed files with 3455 additions and 73 deletions
158
client/rapp_playwright.py
Normal file
158
client/rapp_playwright.py
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Dependency-free client for the rapp-playwright v1 job API (Python 3.10+)."""
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
|
||||
class NoRedirect(urllib.request.HTTPRedirectHandler):
|
||||
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
||||
return None
|
||||
|
||||
|
||||
class ClientError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class Client:
|
||||
def __init__(self, url):
|
||||
parsed = urllib.parse.urlsplit(url)
|
||||
if parsed.scheme not in ('http', 'https') or parsed.username or parsed.password or parsed.query or parsed.fragment:
|
||||
raise ClientError('invalid service URL')
|
||||
if parsed.scheme == 'http' and parsed.hostname not in ('localhost', '127.0.0.1', '::1'):
|
||||
raise ClientError('HTTP requires a loopback tunnel; remote endpoints require HTTPS')
|
||||
self.url = url.rstrip('/')
|
||||
self.opener = urllib.request.build_opener(NoRedirect)
|
||||
|
||||
def request(self, path, method='GET', body=None, token=None, binary=False):
|
||||
headers = {'X-Rapp-Protocol': '1'}
|
||||
if token:
|
||||
headers['Authorization'] = 'Bearer ' + token
|
||||
data = None
|
||||
if body is not None:
|
||||
headers['Content-Type'] = 'application/json'
|
||||
data = json.dumps(body).encode()
|
||||
req = urllib.request.Request(self.url + path, data=data, headers=headers, method=method)
|
||||
try:
|
||||
with self.opener.open(req, timeout=30) as response:
|
||||
data = response.read(21 * 1024 * 1024)
|
||||
if len(data) >= 21 * 1024 * 1024:
|
||||
raise ClientError('response exceeds artifact limit')
|
||||
return data if binary else json.loads(data)
|
||||
except urllib.error.HTTPError as exc:
|
||||
try:
|
||||
detail = json.loads(exc.read(32768))
|
||||
except (ValueError, OSError):
|
||||
detail = {'error': 'http_error'}
|
||||
raise ClientError(f'HTTP {exc.code}: {detail.get("error", "service_unavailable")}') from None
|
||||
except (urllib.error.URLError, TimeoutError, OSError):
|
||||
raise ClientError('service_unreachable: establish the documented tunnel and retry') from None
|
||||
|
||||
|
||||
def save(path, data):
|
||||
path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||||
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
||||
with os.fdopen(fd, 'w') as stream:
|
||||
json.dump(data, stream, indent=2)
|
||||
stream.write('\n')
|
||||
|
||||
|
||||
def handle(path):
|
||||
data = json.loads(path.read_text())
|
||||
if not re.fullmatch(r'[a-f0-9-]{36}', data['id']) or not re.fullmatch(r'[a-f0-9]{64}', data['token']):
|
||||
raise ClientError('invalid local run handle')
|
||||
return data
|
||||
|
||||
|
||||
def collect(client, run, out):
|
||||
receipt = client.request('/v1/runs/' + run['id'], token=run['token'])
|
||||
downloads = []
|
||||
for artifact in receipt.get('artifacts', []):
|
||||
name = artifact['name']
|
||||
if not re.fullmatch(r'[a-z0-9-]+\.png', name):
|
||||
raise ClientError('invalid artifact name')
|
||||
data = client.request('/v1/runs/' + run['id'] + '/artifacts/' + name, token=run['token'], binary=True)
|
||||
if len(data) != artifact['bytes']:
|
||||
raise ClientError('artifact size mismatch')
|
||||
fd = os.open(out / name, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
||||
with os.fdopen(fd, 'wb') as stream:
|
||||
stream.write(data)
|
||||
downloads.append({'name': name, 'bytes': len(data), 'sha256': hashlib.sha256(data).hexdigest()})
|
||||
receipt['downloads'] = downloads
|
||||
save(out / 'receipt.json', receipt)
|
||||
return receipt
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--url', default=os.environ.get('RAPP_PLAYWRIGHT_URL', 'http://127.0.0.1:18081'))
|
||||
subs = parser.add_subparsers(dest='command', required=True)
|
||||
subs.add_parser('preflight')
|
||||
for cmd in ('trial', 'run'):
|
||||
sub = subs.add_parser(cmd)
|
||||
sub.add_argument('--out', type=Path, required=True, help='New private artifact directory')
|
||||
if cmd == 'run':
|
||||
sub.add_argument('--spec', type=Path, required=True)
|
||||
for cmd in ('status', 'cancel', 'download'):
|
||||
sub = subs.add_parser(cmd)
|
||||
sub.add_argument('--handle', type=Path, required=True)
|
||||
args = parser.parse_args()
|
||||
client = Client(args.url)
|
||||
if args.command == 'preflight':
|
||||
print(json.dumps(client.request('/v1/preflight'), indent=2))
|
||||
return 0
|
||||
if args.command in ('status', 'cancel', 'download'):
|
||||
run = handle(args.handle)
|
||||
if args.command == 'download':
|
||||
result = collect(client, run, args.handle.parent)
|
||||
else:
|
||||
result = client.request('/v1/runs/' + run['id'], method='DELETE' if args.command == 'cancel' else 'GET', token=run['token'])
|
||||
print(json.dumps(result, indent=2))
|
||||
return 0
|
||||
preflight = client.request('/v1/preflight')
|
||||
if not preflight['available'] or preflight['api_version'] != '1':
|
||||
raise ClientError('service_not_ready_or_incompatible')
|
||||
spec = client.request('/v1/trial') if args.command == 'trial' else json.loads(args.spec.read_text())
|
||||
args.out.mkdir(parents=True, mode=0o700, exist_ok=False)
|
||||
save(args.out / 'preflight.json', preflight)
|
||||
save(args.out / 'invocation.json', {'argv': sys.argv, 'service_url': args.url})
|
||||
run = client.request('/v1/runs', 'POST', spec)
|
||||
save(args.out / 'run.json', run) # Contains the capability; never print or publish.
|
||||
deadline = time.monotonic() + spec.get('timeout_ms', 60000) / 1000 + 520
|
||||
try:
|
||||
while True:
|
||||
result = client.request('/v1/runs/' + run['id'], token=run['token'])
|
||||
if result['status'] in ('passed', 'failed', 'cancelled', 'interrupted'):
|
||||
break
|
||||
if time.monotonic() > deadline:
|
||||
raise ClientError('client_wait_timeout: run cancellation requested')
|
||||
time.sleep(0.25)
|
||||
except (KeyboardInterrupt, ClientError):
|
||||
try:
|
||||
client.request('/v1/runs/' + run['id'], 'DELETE', token=run['token'])
|
||||
except ClientError:
|
||||
pass # Server time budget still bounds disconnected clients.
|
||||
raise
|
||||
result = collect(client, run, args.out)
|
||||
print(json.dumps({'id': result['id'], 'status': result['status'], 'error': result['error'],
|
||||
'receipt': str(args.out / 'receipt.json'), 'artifacts': result['downloads']}, indent=2))
|
||||
return 0 if result['status'] == 'passed' else 2 if result['error'] == 'ui_assertion_failed' else 3
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
try:
|
||||
sys.exit(main())
|
||||
except (ClientError, ValueError, OSError, KeyError) as exc:
|
||||
print(json.dumps({'error': str(exc)}), file=sys.stderr)
|
||||
sys.exit(3)
|
||||
except KeyboardInterrupt:
|
||||
print(json.dumps({'error': 'cancelled_by_client'}), file=sys.stderr)
|
||||
sys.exit(4)
|
||||
Loading…
Add table
Add a link
Reference in a new issue