Files
go-omni/scripts/verifyScaffold.py

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_MODE="release",
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.3.1", 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(omni.WithMode("release"))
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()