Files
go-omni/scripts/verifyScaffoldModules.py

179 lines
11 KiB
Python

#!/usr/bin/env python3
"""Generate runnable logging/storage projects and verify their HTTP and shutdown paths."""
import argparse
from datetime import datetime, timezone
import hashlib
import json
import os
from pathlib import Path
import secrets
import signal
import socket
import subprocess
import tempfile
import time
import urllib.error
import urllib.parse
import urllib.request
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--cli', type=Path)
parser.add_argument('--framework-dir', type=Path, default=Path(__file__).resolve().parent.parent)
parser.add_argument('--output', type=Path, default=Path('performance/scaffoldModulesLocal.json'))
args = parser.parse_args()
root = args.framework_dir.resolve()
output = args.output.resolve()
output.parent.mkdir(parents=True, exist_ok=True)
log_path = output.with_suffix('.txt')
env = {k: v for k, v in os.environ.items() if not k.startswith('GO_OMNI_')}
env.update(GOWORK='off', GOMAXPROCS='4')
report = {'status': 'running', 'startedAt': datetime.now(timezone.utc).isoformat(), 'cases': [], 'sourceSha256': {}}
sources = list((root / 'cmd/go-omni').rglob('*.go')) + list((root / 'cmd/go-omni/scaffold').rglob('*.tmpl'))
sources += [root / 'config/env.go', Path(__file__).resolve()]
for p in sorted(sources):
report['sourceSha256'][str(p.relative_to(root))] = hashlib.sha256(p.read_bytes()).hexdigest()
opener = urllib.request.build_opener(urllib.request.ProxyHandler({}))
def request(method, url, body=None, headers=None):
req = urllib.request.Request(url, data=body, headers=headers or {}, method=method)
try:
response = opener.open(req, timeout=3)
except urllib.error.HTTPError as error:
response = error
with response:
return response.status, response.read(), dict(response.headers)
def call(base, token, path, value=None, expected=200, method='POST'):
headers = {'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token, 'X-Request-ID': 'scaffold-module-test'}
body = json.dumps(value).encode() if value is not None else None
status, data, _ = request(method, base + path, body, headers)
if status != expected:
raise AssertionError(f'{method} {path}: {status}, expected {expected}; {data[:512]!r}')
return json.loads(data) if data else None
failure = None
with log_path.open('w') as log, tempfile.TemporaryDirectory(prefix='goOmniModuleAcceptance') as directory:
temp = Path(directory).resolve()
def run(command, cwd):
subprocess.run(command, cwd=cwd, env=env, stdout=log, stderr=subprocess.STDOUT, check=True, timeout=180)
log.flush()
try:
cli = args.cli.resolve() if args.cli else temp / 'go-omni'
if not args.cli:
run(['go', 'build', '-o', str(cli), './cmd/go-omni'], root)
profiles = [('stderr', 'json', 'nethttp', signal.SIGTERM), ('stdout', 'text', 'native', signal.SIGINT),
('file', 'json', 'native', signal.SIGTERM), ('file', 'text', 'nethttp', signal.SIGINT)]
for index, (sink, fmt, transport, stop_signal) in enumerate(profiles):
print(f'Checking {sink}/{fmt}, {transport}, {stop_signal.name}', flush=True)
project = temp / ('case' + str(index))
run([str(cli), 'new', '--module', 'example.com/moduleAcceptance', '--framework-dir', str(root),
'--storage', 'local', '--log-output', sink, '--log-format', fmt, str(project)], temp)
run(['go', 'test', '-mod=mod', '-race', '-p=2', './...'], project)
binary = project / 'server'
run(['go', 'build', '-o', str(binary), './cmd/server'], project)
with socket.socket() as reservation:
reservation.bind(('127.0.0.1', 0))
port = reservation.getsockname()[1]
base = f'http://127.0.0.1:{port}'
token = secrets.token_hex(32)
runtime_env = dict(env, GO_OMNI_FILES_TOKEN=token, GO_OMNI_STORAGE_SIGNING_KEY=secrets.token_hex(32),
GO_OMNI_HTTP_ADDR=f'127.0.0.1:{port}', GO_OMNI_TRANSPORT=transport,
GO_OMNI_STORAGE_PUBLIC_URL=base + '/storage/object', GO_OMNI_STORAGE_MAX_BYTES='64')
process_log = project / 'process.log'
with process_log.open('w') as process_output:
process = subprocess.Popen([str(binary)], cwd=project, env=runtime_env, stdout=process_output, stderr=subprocess.STDOUT)
try:
deadline = time.monotonic() + 15
while True:
if process.poll() is not None:
raise RuntimeError('startup failed: ' + process_log.read_text())
try:
status, _, _ = request('GET', base + '/health/ready')
if status == 200:
break
except (OSError, urllib.error.URLError):
pass
if time.monotonic() > deadline:
raise TimeoutError('service readiness timeout')
time.sleep(0.05)
call(base, token, '/users', {'name': 'Example', 'email': 'module@example.com'}, 201)
data = b'hello go-omni'
upload_headers = {'Content-Type': 'text/plain', 'Authorization': 'Bearer ' + token, 'X-Request-ID': 'scaffold-module-test'}
status, raw, _ = request('POST', base + '/files', data, upload_headers)
if status != 201:
raise AssertionError(f'upload failed: {status}, {raw!r}')
key = json.loads(raw)['data']['key']
download = call(base, token, '/files/downloadLink', {'key': key})['data']['url']
status, raw, _ = request('GET', download)
if status != 200 or raw != data:
raise AssertionError('download content mismatch')
parts = urllib.parse.urlsplit(download)
query = urllib.parse.parse_qs(parts.query)
query['signature'] = ['tampered']
altered = urllib.parse.urlunsplit(parts._replace(query=urllib.parse.urlencode(query, doseq=True)))
if request('GET', altered)[0] != 403:
raise AssertionError('tampered signature accepted')
direct = call(base, token, '/files/uploadLink', {'size': len(data), 'contentType': 'text/plain'})['data']
signed = direct['request']
headers = {k: ', '.join(v) for k, v in signed['headers'].items()}
if request(signed['method'], signed['url'], data, headers)[0] != 201:
raise AssertionError('signed upload failed')
if request('POST', base + '/files', data, {'Content-Type': 'text/plain'})[0] != 401:
raise AssertionError('missing authorization accepted')
if request('POST', base + '/files', b'x' * 65, upload_headers)[0] != 413:
raise AssertionError('oversized upload accepted')
wrong_headers = dict(upload_headers, **{'Content-Type': 'image/png'})
if request('POST', base + '/files', data, wrong_headers)[0] != 415:
raise AssertionError('forged type accepted')
call(base, token, '/files/downloadLink', {'key': '../bad'}, 400)
call(base, token, '/files/downloadLink', {'key': 'missing'}, 404)
process.send_signal(stop_signal)
exit_code = process.wait(timeout=12)
if exit_code != 0:
raise AssertionError(f'{stop_signal.name}: exit {exit_code}')
finally:
if process.poll() is None:
process.kill()
process.wait(timeout=5)
sink_path = project / 'var/log/server.log' if sink == 'file' else process_log
text = sink_path.read_text()
if token in text or 'signature=' in text or '"url":' in text:
raise AssertionError('secret or signed URL appeared in logs')
for event in ('user.created', 'file.uploaded', 'file.downloadLink.issued', 'file.uploadLink.issued', 'http.request', 'scaffold-module-test'):
if event not in text:
raise AssertionError('missing log evidence: ' + event)
if fmt == 'json':
entries = [json.loads(line) for line in text.splitlines() if line.strip()]
if not any(e.get('status') == 403 and e.get('path') == '/storage/object' for e in entries):
raise AssertionError('signed endpoint status lost in access logs')
with socket.socket() as check:
check.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
check.bind(('127.0.0.1', port))
# Startup validation must fail instead of silently exposing control endpoints.
invalid_env = dict(runtime_env, GO_OMNI_FILES_TOKEN='')
invalid = subprocess.run([str(binary)], cwd=project, env=invalid_env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=5)
if invalid.returncode == 0 or b'GO_OMNI_FILES_TOKEN' not in invalid.stdout:
raise AssertionError('missing token did not fail startup')
report['cases'].append({'logOutput': sink, 'logFormat': fmt, 'transport': transport, 'signal': stop_signal.name,
'status': 'passed', 'exitCode': exit_code, 'portReleased': True, 'missingTokenRejected': True})
report['status'] = 'passed'
except Exception as error:
failure = str(error)
report['status'], report['error'] = 'failed', failure
finally:
log.flush()
report['finishedAt'] = datetime.now(timezone.utc).isoformat()
report['logSha256'] = hashlib.sha256(log_path.read_bytes()).hexdigest()
output.write_text(json.dumps(report, indent=2) + '\n')
if failure:
raise SystemExit('FAIL: ' + failure + '; see ' + str(log_path))
print('PASS generated project HTTP/logging/shutdown; report: ' + str(output), flush=True)
if __name__ == '__main__':
main()