160 lines
7.9 KiB
Python
160 lines
7.9 KiB
Python
#!/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()
|