feat: release managed service generation and contract diagnostics
This commit is contained in:
167
scripts/verifyManagedProject.py
Normal file
167
scripts/verifyManagedProject.py
Normal file
@@ -0,0 +1,167 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Verify source-generated root entrypoints with real local processes only."""
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import signal
|
||||
import socket
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def run(args, cwd, env):
|
||||
result = subprocess.run(args, cwd=cwd, env=env, capture_output=True, text=True, timeout=120)
|
||||
if result.returncode:
|
||||
output = re.sub(r'https://[^/@\s]+:[^/@\s]+@', 'https://', result.stdout + result.stderr)
|
||||
raise RuntimeError(f'{args[0]} failed: {output[-8000:]}')
|
||||
return result.stdout
|
||||
|
||||
|
||||
def free_port():
|
||||
with socket.socket() as s:
|
||||
s.bind(('127.0.0.1', 0))
|
||||
return s.getsockname()[1]
|
||||
|
||||
|
||||
def request(url, data=None, headers=None, method=None):
|
||||
try:
|
||||
req = urllib.request.Request(url, data=data, headers=headers or {}, method=method)
|
||||
with urllib.request.build_opener(urllib.request.ProxyHandler({})).open(req, timeout=0.4) as response:
|
||||
return response.status, response.read()
|
||||
except urllib.error.HTTPError as exc:
|
||||
return exc.code, exc.read()
|
||||
|
||||
|
||||
def verify_contract_changes(cli, project, env):
|
||||
api = project/'api/service.api'
|
||||
logic = project/'internal/logic/greet/greet.go'
|
||||
original = logic.read_bytes()
|
||||
api.write_text(api.read_text().replace('GreetReq', 'HelloReq'))
|
||||
def sources():
|
||||
return {str(p.relative_to(project)): p.read_bytes() for p in project.rglob('*') if p.is_file()}
|
||||
before = sources()
|
||||
preview = run([str(cli), 'api', 'generate', '--dry-run'], project, env)
|
||||
assert 'ERROR [logicSignature]' in preview and '*types.HelloReq' in preview
|
||||
assert before == sources(), 'preview modified project'
|
||||
for generated in (False, True):
|
||||
if generated:
|
||||
output = run([str(cli), 'api', 'generate'], project, env)
|
||||
assert 'ERROR [logicSignature]' in output and logic.read_bytes() == original
|
||||
before = sources()
|
||||
result = subprocess.run([str(cli), 'api', 'generate', '--check'], cwd=project, env=env, capture_output=True, text=True, timeout=30)
|
||||
assert result.returncode != 0 and 'ERROR [logicSignature]' in result.stdout
|
||||
assert 'internal/logic/greet/greet.go:' in result.stdout and before == sources()
|
||||
logic.write_bytes(original.replace(b'GreetReq', b'HelloReq'))
|
||||
run([str(cli), 'api', 'generate', '--check'], project, env)
|
||||
run(['go', 'test', '-mod=mod', './...'], project, env)
|
||||
run(['go', 'build', '.'], project, env)
|
||||
|
||||
|
||||
def serve(args, project, env, transport, sig, strict_exit, contract=False, logs=None):
|
||||
port = free_port()
|
||||
env = dict(env, GO_OMNI_HTTP_ADDR=f'127.0.0.1:{port}', GO_OMNI_TRANSPORT=transport)
|
||||
log_path = (logs or project.parent) / f'{transport}-{sig.name}-{strict_exit}-{contract}.log'
|
||||
with log_path.open('wb') as log:
|
||||
proc = subprocess.Popen(args, cwd=project, env=env, stdout=log, stderr=log, start_new_session=True)
|
||||
try:
|
||||
deadline = time.monotonic() + 30
|
||||
base = f'http://127.0.0.1:{port}'
|
||||
while time.monotonic() < deadline:
|
||||
if proc.poll() is not None:
|
||||
raise RuntimeError('server exited before readiness: ' + log_path.read_text()[-2000:])
|
||||
try:
|
||||
if request(base + '/health/ready')[0] == 200:
|
||||
break
|
||||
except (OSError, urllib.error.URLError):
|
||||
pass
|
||||
time.sleep(0.05)
|
||||
else:
|
||||
raise RuntimeError('server readiness timed out')
|
||||
if contract:
|
||||
status, body = request(base + '/check?kind=fixed')
|
||||
assert status == 400 and json.loads(body) == {'code': 400002, 'msg': '用户名格式不正确', 'data': None}
|
||||
assert json.loads(request(base + '/missing')[1])['msg'] == '接口不存在'
|
||||
assert request(base + '/check', method='POST')[0] == 405
|
||||
assert json.loads(request(base + '/raw')[1]) == {'bytes': 3}
|
||||
assert request(base + '/download') == (200, b'file-content')
|
||||
assert request(base + '/callback', b'{ "raw" : 1 }', {'X-Signature': 'valid'}) == (200, b'ACK:{ "raw" : 1 }')
|
||||
assert request(base + '/callback', b'x' * 65, {'X-Signature': 'valid'})[0] == 413
|
||||
upload = b'--omniBoundary\r\nContent-Disposition: form-data; name="file"; filename="test.txt"\r\nContent-Type: text/plain\r\n\r\nhello\r\n--omniBoundary--\r\n'
|
||||
status, body = request(base + '/upload', upload, {'Content-Type': 'multipart/form-data; boundary=omniBoundary'})
|
||||
assert status == 200 and json.loads(body)['data'] == {'bytes': 5}
|
||||
# Exercise malformed multipart over real HTTP on both transports.
|
||||
for broken in (upload.split(b'hello')[0] + b'private file bytes', b'--omniBoundary\r\nContent-Disposition: form-data'):
|
||||
status, body = request(base + '/upload', broken, {'Content-Type': 'multipart/form-data; boundary=omniBoundary'})
|
||||
assert status == 400 and json.loads(body) == {'code': 400004, 'msg': '上传内容不完整', 'data': None}
|
||||
else:
|
||||
status, body = request(base + '/api/greet/Alice')
|
||||
assert status == 200 and json.loads(body)['data']['message'] == 'Hello Alice'
|
||||
assert request(base + '/missing')[0] == 404
|
||||
os.killpg(proc.pid, sig)
|
||||
exit_code = proc.wait(timeout=10)
|
||||
if strict_exit:
|
||||
assert exit_code == 0, exit_code
|
||||
# A go run parent can exit from the signal; its compiled child must also release its listener.
|
||||
deadline = time.monotonic() + 5
|
||||
while time.monotonic() < deadline:
|
||||
try:
|
||||
with socket.create_connection(('127.0.0.1', port), timeout=0.1):
|
||||
pass
|
||||
except OSError:
|
||||
break
|
||||
time.sleep(0.05)
|
||||
else:
|
||||
raise RuntimeError('listener remains after shutdown')
|
||||
finally:
|
||||
try:
|
||||
os.killpg(proc.pid, signal.SIGKILL)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
proc.wait(timeout=10)
|
||||
|
||||
|
||||
def main():
|
||||
started = time.monotonic()
|
||||
env = dict(os.environ, GOWORK='off', GOFLAGS='')
|
||||
# Prevent inherited project configuration from binding a real server or loading secrets.
|
||||
env = {k: v for k, v in env.items() if not k.startswith('GO_OMNI_')}
|
||||
with tempfile.TemporaryDirectory(prefix='omni-managed-') as temp:
|
||||
work = Path(temp)
|
||||
cli = work / 'go-omni'
|
||||
project = work / 'service'
|
||||
run(['go', 'build', '-o', str(cli), './cmd/go-omni'], ROOT, env)
|
||||
run([str(cli), 'new', '--framework-dir', str(ROOT), '--module', 'example.com/managedSmoke', str(project)], ROOT, env)
|
||||
run(['go', 'mod', 'tidy'], project, env)
|
||||
run(['go', 'build', '.'], project, env)
|
||||
assert (project / 'main.go').is_file() and not (project / 'cmd/server/main.go').exists()
|
||||
before = {str(p.relative_to(project)): hashlib.sha256(p.read_bytes()).hexdigest() for p in project.rglob('*.go')}
|
||||
run([str(cli), 'api', 'generate'], project, env)
|
||||
run([str(cli), 'api', 'generate', '--check'], project, env)
|
||||
after = {str(p.relative_to(project)): hashlib.sha256(p.read_bytes()).hexdigest() for p in project.rglob('*.go')}
|
||||
assert before == after
|
||||
verify_contract_changes(cli, project, env)
|
||||
serve(['go', 'run', '.'], project, env, 'nethttp', signal.SIGTERM, False)
|
||||
for transport in ('nethttp', 'native'):
|
||||
for sig in (signal.SIGINT, signal.SIGTERM):
|
||||
serve([str(project / 'managedSmoke')], project, env, transport, sig, True)
|
||||
example = ROOT/'examples/managedService'
|
||||
run(['go', 'build', '-o', str(work/'managedExample'), '.'], example, env)
|
||||
for transport in ('nethttp', 'native'):
|
||||
serve([str(work/'managedExample')], example, env, transport, signal.SIGTERM, True, contract=True, logs=work)
|
||||
sources = ['cmd/go-omni/apigen/generate.go', 'cmd/go-omni/apigen/templates.go', 'cmd/go-omni/apigen/startup.go', 'cmd/go-omni/apigen/managed_test.go', 'cmd/go-omni/apigen/contractChanges.go', 'cmd/go-omni/apigen/logicCheck.go', 'cmd/go-omni/apigen/write.go', 'cmd/go-omni/api.go', 'config/config.go', 'httpx/transfer.go', 'httpx/multipart.go', 'scripts/verifyManagedProject.py']
|
||||
sources += [str(p.relative_to(ROOT)) for p in (ROOT/'cmd/go-omni/scaffold/templates').glob('api-*.tmpl')]
|
||||
sources += [str(p.relative_to(ROOT)) for p in (ROOT/'cmd/go-omni/apigen/testdata/managed').glob('*.go')]
|
||||
report = {'passed': True, 'mode': 'localSource', 'durationSeconds': round(time.monotonic()-started, 2), 'checks': ['rootGoRun', 'rootGoBuild', 'stableRegeneration', 'checkMainOwnership', 'contractChangePreview', 'logicSignatureCheckPersists', 'businessSourcePreserved', 'fixedConsumerBuild', 'nethttpSIGINT', 'nethttpSIGTERM', 'nativeSIGINT', 'nativeSIGTERM', 'listenerReleased', 'temporaryProcessesRemoved', 'nethttpGeneratedTransfers', 'nativeGeneratedTransfers', 'malformedMultipart400', 'goOwnedErrors', 'default404And405'], 'sourceHashes': {p: hashlib.sha256((ROOT/p).read_bytes()).hexdigest() for p in sources}}
|
||||
(ROOT/'performance/managedProjectLocal.json').write_text(json.dumps(report, indent=2)+'\n')
|
||||
print('PASS generated root project: go run/build, regeneration, nethttp/native signals; temporary files removed')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
Reference in New Issue
Block a user