179 lines
11 KiB
Python
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()
|