feat: complete API-driven projects with infrastructure and business acceptance
This commit is contained in:
178
scripts/verifyScaffoldModules.py
Normal file
178
scripts/verifyScaffoldModules.py
Normal file
@@ -0,0 +1,178 @@
|
||||
#!/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()
|
||||
Reference in New Issue
Block a user