feat: complete API-driven projects with infrastructure and business acceptance
This commit is contained in:
233
scripts/verifyAPI.py
Normal file
233
scripts/verifyAPI.py
Normal file
@@ -0,0 +1,233 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Bounded local acceptance of API-generated projects, on both Omni transports.
|
||||
|
||||
No remote hosts, cloud credentials or database are used. Child servers and
|
||||
temporary modules are always cleaned up. Run from any directory with Python 3.
|
||||
"""
|
||||
import hashlib
|
||||
import http.client
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import signal
|
||||
import socket
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
REPORT = ROOT / "performance/apiGenerationLocal.json"
|
||||
ENV = {k: v for k, v in os.environ.items() if not k.startswith("GO_OMNI_")}
|
||||
ENV.update(GOWORK="off", GOFLAGS="")
|
||||
|
||||
|
||||
def run(args, cwd=ROOT, ok=True):
|
||||
result = subprocess.run([str(x) for x in args], cwd=cwd, env=ENV,
|
||||
text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
||||
timeout=90)
|
||||
if ok and result.returncode:
|
||||
raise AssertionError(f"{args}:\n{result.stdout}")
|
||||
if not ok and not result.returncode:
|
||||
raise AssertionError(f"command unexpectedly succeeded: {args}")
|
||||
return result.stdout
|
||||
|
||||
|
||||
def request(port, method, path, body=None, headers=None):
|
||||
conn = http.client.HTTPConnection("127.0.0.1", port, timeout=3)
|
||||
try:
|
||||
if body is not None and len(body) > (1 << 20):
|
||||
# Probe early Content-Length rejection without racing a 1 MiB
|
||||
# client send against the server's intentional connection close.
|
||||
conn.putrequest(method, path)
|
||||
for key, value in (headers or {}).items():
|
||||
conn.putheader(key, value)
|
||||
conn.putheader("Content-Length", str(len(body)))
|
||||
conn.endheaders()
|
||||
else:
|
||||
conn.request(method, path, body=body, headers=headers or {})
|
||||
response = conn.getresponse()
|
||||
return response.status, dict(response.getheaders()), response.read()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def write(path, text):
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(text)
|
||||
|
||||
|
||||
def exercise(binary, project, transport, starter):
|
||||
with socket.socket() as sock:
|
||||
sock.bind(("127.0.0.1", 0))
|
||||
port = sock.getsockname()[1]
|
||||
environment = dict(ENV, GO_OMNI_HTTP_ADDR=f"127.0.0.1:{port}",
|
||||
GO_OMNI_TRANSPORT=transport, GO_OMNI_LOG_OUTPUT="stderr",
|
||||
GO_OMNI_LOG_FORMAT="json")
|
||||
checks = []
|
||||
log_path = project / f"{transport}.log"
|
||||
with log_path.open("w+") as log:
|
||||
process = subprocess.Popen([str(binary)], cwd=project, env=environment,
|
||||
stdout=log, stderr=log)
|
||||
try:
|
||||
deadline = time.monotonic() + 10
|
||||
while True:
|
||||
if process.poll() is not None:
|
||||
raise AssertionError(log_path.read_text())
|
||||
try:
|
||||
if request(port, "GET", "/health/ready")[0] == 200:
|
||||
break
|
||||
except OSError:
|
||||
pass
|
||||
if time.monotonic() > deadline:
|
||||
raise AssertionError("server startup timed out")
|
||||
time.sleep(0.05)
|
||||
checks.append("readiness")
|
||||
status, headers, document = request(port, "GET", "/openapi.json")
|
||||
assert status == 200 and document == (project / "docs/openapi.json").read_bytes()
|
||||
checks.append("servedOpenApiMatchesFile")
|
||||
if starter:
|
||||
status, headers, data = request(port, "GET", "/api/greet/Alice")
|
||||
assert status == 200 and json.loads(data)["data"]["message"] == "Hello Alice"
|
||||
assert headers.get("X-Request-Id") or headers.get("X-Request-ID")
|
||||
checks.extend(["greeting", "requestId"])
|
||||
else:
|
||||
body = json.dumps({"name": "Alice", "active": False, "age": 0})
|
||||
base = {"Content-Type": "application/json", "X-Request-ID": "api-acceptance-42"}
|
||||
for method, path, payload, expected in [
|
||||
("PUT", "/api/v1/users/7?notify=true", body, 200),
|
||||
("PUT", "/api/v1/users/7", '{"name":"Alice"}', 400),
|
||||
("PUT", "/api/v1/users/7?notify=bad", body, 400),
|
||||
("PUT", "/api/v1/users/7?notify=true¬ify=false", body, 400),
|
||||
("PUT", "/api/v1/users/7", body[:-1] + ',"extra":1}', 400),
|
||||
("PUT", "/api/v1/users/7", body + "{}", 400),
|
||||
("PUT", "/api/v1/users/7", '{"name":"A","active":false,"age":151}', 400),
|
||||
("PUT", "/api/v1/users/0", body, 400),
|
||||
("PUT", "/api/v1/users/7", " " * ((1 << 20) + 1), 413),
|
||||
("GET", "/api/v1/users?page=2&pageSize=10&tag=a&tag=b", None, 200),
|
||||
("GET", "/api/v1/users?page=0", None, 400),
|
||||
("DELETE", "/admin/v1/users/7", None, 401),
|
||||
("POST", "/api/v1/newUsers", body, 201),
|
||||
("POST", "/api/v1/acceptedUsers", body, 202),
|
||||
]:
|
||||
status, headers, data = request(port, method, path, payload, base)
|
||||
assert status == expected, (method, path, status, data)
|
||||
if path.startswith("/api/v1/users/7?") and status == 200:
|
||||
assert json.loads(data)["data"] == {"userId": 7, "name": "Alice", "active": False}
|
||||
assert json.loads(data)["requestId"] == "api-acceptance-42"
|
||||
if path.startswith("/api/v1/users?page=2"):
|
||||
assert json.loads(data)["data"]["page"] == 2
|
||||
checks.extend(["mixedSources", "zeroAndFalse", "pathRange", "defaultAndQuery",
|
||||
"requiredFields", "unknownFields", "duplicateScalar", "trailingJson",
|
||||
"rangeValidation", "bodyLimit413", "auth401", "status201", "status202"])
|
||||
assert request(port, "PUT", "/api/v1/users/7", body,
|
||||
{"Content-Type": "text/plain"})[0] == 415
|
||||
status, headers, data = request(port, "DELETE", "/admin/v1/users/7", headers={"Authorization": "Bearer test-only"})
|
||||
assert status == 204 and data == b"" and headers.get("X-Audit") == "yes"
|
||||
checks.extend(["contentType415", "middlewareComposition", "noContent204"])
|
||||
process.send_signal(signal.SIGTERM)
|
||||
assert process.wait(timeout=12) == 0
|
||||
checks.append("gracefulShutdown")
|
||||
try:
|
||||
request(port, "GET", "/health/live")
|
||||
raise AssertionError("listener survived shutdown")
|
||||
except OSError:
|
||||
checks.append("listenerReleased")
|
||||
finally:
|
||||
if process.poll() is None:
|
||||
process.kill()
|
||||
process.wait(timeout=5)
|
||||
if not starter:
|
||||
logs = [json.loads(line) for line in log_path.read_text().splitlines() if line.startswith("{")]
|
||||
assert any(row.get("msg") == "user.updated" and row.get("requestId") == "api-acceptance-42" for row in logs)
|
||||
checks.append("businessLogCorrelation")
|
||||
return {"project": "starter" if starter else "contract", "transport": transport,
|
||||
"checks": checks, "passed": True}
|
||||
|
||||
|
||||
def main():
|
||||
started = time.monotonic()
|
||||
results = []
|
||||
with tempfile.TemporaryDirectory(prefix="goOmniApiAcceptance") as temp:
|
||||
temp = Path(temp)
|
||||
cli = temp / "go-omni"
|
||||
run(["go", "build", "-o", cli, "./cmd/go-omni"])
|
||||
for starter in (True, False):
|
||||
project = temp / ("starter" if starter else "contract")
|
||||
common = ["--module", "example.com/apiAcceptance", "--framework-dir", ROOT]
|
||||
if starter:
|
||||
run([cli, "api", "new", *common, project])
|
||||
entry = project / "api/service.api"
|
||||
else:
|
||||
run([cli, "new", "--api", ROOT / "examples/contracts/user.api", *common, project])
|
||||
entry = project / "api/user.api"
|
||||
business = '''package user
|
||||
import("context";"example.com/apiAcceptance/internal/types")
|
||||
func(s *Service) UpdateUser(ctx context.Context,req *types.UpdateUserReq)(*types.UserResp,error){
|
||||
s.deps.Logger.InfoContext(ctx,"user.updated","userId",req.UserID)
|
||||
return &types.UserResp{UserID:req.UserID,Name:req.Name,Active:req.Active},nil
|
||||
}
|
||||
'''
|
||||
write(project / "internal/service/user/updateUser.go", business)
|
||||
write(project / "internal/service/user/listUsers.go", '''package user
|
||||
import("context";"example.com/apiAcceptance/internal/types")
|
||||
func(s *Service) ListUsers(ctx context.Context,req *types.ListUsersReq)(*types.ListUsersResp,error){return &types.ListUsersResp{Items:[]types.UserResp{},Page:req.Page,Total:0},nil}
|
||||
''')
|
||||
write(project / "internal/service/admin/user/deleteUser.go", '''package user
|
||||
import("context";"example.com/apiAcceptance/internal/types")
|
||||
func(s *Service) DeleteUser(ctx context.Context,req *types.DeleteUserReq)error{return nil}
|
||||
''')
|
||||
write(project / "internal/middleware/auth.go", '''package middleware
|
||||
import("git.yuntuo.hk/k2safe/go-omni/httpx";apierrors "git.yuntuo.hk/k2safe/go-omni/errors")
|
||||
func Auth(next httpx.Handler)httpx.Handler{return func(c *httpx.Context)error{if c.Header("Authorization")!="Bearer test-only"{return apierrors.Unauthorized("UNAUTHORIZED","authentication required")};return next(c)}}
|
||||
''')
|
||||
write(project / "internal/middleware/audit.go", '''package middleware
|
||||
import("git.yuntuo.hk/k2safe/go-omni/httpx")
|
||||
func Audit(next httpx.Handler)httpx.Handler{return func(c *httpx.Context)error{c.SetResponseHeader("X-Audit","yes");return next(c)}}
|
||||
''')
|
||||
source = entry.read_text().replace(" UpdateUserReq {", ' CreateUserReq {\n Name string `json:"name"`\n Active bool `json:"active"`\n Age *int `json:"age,optional"`\n }\n UpdateUserReq {')
|
||||
source += '''
|
||||
@server (
|
||||
group: user
|
||||
prefix: /api/v1
|
||||
)
|
||||
service user-api {
|
||||
@handler createUser
|
||||
@status 201
|
||||
post /newUsers (CreateUserReq) returns (UserResp)
|
||||
@handler acceptUser
|
||||
@status 202
|
||||
post /acceptedUsers (CreateUserReq) returns (UserResp)
|
||||
}
|
||||
'''
|
||||
entry.write_text(source)
|
||||
run([cli, "api", "generate", "--api", entry, "--dir", project, "--check"], ok=False)
|
||||
run([cli, "api", "generate", "--api", entry, "--dir", project])
|
||||
assert (project / "internal/service/user/updateUser.go").read_text() == business
|
||||
for operation in ("CreateUser", "AcceptUser"):
|
||||
name = operation[0].lower() + operation[1:]
|
||||
write(project / f"internal/service/user/{name}.go", f'''package user
|
||||
import("context";"example.com/apiAcceptance/internal/types")
|
||||
func(s *Service) {operation}(ctx context.Context,req *types.CreateUserReq)(*types.UserResp,error){{return &types.UserResp{{UserID:8,Name:req.Name,Active:req.Active}},nil}}
|
||||
''')
|
||||
run([cli, "api", "validate", "--api", entry])
|
||||
run([cli, "api", "generate", "--api", entry, "--dir", project, "--check"])
|
||||
binary = project / "server"
|
||||
run(["go", "build", "-mod=mod", "-o", binary, "./cmd/server"], cwd=project)
|
||||
for transport in ("nethttp", "native"):
|
||||
result = exercise(binary, project, transport, starter)
|
||||
results.append(result)
|
||||
print(f"PASS {result['project']}/{transport}: {len(result['checks'])} checks", flush=True)
|
||||
files = []
|
||||
for folder in ("cmd/go-omni/apigen", "contract", "openapi", "examples/contracts"):
|
||||
files.extend(p for p in (ROOT / folder).rglob("*") if p.is_file())
|
||||
files.extend(ROOT / p for p in ("cmd/go-omni/api.go", "cmd/go-omni/main.go", "scripts/verifyAPI.py",
|
||||
"transport/native/server.go", "transport/native/earlyReject_test.go"))
|
||||
report = {"passed": True, "durationSeconds": round(time.monotonic() - started, 2),
|
||||
"cases": results,
|
||||
"sourceSha256": {str(p.relative_to(ROOT)): hashlib.sha256(p.read_bytes()).hexdigest() for p in sorted(files)}}
|
||||
REPORT.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n")
|
||||
print(f"Saved {REPORT}", flush=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
247
scripts/verifyAPIWorkflow.py
Normal file
247
scripts/verifyAPIWorkflow.py
Normal file
@@ -0,0 +1,247 @@
|
||||
#!/usr/bin/env python3
|
||||
"""API + MySQL + storage business acceptance, using only disposable local resources.
|
||||
|
||||
--version installs the real remote CLI and tests consumers with no local replace.
|
||||
No deployment is performed. Secrets and signed URLs are excluded from reports.
|
||||
"""
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import secrets
|
||||
import shutil
|
||||
import signal
|
||||
import socket
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
|
||||
from verifyDatabase import MySQL, waitFor
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
MODULE = "git.yuntuo.hk/k2safe/go-omni"
|
||||
|
||||
|
||||
def run(args, cwd, env, timeout=180, expect_ok=True):
|
||||
proc = subprocess.run([str(x) for x in args], cwd=cwd, env=env, text=True,
|
||||
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=timeout)
|
||||
if (proc.returncode == 0) != expect_ok:
|
||||
# Commands contain only paths and module versions, never credentials.
|
||||
output = proc.stdout
|
||||
for key, value in env.items():
|
||||
if value and any(word in key for word in ("TOKEN", "PASSWORD", "SECRET", "DSN", "SIGNING_KEY")):
|
||||
output = output.replace(value, "[REDACTED]")
|
||||
raise RuntimeError(f"{args[0]} failed ({proc.returncode}):\n{output}")
|
||||
return proc.stdout
|
||||
|
||||
|
||||
class LimitedMySQL(MySQL):
|
||||
def start(self):
|
||||
endpoint = self.env.get("DOCKER_HOST") or run(
|
||||
["docker", "context", "inspect", "--format", "{{.Endpoints.docker.Host}}"], self.directory, self.env).strip()
|
||||
if not endpoint.startswith(("unix://", "npipe://")):
|
||||
raise RuntimeError("a local Docker context is required")
|
||||
with socket.socket() as sock:
|
||||
sock.bind(("127.0.0.1", 0)); port = sock.getsockname()[1]
|
||||
self.container = run(["docker", "run", "--detach", "--name", self.name,
|
||||
"--label", "go-omni.test=apiWorkflow", "--cpus=1", "--memory=768m", "--pids-limit=256",
|
||||
"--publish", f"127.0.0.1:{port}:3306", "--env", "MYSQL_ROOT_PASSWORD",
|
||||
"--env", "MYSQL_PASSWORD", "--env", "MYSQL_DATABASE=omni_test", "--env", "MYSQL_USER=omni_test",
|
||||
self.image], self.directory, self.env).strip()
|
||||
self.wait()
|
||||
self.dsn = f"omni_test:{self.password}@tcp(127.0.0.1:{port})/omni_test?parseTime=true&timeout=2s&readTimeout=2s&writeTimeout=2s"
|
||||
|
||||
|
||||
def exercise(binary, project, env, db, transport):
|
||||
with socket.socket() as sock:
|
||||
sock.bind(("127.0.0.1", 0)); port = sock.getsockname()[1]
|
||||
token, signing = secrets.token_hex(24), secrets.token_hex(24)
|
||||
environment = dict(env, GO_OMNI_HTTP_ADDR=f"127.0.0.1:{port}", GO_OMNI_TRANSPORT=transport,
|
||||
USER_SERVICE_API_TOKEN=token, GO_OMNI_STORAGE_SIGNING_KEY=signing,
|
||||
GO_OMNI_STORAGE_ROOT=str(project / (transport + "Files")),
|
||||
GO_OMNI_STORAGE_PUBLIC_URL=f"http://127.0.0.1:{port}/storage/object")
|
||||
logs = project / "var/log/server.log"
|
||||
logs.parent.mkdir(parents=True, exist_ok=True)
|
||||
# Keep each transport's business log checks separate.
|
||||
logs.write_text("")
|
||||
checks = []
|
||||
client = urllib.request.build_opener(urllib.request.ProxyHandler({}))
|
||||
def request(method, path, body=None, expected=200, auth=True):
|
||||
headers = {"Content-Type": "application/json", "X-Request-ID": "apiWorkflow"}
|
||||
if auth: headers["Authorization"] = "Bearer " + token
|
||||
req = urllib.request.Request(f"http://127.0.0.1:{port}" + path,
|
||||
data=None if body is None else json.dumps(body).encode(), headers=headers, method=method)
|
||||
try: response = client.open(req, timeout=5)
|
||||
except urllib.error.HTTPError as exc: response = exc
|
||||
with response:
|
||||
data = response.read()
|
||||
if response.status != expected: raise AssertionError(f"{method} {path}: expected {expected}, got {response.status}")
|
||||
if response.headers.get("X-Request-ID") != "apiWorkflow": raise AssertionError("requestId header absent")
|
||||
if not data: return None
|
||||
value = json.loads(data)
|
||||
if path != "/openapi.json" and value.get("requestId") != "apiWorkflow": raise AssertionError("requestId body absent")
|
||||
return value
|
||||
def start():
|
||||
process = subprocess.Popen([str(binary)], cwd=project, env=environment, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
def ready():
|
||||
if process.poll() is not None: raise AssertionError("example failed startup")
|
||||
try: return request("GET", "/health/ready")["code"] == "READY"
|
||||
except (OSError, urllib.error.URLError): return False
|
||||
try: waitFor(ready, "business service ready", 15)
|
||||
except BaseException:
|
||||
process.kill();process.wait(timeout=5);raise
|
||||
return process
|
||||
process = start()
|
||||
try:
|
||||
request("GET", "/api/v1/users", expected=401, auth=False); checks.append("authBeforeService")
|
||||
empty = request("GET", "/api/v1/users")["data"]
|
||||
assert empty["items"] == [] and empty["total"] == 0
|
||||
checks.append("emptyPageAndQueryDefaults")
|
||||
request("POST", "/api/v1/users", {"name": "X", "email": "invalid"}, 400)
|
||||
request("POST", "/api/v1/users", {"name": "X", "email": "x@example.com", "unknown": 1}, 400)
|
||||
request("GET", "/api/v1/users?pageSize=101", expected=400)
|
||||
request("GET", "/api/v1/users/0", expected=400); checks.append("contractValidation")
|
||||
user = request("POST", "/api/v1/users", {"name": " Alice ", "email": "Alice@Example.com"}, 201)["data"]
|
||||
assert user["name"] == "Alice" and user["email"] == "alice@example.com"
|
||||
uid = user["id"]; path = f"/api/v1/users/{uid}"
|
||||
assert request("GET", path)["data"] == user; checks.append("createReadNormalize")
|
||||
assert request("POST", "/api/v1/users", {"name": "Other", "email": "alice@example.com"}, 409)["code"] == "EMAIL_TAKEN"
|
||||
checks.append("uniqueEmailConflict")
|
||||
user = request("PUT", path, {"name": "Updated", "email": "updated@example.com"})["data"]
|
||||
assert user["name"] == "Updated"
|
||||
page = request("GET", "/api/v1/users?page=1&pageSize=1")["data"]
|
||||
assert page["total"] == 1 and page["items"][0]["id"] == uid
|
||||
checks.append("updateAndPagination")
|
||||
# Force the second SQL write to fail: create/update/delete must roll back.
|
||||
db.sql("CREATE TRIGGER reject_audit BEFORE INSERT ON user_audit FOR EACH ROW SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='acceptance rollback';", "omni_test")
|
||||
try:
|
||||
request("POST", "/api/v1/users", {"name": "Rollback", "email": "rollback@example.com"}, 500)
|
||||
request("PUT", path, {"name": "Rollback", "email": "rollback@example.com"}, 500)
|
||||
request("DELETE", path, expected=500)
|
||||
assert request("GET", path)["data"] == user
|
||||
assert db.sql("SELECT COUNT(*) FROM users WHERE email='rollback@example.com'", "omni_test") == "0"
|
||||
assert db.sql("SELECT COUNT(*) FROM user_audit", "omni_test") == "2"
|
||||
finally: db.sql("DROP TRIGGER reject_audit", "omni_test")
|
||||
checks.append("createUpdateDeleteRollbackOnAuditFailure")
|
||||
link = request("POST", path + "/export")["data"]["downloadUrl"]
|
||||
parsed = urllib.parse.urlparse(link)
|
||||
assert parsed.hostname == "127.0.0.1" and parsed.port == port
|
||||
with client.open(link, timeout=3) as response: exported = json.load(response)
|
||||
assert exported == user; checks.append("storageUploadAndSignedDownload")
|
||||
tampered = link.replace("signature=", "signature=bad")
|
||||
if tampered == link:
|
||||
query = urllib.parse.parse_qs(parsed.query)
|
||||
key = next(k for k in query if k.lower() in ("sig", "signature"))
|
||||
query[key] = ["bad"]
|
||||
tampered = urllib.parse.urlunparse(parsed._replace(query=urllib.parse.urlencode(query, doseq=True)))
|
||||
try: client.open(tampered, timeout=3); raise AssertionError("tampered signature accepted")
|
||||
except urllib.error.HTTPError as exc: assert exc.code == 403
|
||||
checks.append("signedDownloadTamperRejected")
|
||||
doc = request("GET", "/openapi.json")
|
||||
assert doc == json.loads((project / "docs/openapi.json").read_text()); checks.append("openAPIMatchesContract")
|
||||
run(["docker", "stop", "--time=3", db.container], project, db.env)
|
||||
request("GET", "/health/ready", expected=503)
|
||||
run(["docker", "start", db.container], project, db.env); db.wait()
|
||||
waitFor(lambda: request("GET", "/health/ready")["code"] == "READY", "database recovery")
|
||||
checks.append("readinessOutageAndRecovery")
|
||||
process.send_signal(signal.SIGTERM); assert process.wait(timeout=12) == 0
|
||||
process = start()
|
||||
assert request("GET", path)["data"] == user; checks.append("persistenceAfterServiceRestart")
|
||||
request("DELETE", path, expected=204)
|
||||
assert request("GET", path, expected=404)["code"] == "USER_NOT_FOUND"
|
||||
assert request("GET", "/api/v1/users")["data"]["items"] == []
|
||||
assert db.sql("SELECT COUNT(*) FROM user_audit WHERE request_id='apiWorkflow'", "omni_test") == "3"
|
||||
checks.append("deleteAndTransactionalAudit")
|
||||
process.send_signal(signal.SIGTERM); assert process.wait(timeout=12) == 0
|
||||
with socket.socket() as sock: assert sock.connect_ex(("127.0.0.1", port)) != 0
|
||||
checks.append("gracefulStopAndListenerRelease")
|
||||
text = logs.read_text()
|
||||
entries = [json.loads(line) for line in text.splitlines() if line.startswith("{")]
|
||||
assert any(e.get("msg") == "user.created" and e.get("requestId") == "apiWorkflow" for e in entries)
|
||||
assert all(secret not in text for secret in (token, signing, env["GO_OMNI_DB_DSN"], link, "updated@example.com"))
|
||||
checks.append("businessFileLogsAndSecretExclusion")
|
||||
return {"transport": transport, "checks": checks}
|
||||
finally:
|
||||
if process.poll() is None: process.kill(); process.wait(timeout=5)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--version")
|
||||
parser.add_argument("--report", type=Path, default=ROOT / "performance/apiWorkflowLocal.json")
|
||||
args = parser.parse_args()
|
||||
env = {k: v for k, v in os.environ.items() if not k.startswith(("GO_OMNI_", "USER_SERVICE_"))}
|
||||
env.update(GOWORK="off", GOFLAGS="", GOPRIVATE="git.yuntuo.hk", GONOSUMDB="git.yuntuo.hk")
|
||||
started = time.monotonic()
|
||||
resolved = {}
|
||||
with tempfile.TemporaryDirectory(prefix="goOmniAPIWorkflow-") as temp:
|
||||
temp = Path(temp); cli = temp / "bin/go-omni"; cli.parent.mkdir()
|
||||
if args.version:
|
||||
env.update(GOBIN=str(cli.parent), GOMODCACHE=str(temp / "modcache"))
|
||||
run(["go", "install", MODULE + "/cmd/go-omni@" + args.version], temp, env, timeout=300)
|
||||
else: run(["go", "build", "-o", cli, "./cmd/go-omni"], ROOT, env)
|
||||
project = temp / "userService"
|
||||
source = ROOT / "examples/userService"
|
||||
shutil.copytree(source, project, ignore=lambda directory, names: {"var", "server"} if Path(directory) == source else set())
|
||||
if args.version:
|
||||
run(["go", "mod", "edit", "-dropreplace=" + MODULE, "-require=" + MODULE + "@" + args.version], project, env)
|
||||
else: run(["go", "mod", "edit", "-replace=" + MODULE + "=" + str(ROOT)], project, env)
|
||||
run(["go", "mod", "tidy"], project, env)
|
||||
if args.version:
|
||||
assert "replace " not in (project / "go.mod").read_text()
|
||||
modules = run(["go", "list", "-m", "-json", MODULE], project, env)
|
||||
resolved = json.loads(modules)
|
||||
assert resolved["Version"] == args.version and "Replace" not in resolved
|
||||
# Installed CLI must pin its own version without a source checkout.
|
||||
fresh = temp / "fresh"
|
||||
run([cli, "api", "new", "--module", "example.com/fresh", "--database", "mysql", "--storage", "local", fresh], temp, env)
|
||||
assert args.version in (fresh / "go.mod").read_text() and "replace " not in (fresh / "go.mod").read_text()
|
||||
run(["go", "test", "-mod=mod", "./..."], fresh, env)
|
||||
# Regeneration must preserve all developer-owned code and config.
|
||||
manifest = json.loads((project / ".goOmni/apiManifest.json").read_text())
|
||||
owned = set(manifest["generated"]) | {".goOmni/apiManifest.json"}
|
||||
def hand_hashes():
|
||||
return {str(p.relative_to(project)): hashlib.sha256(p.read_bytes()).hexdigest()
|
||||
for p in project.rglob("*") if p.is_file() and str(p.relative_to(project)) not in owned}
|
||||
before = hand_hashes()
|
||||
run([cli, "api", "generate", "--api", "api/user.api", "--dir", ".", "--check"], project, env)
|
||||
run([cli, "api", "generate", "--api", "api/user.api", "--dir", "."], project, env)
|
||||
assert before == hand_hashes()
|
||||
db = LimitedMySQL(temp, env, "mysql:8.4")
|
||||
try:
|
||||
print("Starting bounded, isolated MySQL", flush=True); db.start()
|
||||
env.update(GO_OMNI_DB_DSN=db.dsn, USER_SERVICE_TEST_DATABASE="1")
|
||||
run([cli, "migrate", "--dir", "migrations", "--apply"], project, env)
|
||||
run(["go", "test", "-race", "-count=1", "./..."], project, env)
|
||||
run(["go", "vet", "./..."], project, env)
|
||||
binary = project / "server";run(["go", "build", "-o", binary, "./cmd/server"], project, env)
|
||||
groups = []
|
||||
for transport in ("nethttp", "native"):
|
||||
db.sql("DELETE FROM user_audit; DELETE FROM users;", "omni_test")
|
||||
groups.append(exercise(binary, project, env, db, transport))
|
||||
print("PASS business workflow: " + transport, flush=True)
|
||||
finally: db.close()
|
||||
version = args.version or "local"
|
||||
report = {"passed": True, "frameworkVersion": version, "noLocalReplace": bool(args.version),
|
||||
"durationSeconds": round(time.monotonic() - started, 2),
|
||||
"regenerationPreservedHandwrittenFiles": len(before), "groups": groups,
|
||||
"transactionTests": ["commit", "errorRollback", "panicRollback", "cancellationRollback"],
|
||||
"startupTests": ["storageFailureClosesDatabase", "idempotentClose", "closedContextCannotReopen"],
|
||||
"migrationCLI": True,
|
||||
"frameworkSum": resolved.get("Sum"),
|
||||
"sourceHashes": {str(p.relative_to(ROOT)): hashlib.sha256(p.read_bytes()).hexdigest()
|
||||
for base in (ROOT / "cmd/go-omni", ROOT / "database", ROOT / "examples/userService")
|
||||
for p in sorted(base.rglob("*")) if p.is_file() and p.suffix in (".go", ".tmpl", ".api", ".sql", ".yaml")}}
|
||||
args.report.write_text(json.dumps(report, indent=2) + "\n")
|
||||
print(f"PASS API workflow ({version}): {sum(len(g['checks']) for g in groups)} HTTP/lifecycle checks", flush=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
def interrupt(signum, frame):
|
||||
raise KeyboardInterrupt(f"signal {signum}")
|
||||
for sig in (signal.SIGTERM, signal.SIGHUP): signal.signal(sig, interrupt)
|
||||
main()
|
||||
178
scripts/verifyScaffoldModules.py
Normal file
178
scripts/verifyScaffoldModules.py
Normal file
@@ -0,0 +1,178 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate runnable logging/storage projects and verify their HTTP and shutdown paths."""
|
||||
import argparse
|
||||
from datetime import datetime, timezone
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import secrets
|
||||
import signal
|
||||
import socket
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--cli', type=Path)
|
||||
parser.add_argument('--framework-dir', type=Path, default=Path(__file__).resolve().parent.parent)
|
||||
parser.add_argument('--output', type=Path, default=Path('performance/scaffoldModulesLocal.json'))
|
||||
args = parser.parse_args()
|
||||
root = args.framework_dir.resolve()
|
||||
output = args.output.resolve()
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
log_path = output.with_suffix('.txt')
|
||||
env = {k: v for k, v in os.environ.items() if not k.startswith('GO_OMNI_')}
|
||||
env.update(GOWORK='off', GOMAXPROCS='4')
|
||||
report = {'status': 'running', 'startedAt': datetime.now(timezone.utc).isoformat(), 'cases': [], 'sourceSha256': {}}
|
||||
sources = list((root / 'cmd/go-omni').rglob('*.go')) + list((root / 'cmd/go-omni/scaffold').rglob('*.tmpl'))
|
||||
sources += [root / 'config/env.go', Path(__file__).resolve()]
|
||||
for p in sorted(sources):
|
||||
report['sourceSha256'][str(p.relative_to(root))] = hashlib.sha256(p.read_bytes()).hexdigest()
|
||||
opener = urllib.request.build_opener(urllib.request.ProxyHandler({}))
|
||||
|
||||
def request(method, url, body=None, headers=None):
|
||||
req = urllib.request.Request(url, data=body, headers=headers or {}, method=method)
|
||||
try:
|
||||
response = opener.open(req, timeout=3)
|
||||
except urllib.error.HTTPError as error:
|
||||
response = error
|
||||
with response:
|
||||
return response.status, response.read(), dict(response.headers)
|
||||
|
||||
def call(base, token, path, value=None, expected=200, method='POST'):
|
||||
headers = {'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token, 'X-Request-ID': 'scaffold-module-test'}
|
||||
body = json.dumps(value).encode() if value is not None else None
|
||||
status, data, _ = request(method, base + path, body, headers)
|
||||
if status != expected:
|
||||
raise AssertionError(f'{method} {path}: {status}, expected {expected}; {data[:512]!r}')
|
||||
return json.loads(data) if data else None
|
||||
|
||||
failure = None
|
||||
with log_path.open('w') as log, tempfile.TemporaryDirectory(prefix='goOmniModuleAcceptance') as directory:
|
||||
temp = Path(directory).resolve()
|
||||
|
||||
def run(command, cwd):
|
||||
subprocess.run(command, cwd=cwd, env=env, stdout=log, stderr=subprocess.STDOUT, check=True, timeout=180)
|
||||
log.flush()
|
||||
|
||||
try:
|
||||
cli = args.cli.resolve() if args.cli else temp / 'go-omni'
|
||||
if not args.cli:
|
||||
run(['go', 'build', '-o', str(cli), './cmd/go-omni'], root)
|
||||
profiles = [('stderr', 'json', 'nethttp', signal.SIGTERM), ('stdout', 'text', 'native', signal.SIGINT),
|
||||
('file', 'json', 'native', signal.SIGTERM), ('file', 'text', 'nethttp', signal.SIGINT)]
|
||||
for index, (sink, fmt, transport, stop_signal) in enumerate(profiles):
|
||||
print(f'Checking {sink}/{fmt}, {transport}, {stop_signal.name}', flush=True)
|
||||
project = temp / ('case' + str(index))
|
||||
run([str(cli), 'new', '--module', 'example.com/moduleAcceptance', '--framework-dir', str(root),
|
||||
'--storage', 'local', '--log-output', sink, '--log-format', fmt, str(project)], temp)
|
||||
run(['go', 'test', '-mod=mod', '-race', '-p=2', './...'], project)
|
||||
binary = project / 'server'
|
||||
run(['go', 'build', '-o', str(binary), './cmd/server'], project)
|
||||
with socket.socket() as reservation:
|
||||
reservation.bind(('127.0.0.1', 0))
|
||||
port = reservation.getsockname()[1]
|
||||
base = f'http://127.0.0.1:{port}'
|
||||
token = secrets.token_hex(32)
|
||||
runtime_env = dict(env, GO_OMNI_FILES_TOKEN=token, GO_OMNI_STORAGE_SIGNING_KEY=secrets.token_hex(32),
|
||||
GO_OMNI_HTTP_ADDR=f'127.0.0.1:{port}', GO_OMNI_TRANSPORT=transport,
|
||||
GO_OMNI_STORAGE_PUBLIC_URL=base + '/storage/object', GO_OMNI_STORAGE_MAX_BYTES='64')
|
||||
process_log = project / 'process.log'
|
||||
with process_log.open('w') as process_output:
|
||||
process = subprocess.Popen([str(binary)], cwd=project, env=runtime_env, stdout=process_output, stderr=subprocess.STDOUT)
|
||||
try:
|
||||
deadline = time.monotonic() + 15
|
||||
while True:
|
||||
if process.poll() is not None:
|
||||
raise RuntimeError('startup failed: ' + process_log.read_text())
|
||||
try:
|
||||
status, _, _ = request('GET', base + '/health/ready')
|
||||
if status == 200:
|
||||
break
|
||||
except (OSError, urllib.error.URLError):
|
||||
pass
|
||||
if time.monotonic() > deadline:
|
||||
raise TimeoutError('service readiness timeout')
|
||||
time.sleep(0.05)
|
||||
call(base, token, '/users', {'name': 'Example', 'email': 'module@example.com'}, 201)
|
||||
data = b'hello go-omni'
|
||||
upload_headers = {'Content-Type': 'text/plain', 'Authorization': 'Bearer ' + token, 'X-Request-ID': 'scaffold-module-test'}
|
||||
status, raw, _ = request('POST', base + '/files', data, upload_headers)
|
||||
if status != 201:
|
||||
raise AssertionError(f'upload failed: {status}, {raw!r}')
|
||||
key = json.loads(raw)['data']['key']
|
||||
download = call(base, token, '/files/downloadLink', {'key': key})['data']['url']
|
||||
status, raw, _ = request('GET', download)
|
||||
if status != 200 or raw != data:
|
||||
raise AssertionError('download content mismatch')
|
||||
parts = urllib.parse.urlsplit(download)
|
||||
query = urllib.parse.parse_qs(parts.query)
|
||||
query['signature'] = ['tampered']
|
||||
altered = urllib.parse.urlunsplit(parts._replace(query=urllib.parse.urlencode(query, doseq=True)))
|
||||
if request('GET', altered)[0] != 403:
|
||||
raise AssertionError('tampered signature accepted')
|
||||
direct = call(base, token, '/files/uploadLink', {'size': len(data), 'contentType': 'text/plain'})['data']
|
||||
signed = direct['request']
|
||||
headers = {k: ', '.join(v) for k, v in signed['headers'].items()}
|
||||
if request(signed['method'], signed['url'], data, headers)[0] != 201:
|
||||
raise AssertionError('signed upload failed')
|
||||
if request('POST', base + '/files', data, {'Content-Type': 'text/plain'})[0] != 401:
|
||||
raise AssertionError('missing authorization accepted')
|
||||
if request('POST', base + '/files', b'x' * 65, upload_headers)[0] != 413:
|
||||
raise AssertionError('oversized upload accepted')
|
||||
wrong_headers = dict(upload_headers, **{'Content-Type': 'image/png'})
|
||||
if request('POST', base + '/files', data, wrong_headers)[0] != 415:
|
||||
raise AssertionError('forged type accepted')
|
||||
call(base, token, '/files/downloadLink', {'key': '../bad'}, 400)
|
||||
call(base, token, '/files/downloadLink', {'key': 'missing'}, 404)
|
||||
process.send_signal(stop_signal)
|
||||
exit_code = process.wait(timeout=12)
|
||||
if exit_code != 0:
|
||||
raise AssertionError(f'{stop_signal.name}: exit {exit_code}')
|
||||
finally:
|
||||
if process.poll() is None:
|
||||
process.kill()
|
||||
process.wait(timeout=5)
|
||||
sink_path = project / 'var/log/server.log' if sink == 'file' else process_log
|
||||
text = sink_path.read_text()
|
||||
if token in text or 'signature=' in text or '"url":' in text:
|
||||
raise AssertionError('secret or signed URL appeared in logs')
|
||||
for event in ('user.created', 'file.uploaded', 'file.downloadLink.issued', 'file.uploadLink.issued', 'http.request', 'scaffold-module-test'):
|
||||
if event not in text:
|
||||
raise AssertionError('missing log evidence: ' + event)
|
||||
if fmt == 'json':
|
||||
entries = [json.loads(line) for line in text.splitlines() if line.strip()]
|
||||
if not any(e.get('status') == 403 and e.get('path') == '/storage/object' for e in entries):
|
||||
raise AssertionError('signed endpoint status lost in access logs')
|
||||
with socket.socket() as check:
|
||||
check.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
check.bind(('127.0.0.1', port))
|
||||
# Startup validation must fail instead of silently exposing control endpoints.
|
||||
invalid_env = dict(runtime_env, GO_OMNI_FILES_TOKEN='')
|
||||
invalid = subprocess.run([str(binary)], cwd=project, env=invalid_env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=5)
|
||||
if invalid.returncode == 0 or b'GO_OMNI_FILES_TOKEN' not in invalid.stdout:
|
||||
raise AssertionError('missing token did not fail startup')
|
||||
report['cases'].append({'logOutput': sink, 'logFormat': fmt, 'transport': transport, 'signal': stop_signal.name,
|
||||
'status': 'passed', 'exitCode': exit_code, 'portReleased': True, 'missingTokenRejected': True})
|
||||
report['status'] = 'passed'
|
||||
except Exception as error:
|
||||
failure = str(error)
|
||||
report['status'], report['error'] = 'failed', failure
|
||||
finally:
|
||||
log.flush()
|
||||
report['finishedAt'] = datetime.now(timezone.utc).isoformat()
|
||||
report['logSha256'] = hashlib.sha256(log_path.read_bytes()).hexdigest()
|
||||
output.write_text(json.dumps(report, indent=2) + '\n')
|
||||
if failure:
|
||||
raise SystemExit('FAIL: ' + failure + '; see ' + str(log_path))
|
||||
print('PASS generated project HTTP/logging/shutdown; report: ' + str(output), flush=True)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
95
scripts/verifyUploads.py
Normal file
95
scripts/verifyUploads.py
Normal file
@@ -0,0 +1,95 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Run real S3 uploads in one bounded, network-isolated local Docker container."""
|
||||
import argparse
|
||||
from datetime import datetime, timezone
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import tempfile
|
||||
import uuid
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--image', default='quay.io/minio/minio:RELEASE.2025-09-07T16-13-09Z')
|
||||
parser.add_argument('--output', type=Path, default=Path('performance/uploadsLocal.json'))
|
||||
args = parser.parse_args()
|
||||
root = Path(__file__).resolve().parent.parent
|
||||
output = args.output.resolve()
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
log_path = output.with_suffix('.txt')
|
||||
name = 'go-omni-upload-' + uuid.uuid4().hex[:12]
|
||||
|
||||
def capture(command):
|
||||
return subprocess.check_output(command, text=True, timeout=15).strip()
|
||||
|
||||
arch = capture(['docker', 'info', '--format', '{{.Architecture}}'])
|
||||
arch = {'aarch64': 'arm64', 'x86_64': 'amd64'}.get(arch, arch)
|
||||
if arch not in ('arm64', 'amd64'):
|
||||
raise SystemExit('unsupported Docker architecture: ' + arch)
|
||||
before = set(capture(['docker', 'ps', '-q']).split())
|
||||
report = {
|
||||
'status': 'running', 'startedAt': datetime.now(timezone.utc).isoformat(),
|
||||
'architecture': arch, 'goVersion': capture(['go', 'version']),
|
||||
'image': args.image, 'imageId': capture(['docker', 'image', 'inspect', args.image, '--format', '{{.Id}}']),
|
||||
'sourceSha256': {}, 'commands': [],
|
||||
'containerLimits': {'cpus': 2, 'memoryBytes': 2147483648, 'pids': 256, 'network': 'none'},
|
||||
'notes': ['MinIO verifies S3 signatures; OSS/R2 real cloud accounts are not exercised.'],
|
||||
}
|
||||
for p in sorted(list((root / 'storage').rglob('*.go')) + list((root / 'internal/storagefixture').glob('*.go')) + [root / 'go.mod', root / 'go.sum']):
|
||||
report['sourceSha256'][str(p.relative_to(root))] = hashlib.sha256(p.read_bytes()).hexdigest()
|
||||
failure = None
|
||||
with log_path.open('w') as log:
|
||||
try:
|
||||
with tempfile.TemporaryDirectory(prefix='goOmniUpload') as temporary:
|
||||
Path(temporary).chmod(0o755)
|
||||
env = dict(os.environ, GOOS='linux', GOARCH=arch, CGO_ENABLED='0', GOMAXPROCS='2', GOWORK='off')
|
||||
build = ['go', 'test', '-p=2', '-c', '-o', temporary + '/s3.test', './storage/s3']
|
||||
report['commands'].append(build)
|
||||
subprocess.run(build, cwd=root, env=env, stdout=log, stderr=subprocess.STDOUT, check=True, timeout=240)
|
||||
check = '''set -eu
|
||||
minio --version
|
||||
minio server /data --address 127.0.0.1:9000 --console-address 127.0.0.1:9001 >/tmp/minio.log 2>&1 &
|
||||
server=$!
|
||||
trap 'kill "$server" 2>/dev/null || true; wait "$server" 2>/dev/null || true' EXIT
|
||||
/test/s3.test -test.v -test.run '^TestS3Integration$' -test.count=1 -test.timeout=60s || { cat /tmp/minio.log; exit 1; }
|
||||
'''
|
||||
command = ['docker', 'run', '--rm', '--name', name, '--platform', 'linux/' + arch,
|
||||
'--cpus', '2', '--memory', '2048m', '--memory-swap', '2048m', '--pids-limit', '256',
|
||||
'--network', 'none', '--read-only', '--cap-drop', 'ALL', '--security-opt', 'no-new-privileges',
|
||||
'--user', '1000:1000', '--tmpfs', '/tmp:rw,nosuid,noexec,size=64m,mode=1777',
|
||||
'--tmpfs', '/data:rw,nosuid,noexec,size=128m,mode=1777', '-v', temporary + ':/test:ro',
|
||||
'-e', 'GOMAXPROCS=2', '-e', 'MINIO_ROOT_USER=goOmniTest', '-e', 'MINIO_ROOT_PASSWORD=goOmniTestSecret',
|
||||
'-e', 'GO_OMNI_S3_TEST_ADDR=127.0.0.1:9000', '--entrypoint', 'sh', args.image, '-c', check]
|
||||
report['commands'].append(command)
|
||||
subprocess.run(command, stdout=log, stderr=subprocess.STDOUT, check=True, timeout=90)
|
||||
log.flush()
|
||||
if '--- PASS: TestS3Integration' not in log_path.read_text():
|
||||
raise RuntimeError('missing integration pass evidence')
|
||||
report['status'] = 'passed'
|
||||
except Exception as error:
|
||||
failure = str(error)
|
||||
report['status'], report['error'] = 'failed', failure
|
||||
finally:
|
||||
remaining = subprocess.run(['docker', 'inspect', name], capture_output=True, timeout=15)
|
||||
if remaining.returncode == 0:
|
||||
subprocess.run(['docker', 'stop', '-t', '3', name], capture_output=True, timeout=15, check=True)
|
||||
after = set(capture(['docker', 'ps', '-q']).split())
|
||||
report['preExistingContainersStillRunning'] = before.issubset(after)
|
||||
if not before.issubset(after):
|
||||
failure = 'a pre-existing container is no longer running'
|
||||
report['status'], report['error'] = 'failed', failure
|
||||
report['cleanedContainer'] = name
|
||||
report['finishedAt'] = datetime.now(timezone.utc).isoformat()
|
||||
log.flush()
|
||||
report['logSha256'] = hashlib.sha256(log_path.read_bytes()).hexdigest()
|
||||
output.write_text(json.dumps(report, indent=2) + '\n')
|
||||
if failure:
|
||||
raise SystemExit('FAIL: ' + failure + '; see ' + str(log_path))
|
||||
print('PASS real S3 direct/multipart uploads, pagination, abort, signed constraints; report: ' + str(output))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
Reference in New Issue
Block a user