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()
|
||||
97
scripts/verifyManagedRelease.py
Normal file
97
scripts/verifyManagedRelease.py
Normal file
@@ -0,0 +1,97 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Accept a fixed managed-project release using a fresh remote module cache."""
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
import signal
|
||||
import tempfile
|
||||
import time
|
||||
|
||||
from verifyErrorInheritanceRelease import run
|
||||
from verifyManagedProject import serve, verify_contract_changes
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
MODULE = "git.yuntuo.hk/k2safe/go-omni"
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--version", required=True)
|
||||
parser.add_argument("--report", type=Path, default=ROOT / "performance/managedProjectRemote.json")
|
||||
args = parser.parse_args()
|
||||
if not re.fullmatch(r"v\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?", args.version):
|
||||
parser.error("a fixed release version is required")
|
||||
env = {k: v for k, v in os.environ.items() if not k.startswith("GO_OMNI_")}
|
||||
env.update(GOWORK="off", GOFLAGS="", GOPRIVATE="git.yuntuo.hk", GONOSUMDB="git.yuntuo.hk")
|
||||
started = time.monotonic()
|
||||
checks = ["remoteCLIInstall", "freshModuleCache", "fixedVersionNoReplace"]
|
||||
with tempfile.TemporaryDirectory(prefix="goOmniManagedRelease-") as directory:
|
||||
temp = Path(directory)
|
||||
(temp / "bin").mkdir()
|
||||
env.update(GOBIN=str(temp / "bin"), GOMODCACHE=str(temp / "modcache"))
|
||||
print(f"Installing remote CLI {args.version} in an empty module cache", flush=True)
|
||||
run(["go", "install", MODULE + "/cmd/go-omni@" + args.version], temp, env)
|
||||
cli = temp / "bin/go-omni"
|
||||
# Do not pass --framework-version: verify that the installed CLI pins
|
||||
# its own downloaded version, without a source-directory replacement.
|
||||
starter = temp / "starter"
|
||||
run([cli, "new", "--module", "example.com/releaseStarter", starter], temp, env)
|
||||
run(["go", "mod", "tidy"], starter, env)
|
||||
dependency = json.loads(run(["go", "list", "-m", "-json", MODULE], starter, env))
|
||||
assert dependency["Version"] == args.version and dependency.get("Sum") and not dependency.get("Replace")
|
||||
verify_contract_changes(cli, starter, env)
|
||||
checks += ["contractChangePreview", "persistentLogicSignatureFailure", "businessSourcePreserved", "fixedConsumerBuild"]
|
||||
serve(["go", "run", "."], starter, env, "nethttp", signal.SIGTERM, False, logs=temp)
|
||||
for transport in ("nethttp", "native"):
|
||||
for sig in (signal.SIGINT, signal.SIGTERM):
|
||||
serve([str(starter / "releaseStarter")], starter, env, transport, sig, True, logs=temp)
|
||||
checks += ["rootGoRun", "nethttpAndNativeSignals", "listenerReleased"]
|
||||
|
||||
# Only project-owned business implementations come from the example.
|
||||
# Main, handlers, DTOs and startup are emitted by the downloaded CLI.
|
||||
managed = temp / "managed"
|
||||
run([cli, "new", "--api", ROOT / "examples/contracts/managed.api", "--module", "example.com/managed", managed], temp, env)
|
||||
example = ROOT / "examples/managedService"
|
||||
sources = list((example / "internal/logic/demo").glob("*.go"))
|
||||
sources += [example / "internal/svc/options.go", example / "internal/apierrors/catalog.go"]
|
||||
for source in sources:
|
||||
target = managed / source.relative_to(example)
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copyfile(source, target)
|
||||
run([cli, "api", "generate", "--check"], managed, env)
|
||||
run(["go", "mod", "tidy"], managed, env)
|
||||
run(["go", "build", "-o", temp / "managedServer", "."], managed, env)
|
||||
managed_dependency = json.loads(run(["go", "list", "-m", "-json", MODULE], managed, env))
|
||||
assert managed_dependency["Sum"] == dependency["Sum"] and not managed_dependency.get("Replace")
|
||||
for transport in ("nethttp", "native"):
|
||||
serve([str(temp / "managedServer")], managed, env, transport, signal.SIGTERM, True, contract=True, logs=temp)
|
||||
checks += ["default404And405", "goOwnedErrorCatalog", "unwrappedDownloads", "rawCallback", "multipart400And413", "nethttpAndNativeTransfers"]
|
||||
|
||||
env.update(GO_OMNI_SCAFFOLD_INTEGRATION="1", GO_OMNI_RELEASE_CLI=str(cli), GO_OMNI_RELEASE_VERSION=args.version)
|
||||
tests = ["TestManagedGeneratedProject", "TestErrorInheritanceConsumer", "TestErrorParamsConsumer", "TestCompatibilityConsumer"]
|
||||
print("Running generated consumers against the remote framework, including lifecycle failure cleanup", flush=True)
|
||||
output = run(["go", "test", "-race", "-count=1", "-json", "-timeout=240s", "./cmd/go-omni/apigen", "-run", "^(" + "|".join(tests) + ")$"], ROOT, env)
|
||||
events = [json.loads(line) for line in output.splitlines() if line.startswith("{")]
|
||||
passed = [e["Test"] for e in events if e.get("Action") == "pass" and "Test" in e]
|
||||
assert sorted(passed) == sorted(tests), output
|
||||
evidence = "".join(e.get("Output", "") for e in events)
|
||||
sums = re.findall(r"checksum (h1:\S+); no local replace", evidence)
|
||||
assert len(sums) == len(tests) and all(s == dependency["Sum"] for s in sums), "remote consumer checksum mismatch"
|
||||
checks += ["initializationAndListenFailureCleanup", "drainBeforeDependencyClose", "generatedConsumerRaceTests", "errorParamsAndInheritance", "manualHooksAndScalars"]
|
||||
|
||||
sources = ["scripts/verifyManagedRelease.py", "scripts/verifyManagedProject.py", "cmd/go-omni/apigen/managed_test.go"]
|
||||
sources += [str(p.relative_to(ROOT)) for p in (ROOT / "cmd/go-omni/apigen/testdata/managed").glob("*.go")]
|
||||
report = {"passed": True, "frameworkVersion": args.version, "frameworkSum": dependency["Sum"],
|
||||
"freshModuleCache": True, "noLocalReplace": True,
|
||||
"durationSeconds": round(time.monotonic() - started, 2), "checks": checks,
|
||||
"sourceHashes": {p: hashlib.sha256((ROOT / p).read_bytes()).hexdigest() for p in sources}}
|
||||
args.report.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n")
|
||||
print(f"PASS remote managed project release {args.version}; temporary files and processes removed", flush=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user