feat: release managed service generation and contract diagnostics

This commit is contained in:
2026-09-24 17:34:21 +08:00
parent eb69260586
commit 158b8002b3
85 changed files with 5722 additions and 360 deletions

View 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()

View 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()