#!/usr/bin/env python3 """Verify a generated service outside the source tree using a real server process.""" import argparse import json import os from pathlib import Path import signal import socket import subprocess import tempfile import time import urllib.error import urllib.request MODULE = "git.yuntuo.hk/k2safe/go-omni" def run(args, cwd, env): result = subprocess.run(args, cwd=cwd, env=env, text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=180) if result.returncode: raise RuntimeError(f"{args[0]} {args[1:]} failed:\n{result.stdout}") return result.stdout def exerciseServer(binary, directory, env, stopSignal, cleanupMarker=None): with socket.socket() as listener: listener.bind(("127.0.0.1", 0)) address = f"127.0.0.1:{listener.getsockname()[1]}" serverEnv = {key: value for key, value in env.items() if not key.startswith("GO_OMNI_")} serverEnv.update(GO_OMNI_HTTP_ADDR=address, GO_OMNI_ENABLE_LOGGER="true", GO_OMNI_SHUTDOWN_TIMEOUT="2s") if cleanupMarker: serverEnv["GO_OMNI_CLEANUP_MARKER"] = str(cleanupMarker) logFile = directory / f"{binary.name}-{stopSignal.name}.log" client = urllib.request.build_opener(urllib.request.ProxyHandler({})) def request(path, data=None): req = urllib.request.Request(f"http://{address}{path}", data=data, headers={"Content-Type": "application/json", "X-Request-ID": "smokeRequest"}) with client.open(req, timeout=2) as response: if response.headers.get("X-Request-ID") != "smokeRequest": raise AssertionError("request ID was not preserved") return response.status, json.load(response) with logFile.open("w") as logs: process = subprocess.Popen([str(binary)], cwd=directory, env=serverEnv, stdout=logs, stderr=subprocess.STDOUT) try: deadline = time.monotonic() + 15 while True: if process.poll() is not None: raise RuntimeError(f"server exited during startup: {logFile.read_text()}") try: status, body = request("/health/live") break except (OSError, urllib.error.URLError): if time.monotonic() >= deadline: raise RuntimeError(f"server did not start: {logFile.read_text()}") time.sleep(0.05) if status != 200 or body["data"]["status"] != "ok": raise AssertionError(f"unexpected liveness: {body}") if not cleanupMarker: status, body = request("/health/ready") if status != 200 or body["code"] != "READY": raise AssertionError(f"unexpected readiness: {body}") status, body = request("/users", json.dumps({"name": "Smoke", "email": "smoke@example.com"}).encode()) user = body.get("data", body) if status != 201 or user["name"] != "Smoke" or "createdAt" not in user: raise AssertionError(f"unexpected created user: {user}") process.send_signal(stopSignal) exitCode = process.wait(timeout=8) if exitCode != 0: raise AssertionError(f"{stopSignal.name} exit code {exitCode}: {logFile.read_text()}") if cleanupMarker and cleanupMarker.read_text() != "closed": raise AssertionError("shutdown callback was not executed") # A built binary, rather than `go run`, lets us verify the actual # service exited without leaving its child listening in the background. with socket.socket() as connection: connection.settimeout(1) if connection.connect_ex(("127.0.0.1", int(address.rsplit(":", 1)[1]))) == 0: raise AssertionError("server still accepts connections after exit") finally: if process.poll() is None: process.kill() process.wait(timeout=5) print(f"PASS {binary.name}: HTTP checks, {stopSignal.name}, exit 0, listener closed", flush=True) def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--go", default="go", help="Go executable") parser.add_argument("--version", default="v0.5.0", help="Released CLI version to install") parser.add_argument("--cli", help="Use a locally built CLI instead of installing") parser.add_argument("--framework-version", help="Explicit version passed to the CLI") parser.add_argument("--framework-dir", help="Local checkout for pre-release verification") parser.add_argument("--skip-run-check", action="store_true", help="Skip core Run signal checks when testing an older framework") args = parser.parse_args() env = dict(os.environ, GOWORK="off") env["GOPRIVATE"] = ",".join(filter(None, [env.get("GOPRIVATE", ""), "git.yuntuo.hk"])) with tempfile.TemporaryDirectory(prefix="go omni smoke ") as temporary: directory = Path(temporary).resolve() if args.cli: cli = Path(args.cli).resolve() else: env["GOBIN"] = str(directory / "bin") print(f"Installing remote CLI {args.version}", flush=True) run([args.go, "install", f"{MODULE}/cmd/go-omni@{args.version}"], directory, env) cli = directory / "bin" / "go-omni" command = [str(cli), "new", "--module", "example.com/scaffoldSmoke"] if args.framework_version: command += ["--framework-version", args.framework_version] if args.framework_dir: command += ["--framework-dir", str(Path(args.framework_dir).resolve())] project = directory / "billing" run(command + [str(project)], directory, env) module = json.loads(run([args.go, "mod", "edit", "-json"], project, env)) requirements = {entry["Path"]: entry["Version"] for entry in module["Require"]} expectedVersion = args.framework_version or args.version if requirements.get(MODULE) != expectedVersion: raise AssertionError(f"expected framework {expectedVersion}, got {requirements}") if not args.framework_dir and module.get("Replace"): raise AssertionError("remote generation must not contain local replace directives") print(f"PASS generated module pins {expectedVersion}; local replace: {bool(module.get('Replace'))}", flush=True) run([args.go, "mod", "tidy"], project, env) run([args.go, "test", "./..."], project, env) binary = directory / "server" run([args.go, "build", "-o", str(binary), "./cmd/server"], project, env) for stopSignal in (signal.SIGINT, signal.SIGTERM): exerciseServer(binary, project, env, stopSignal) if not args.skip_run_check: # Verify the framework's convenient Run API independently from the # generated main's caller-owned signal context. helper = project / "cmd" / "runCheck" helper.mkdir() helper.joinpath("main.go").write_text('''package main import ( "log" "os" omni "git.yuntuo.hk/k2safe/go-omni" ) func main() { app := omni.New() app.GET("/health/live", func(ctx *omni.Context) error { return ctx.OK(map[string]string{"status": "ok"}) }) app.OnShutdown(func() error { return os.WriteFile(os.Getenv("GO_OMNI_CLEANUP_MARKER"), []byte("closed"), 0600) }) if err := app.Run(os.Getenv("GO_OMNI_HTTP_ADDR")); err != nil { log.Fatal(err) } } ''') runBinary = directory / "runCheck" run([args.go, "build", "-o", str(runBinary), "./cmd/runCheck"], project, env) for stopSignal in (signal.SIGINT, signal.SIGTERM): marker = project / f"cleanup-{stopSignal.name}" exerciseServer(runBinary, project, env, stopSignal, marker) if __name__ == "__main__": main()