nativegate 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- nativegate/__init__.py +1 -0
- nativegate/__main__.py +4 -0
- nativegate/buildinfo.py +344 -0
- nativegate/cli.py +2007 -0
- nativegate/config.py +991 -0
- nativegate/declared_invariants.py +565 -0
- nativegate/discovery.py +167 -0
- nativegate/driverbuild.py +626 -0
- nativegate/drivers/__init__.py +5 -0
- nativegate/drivers/cpp.py +616 -0
- nativegate/drivers/fortran.py +507 -0
- nativegate/generators/__init__.py +0 -0
- nativegate/generators/cmake_gen.py +101 -0
- nativegate/generators/docker_gen.py +614 -0
- nativegate/generators/error_gen.py +104 -0
- nativegate/generators/f2py_gen.py +91 -0
- nativegate/generators/gateway_gen.py +110 -0
- nativegate/generators/golden_gen.py +50 -0
- nativegate/generators/k8s_gen.py +212 -0
- nativegate/generators/mcp_gen.py +281 -0
- nativegate/generators/middleware_gen.py +717 -0
- nativegate/generators/pybind_gen.py +406 -0
- nativegate/generators/pyproject_gen.py +61 -0
- nativegate/generators/python_pkg_gen.py +1164 -0
- nativegate/generators/test_gen.py +160 -0
- nativegate/golden.py +747 -0
- nativegate/invariants.py +532 -0
- nativegate/ir.py +789 -0
- nativegate/lattice.py +350 -0
- nativegate/locking.py +216 -0
- nativegate/oracle.py +904 -0
- nativegate/parsers/__init__.py +0 -0
- nativegate/parsers/cpp.py +105 -0
- nativegate/parsers/cpp_ast.py +1652 -0
- nativegate/parsers/cpp_regex.py +812 -0
- nativegate/parsers/fixed_form.py +868 -0
- nativegate/parsers/fortran.py +157 -0
- nativegate/parsers/fortran_fparser.py +1116 -0
- nativegate/parsers/fortran_regex.py +686 -0
- nativegate/preprocess.py +335 -0
- nativegate/structural_invariants.py +762 -0
- nativegate/suggest.py +208 -0
- nativegate/templates/__init__.py +20 -0
- nativegate/templates/golden_test_template.py +248 -0
- nativegate/wire.py +438 -0
- nativegate-0.1.0.dist-info/METADATA +547 -0
- nativegate-0.1.0.dist-info/RECORD +50 -0
- nativegate-0.1.0.dist-info/WHEEL +5 -0
- nativegate-0.1.0.dist-info/entry_points.txt +3 -0
- nativegate-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""The generated apps' unhandled-exception handler.
|
|
2
|
+
|
|
3
|
+
Lives in its own module because BOTH app generators need the identical block:
|
|
4
|
+
`python_pkg_gen.generate_service_py` for the standalone container, and
|
|
5
|
+
`gateway_gen.generate_gateway_app` for the composed topology. An exception
|
|
6
|
+
handler is registered per FastAPI *app*, and a service router mounted into a
|
|
7
|
+
gateway runs under the gateway's app — so a handler installed only on
|
|
8
|
+
`service.py` would silently not apply in the composed deployment, which is the
|
|
9
|
+
one more likely to be internet-facing. Emitting from one place is what stops
|
|
10
|
+
those two paths drifting.
|
|
11
|
+
|
|
12
|
+
WHAT THIS DOES AND DOES NOT COVER — measured, not assumed
|
|
13
|
+
|
|
14
|
+
`docs/production-readiness.md` used to claim an uncaught exception returned "a
|
|
15
|
+
raw traceback in the response". **That was wrong, and it was checked before
|
|
16
|
+
this module was written.** With `debug=False` (FastAPI's default, and what the
|
|
17
|
+
generated app uses), Starlette's ServerErrorMiddleware returns exactly
|
|
18
|
+
`Internal Server Error` as `text/plain`. No traceback, no exception message,
|
|
19
|
+
no paths. There was no information leak to close.
|
|
20
|
+
|
|
21
|
+
So this handler is NOT a leak fix. What it actually buys:
|
|
22
|
+
|
|
23
|
+
* **a correlatable id.** The bare 500 is untraceable: a caller reporting "it
|
|
24
|
+
returned 500 at about 2pm" cannot be matched to a log line. Every failure
|
|
25
|
+
now carries an `error_id` that appears in both the response and the log.
|
|
26
|
+
* **a consistent body.** `text/plain` for failures and JSON for everything
|
|
27
|
+
else means a client needs two parsers. Errors are now JSON like the rest.
|
|
28
|
+
* **a named logger.** `nativegate.<service>` can be raised or silenced without
|
|
29
|
+
touching the rest of the process.
|
|
30
|
+
|
|
31
|
+
Two things it does not cover, both verified:
|
|
32
|
+
|
|
33
|
+
* **`debug=True` still leaks the full traceback, and this handler does not
|
|
34
|
+
stop it** — Starlette checks `debug` *before* consulting the handler, so the
|
|
35
|
+
debug response wins. The protection against that is not enabling debug in a
|
|
36
|
+
deployed service; there is no code here that can override it.
|
|
37
|
+
* **Python-level failures only** — an f2py argument error, a ValueError out of
|
|
38
|
+
a wrapper, a numpy conversion failure. Native code that segfaults, calls
|
|
39
|
+
Fortran `STOP`, or hangs takes the worker process down with it and never
|
|
40
|
+
reaches any Python handler. That is ROADMAP 2.2 (crash containment) and
|
|
41
|
+
needs process isolation, not a try/except.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
from __future__ import annotations
|
|
45
|
+
|
|
46
|
+
# Imports the emitted block needs, on top of whatever the app module already
|
|
47
|
+
# imports. Both call sites splice these in rather than hard-coding them, so
|
|
48
|
+
# adding a dependency here cannot leave one generated app un-importable.
|
|
49
|
+
# `Request` is deliberately NOT here: both app modules already import from
|
|
50
|
+
# `fastapi` for `FastAPI`, and a second `from fastapi import ...` line would
|
|
51
|
+
# be a lint finding in generated code nobody is allowed to edit by hand. The
|
|
52
|
+
# call sites add `Request` to the import they already write.
|
|
53
|
+
ERROR_HANDLER_IMPORTS = (
|
|
54
|
+
"import logging",
|
|
55
|
+
"import os",
|
|
56
|
+
"import uuid",
|
|
57
|
+
"",
|
|
58
|
+
"from fastapi.responses import JSONResponse",
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def generate_error_handler(logger_name: str) -> str:
|
|
63
|
+
"""The handler block, to be appended after `app = FastAPI(...)`.
|
|
64
|
+
|
|
65
|
+
`logger_name` names the logger so an operator can raise or silence one
|
|
66
|
+
service's errors without touching the rest of the process.
|
|
67
|
+
"""
|
|
68
|
+
return f'''
|
|
69
|
+
|
|
70
|
+
_LOG = logging.getLogger("nativegate.{logger_name}")
|
|
71
|
+
|
|
72
|
+
# Off by default. A native routine's exception text can carry deck values,
|
|
73
|
+
# array contents or absolute paths from the build machine; none of that should
|
|
74
|
+
# leave the process just because something failed. Turn it on deliberately,
|
|
75
|
+
# in a non-production environment, when a caller needs to see why.
|
|
76
|
+
_DEBUG_ERRORS = os.environ.get("NATIVEGATE_DEBUG_ERRORS", "").lower() not in ("", "0", "false", "no")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@app.exception_handler(Exception)
|
|
80
|
+
async def _unhandled_native_error(request: Request, exc: Exception):
|
|
81
|
+
"""Give a failed request a JSON body and an id that matches the log line.
|
|
82
|
+
|
|
83
|
+
Without this, Starlette answers `Internal Server Error` as plain text —
|
|
84
|
+
safe, but untraceable and a second content type for clients to parse.
|
|
85
|
+
|
|
86
|
+
HTTPException does NOT reach here — FastAPI handles it first — so the
|
|
87
|
+
generated argument-validation errors keep their own status and detail.
|
|
88
|
+
|
|
89
|
+
Caveats, both real: with `debug=True` Starlette returns its traceback
|
|
90
|
+
response *instead* of this one, so do not enable debug on a deployed
|
|
91
|
+
service; and a segfault, Fortran STOP or hang kills the worker process
|
|
92
|
+
before any Python handler runs. See docs/production-readiness.md.
|
|
93
|
+
"""
|
|
94
|
+
error_id = uuid.uuid4().hex[:12]
|
|
95
|
+
# .exception() so the traceback reaches the log exactly once, keyed by the
|
|
96
|
+
# same id the caller was given — that pairing is the whole point.
|
|
97
|
+
_LOG.exception(
|
|
98
|
+
"unhandled error %s on %s %s", error_id, request.method, request.url.path
|
|
99
|
+
)
|
|
100
|
+
body = {{"error": "internal server error", "error_id": error_id}}
|
|
101
|
+
if _DEBUG_ERRORS:
|
|
102
|
+
body["detail"] = f"{{type(exc).__name__}}: {{exc}}"
|
|
103
|
+
return JSONResponse(status_code=500, content=body)
|
|
104
|
+
'''
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""f2py-based CMake build generator (design.md sections 8, 14)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from ..ir import ModuleIR
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def generate_cmake(
|
|
9
|
+
module: ModuleIR,
|
|
10
|
+
service_name: str,
|
|
11
|
+
native_sources: list[str],
|
|
12
|
+
only_routines: list[str] | None = None,
|
|
13
|
+
) -> str:
|
|
14
|
+
# add_custom_command's COMMAND runs with CMAKE_CURRENT_BINARY_DIR as its
|
|
15
|
+
# working directory, not the source dir — relative "native/foo.f90" only
|
|
16
|
+
# resolves by coincidence for in-source builds. scikit-build-core builds
|
|
17
|
+
# out-of-tree (a temp dir), so this must be an absolute path.
|
|
18
|
+
sources = "\n".join(f" ${{CMAKE_CURRENT_SOURCE_DIR}}/{src}" for src in native_sources)
|
|
19
|
+
|
|
20
|
+
# `only: a b :` restricts f2py to the named routines. For legacy F77 this
|
|
21
|
+
# is a correctness requirement, not a size optimization: wrapping a whole
|
|
22
|
+
# file pulls in routines whose PARAMETER-dimensioned arrays f2py emits but
|
|
23
|
+
# never defines, and the C wrapper then fails to compile
|
|
24
|
+
# ("use of undeclared identifier 'mxcell'"). Verified against
|
|
25
|
+
# libraries/petro — matsol.f wraps fine per-routine, fails whole-file.
|
|
26
|
+
only_clause = ""
|
|
27
|
+
if only_routines:
|
|
28
|
+
only_clause = " only: " + " ".join(r.lower() for r in only_routines) + " :"
|
|
29
|
+
|
|
30
|
+
# f2py's intermediate build (custom module.c + optional f2pywrappers2.f90,
|
|
31
|
+
# reassembled by hand into python_add_library) only produces the wrapper
|
|
32
|
+
# file when routines actually need one (e.g. array-intent subroutines) —
|
|
33
|
+
# a plain `function` like a scalar-only routine doesn't generate it, so
|
|
34
|
+
# hardcoding it as a build output breaks for exactly that case. `f2py -c`
|
|
35
|
+
# runs f2py's own build end-to-end and sidesteps this entirely.
|
|
36
|
+
#
|
|
37
|
+
# WHY `--backend meson` IS PASSED EXPLICITLY RATHER THAN LEFT TO DEFAULT
|
|
38
|
+
# f2py picks its backend from the Python version: meson on >=3.12 (where
|
|
39
|
+
# distutils is gone), distutils below that. Leaving it implicit means the
|
|
40
|
+
# generated service builds through two different toolchains depending on
|
|
41
|
+
# the interpreter, which is the opposite of what a re-hosting tool should
|
|
42
|
+
# promise — and the distutils path is actively broken, because
|
|
43
|
+
# `numpy.distutils` calls a `Compiler.__init__` signature that modern
|
|
44
|
+
# setuptools no longer has:
|
|
45
|
+
# TypeError: Compiler.__init__() takes from 1 to 3 positional
|
|
46
|
+
# arguments but 4 were given
|
|
47
|
+
# Reproduced on 3.11 + numpy 2.4 + setuptools 84: the default backend
|
|
48
|
+
# exits 1, `--backend meson` exits 0 and the extension computes. CI found
|
|
49
|
+
# it on macOS 3.10/3.11 while 3.12 passed, which is exactly the shape of
|
|
50
|
+
# a version-dependent backend.
|
|
51
|
+
#
|
|
52
|
+
# numpy.distutils is deprecated and already unavailable on 3.12+, so this
|
|
53
|
+
# is the direction of travel regardless. meson + ninja are declared in the
|
|
54
|
+
# [build] extra and installed by the generated Dockerfile.
|
|
55
|
+
return f"""cmake_minimum_required(VERSION 3.18)
|
|
56
|
+
project({service_name} LANGUAGES Fortran)
|
|
57
|
+
|
|
58
|
+
find_package(Python REQUIRED COMPONENTS Interpreter Development.Module NumPy)
|
|
59
|
+
|
|
60
|
+
execute_process(
|
|
61
|
+
COMMAND "${{Python_EXECUTABLE}}" -c "import sysconfig; print(sysconfig.get_config_var('EXT_SUFFIX'))"
|
|
62
|
+
OUTPUT_VARIABLE PY_EXT_SUFFIX
|
|
63
|
+
OUTPUT_STRIP_TRAILING_WHITESPACE
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
set(F2PY_SOURCES
|
|
67
|
+
{sources}
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
set(F2PY_OUTPUT "${{CMAKE_CURRENT_BINARY_DIR}}/{module.name}${{PY_EXT_SUFFIX}}")
|
|
71
|
+
|
|
72
|
+
# f2py's meson backend picks tempfile.mkdtemp() for its build directory
|
|
73
|
+
# whenever it isn't told otherwise, and nothing removes that directory or
|
|
74
|
+
# records where it went — orphaning the compile_commands.json and .o files
|
|
75
|
+
# meson always writes there. Pin an explicit, discoverable build directory
|
|
76
|
+
# under the service's own `.nativegate/` tree (the same convention used for
|
|
77
|
+
# `.nativegate/ir.json`) so `ngate build` leaves a real, locatable build
|
|
78
|
+
# tree behind: services/<name>/.nativegate/build/bbdir/compile_commands.json.
|
|
79
|
+
set(F2PY_BUILD_DIR "${{CMAKE_CURRENT_SOURCE_DIR}}/.nativegate/build")
|
|
80
|
+
|
|
81
|
+
add_custom_command(
|
|
82
|
+
OUTPUT "${{F2PY_OUTPUT}}"
|
|
83
|
+
COMMAND "${{Python_EXECUTABLE}}" -m numpy.f2py -c --backend meson --build-dir "${{F2PY_BUILD_DIR}}" -m {module.name} ${{F2PY_SOURCES}}{only_clause}
|
|
84
|
+
WORKING_DIRECTORY "${{CMAKE_CURRENT_BINARY_DIR}}"
|
|
85
|
+
DEPENDS ${{F2PY_SOURCES}}
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
add_custom_target({module.name}_ext ALL DEPENDS "${{F2PY_OUTPUT}}")
|
|
89
|
+
|
|
90
|
+
install(FILES "${{F2PY_OUTPUT}}" DESTINATION {service_name}/_native)
|
|
91
|
+
"""
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""Composed-gateway generator: many services, one FastAPI app, one URL.
|
|
2
|
+
|
|
3
|
+
This is the in-process composition option (design.md section 23, Phase 2/3).
|
|
4
|
+
It trades independent scaling for operational simplicity: one image, one
|
|
5
|
+
deploy, one URL. The alternative — keep every service in its own image and
|
|
6
|
+
put an API gateway / K8s Ingress in front — is Phase 4 and needs no code
|
|
7
|
+
generation at all, just routing config. See docs/deployment-topologies.md.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from ..ir import is_valid_python_name
|
|
13
|
+
from . import mcp_gen
|
|
14
|
+
from .error_gen import ERROR_HANDLER_IMPORTS, generate_error_handler
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def generate_gateway_app(
|
|
18
|
+
gateway_name: str, service_names: list[str], package_name: str | None = None
|
|
19
|
+
) -> str:
|
|
20
|
+
for name in service_names:
|
|
21
|
+
# `from lambda.router import ...` is a SyntaxError, and so is any
|
|
22
|
+
# service name that is not an identifier — say so here rather than
|
|
23
|
+
# writing a gateway that cannot be imported.
|
|
24
|
+
if not is_valid_python_name(name):
|
|
25
|
+
raise ValueError(
|
|
26
|
+
f"Service name '{name}' cannot be imported from a gateway: it is "
|
|
27
|
+
"not a valid Python module name (a keyword, or not an identifier). "
|
|
28
|
+
"Rename the service."
|
|
29
|
+
)
|
|
30
|
+
# A service mounted at /mcp would be shadowed by the gateway's MCP
|
|
31
|
+
# endpoint, which is mounted at the same path. Starlette resolves the
|
|
32
|
+
# mount before the router, so the service's routes would simply stop
|
|
33
|
+
# answering — silently, with no error at startup.
|
|
34
|
+
if f"/{name}" == mcp_gen.MCP_MOUNT_PATH:
|
|
35
|
+
raise ValueError(
|
|
36
|
+
f"Service name '{name}' collides with the gateway's MCP endpoint "
|
|
37
|
+
f"at {mcp_gen.MCP_MOUNT_PATH}: mounting it would make the service "
|
|
38
|
+
"unreachable. Rename the service."
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
imports = "\n".join(
|
|
42
|
+
f"from {name}.router import router as {name}_router" for name in service_names
|
|
43
|
+
)
|
|
44
|
+
includes = "\n".join(
|
|
45
|
+
f'app.include_router({name}_router, prefix="/{name}")' for name in service_names
|
|
46
|
+
)
|
|
47
|
+
listed = ", ".join(f'"{name}"' for name in service_names)
|
|
48
|
+
# The distribution name may be hyphenated ("platform-api"); the logger
|
|
49
|
+
# and middleware need the importable spelling.
|
|
50
|
+
package_name = package_name or gateway_name.replace("-", "_")
|
|
51
|
+
# The mounted routers run under THIS app, so the gateway needs its own copy
|
|
52
|
+
# of the handler — a handler on a service's own `service.py` app is not
|
|
53
|
+
# reached in this topology. Same generator, so the two cannot drift.
|
|
54
|
+
error_imports = "\n".join(ERROR_HANDLER_IMPORTS)
|
|
55
|
+
|
|
56
|
+
return f'''# Generated by nativegate. Do not edit by hand — re-run `ngate gateway`.
|
|
57
|
+
"""Composed API gateway: mounts each service's router under its own prefix.
|
|
58
|
+
|
|
59
|
+
Each mounted service is still built and versioned independently — this app
|
|
60
|
+
only depends on their installed wheels. Swapping to one-image-per-service
|
|
61
|
+
means dropping this file and routing at the ingress instead; the service
|
|
62
|
+
routers are unchanged either way.
|
|
63
|
+
"""
|
|
64
|
+
{error_imports}
|
|
65
|
+
from fastapi import FastAPI, Request
|
|
66
|
+
|
|
67
|
+
from .middleware import install_middleware, readiness
|
|
68
|
+
{mcp_gen.MCP_APP_IMPORT}
|
|
69
|
+
{imports}
|
|
70
|
+
|
|
71
|
+
# One MCP endpoint for the whole platform, covering every mounted service —
|
|
72
|
+
# the composed topology's "one URL" promise applies to the LLM interface too.
|
|
73
|
+
# `lifespan=` is required: see generators/mcp_gen.py.
|
|
74
|
+
app = FastAPI(title="{gateway_name}", lifespan=mcp_app.lifespan)
|
|
75
|
+
|
|
76
|
+
{includes}
|
|
77
|
+
|
|
78
|
+
# The mounted routers run under THIS app, so the gateway installs its own
|
|
79
|
+
# middleware and readiness probe. Its auth mode is the strictest of the
|
|
80
|
+
# services it mounts — composing an authenticated service into an open gateway
|
|
81
|
+
# would publish it unprotected.
|
|
82
|
+
install_middleware(app, "{package_name}")
|
|
83
|
+
readiness(app, "{package_name}")
|
|
84
|
+
{mcp_gen.mcp_mount_lines()}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@app.get("/healthz", tags=["health"])
|
|
88
|
+
def healthz():
|
|
89
|
+
"""Liveness: every mounted service's native extension imported cleanly."""
|
|
90
|
+
return {{"status": "ok", "services": [{listed}]}}
|
|
91
|
+
{generate_error_handler(gateway_name)}'''
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def generate_gateway_pyproject(gateway_name: str, package_name: str, service_names: list[str]) -> str:
|
|
95
|
+
deps = ", ".join(f'"{name}"' for name in service_names)
|
|
96
|
+
return f'''[build-system]
|
|
97
|
+
requires = ["setuptools>=68"]
|
|
98
|
+
build-backend = "setuptools.build_meta"
|
|
99
|
+
|
|
100
|
+
[project]
|
|
101
|
+
name = "{gateway_name}"
|
|
102
|
+
version = "1.0.0"
|
|
103
|
+
requires-python = ">=3.10"
|
|
104
|
+
# Each service is a normal wheel dependency — build them independently,
|
|
105
|
+
# publish them, and pin versions here like any other package.
|
|
106
|
+
dependencies = ["fastapi", "uvicorn", "fastmcp", {deps}]
|
|
107
|
+
|
|
108
|
+
[tool.setuptools]
|
|
109
|
+
packages = ["{package_name}"]
|
|
110
|
+
'''
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""The generated numerical-regression test (see nativegate/golden.py).
|
|
2
|
+
|
|
3
|
+
Deliberately self-contained: it reads `golden.json` and replays the calls
|
|
4
|
+
recorded there with nothing but the standard library and the service's own
|
|
5
|
+
package. A generated service is a deployable artifact — making its test suite
|
|
6
|
+
import nativegate would mean shipping the code generator alongside the thing
|
|
7
|
+
it generated, or a CI job that passes locally and fails in the container.
|
|
8
|
+
|
|
9
|
+
The test itself lives in `nativegate/templates/golden_test_template.py` as a
|
|
10
|
+
real, compilable `.py` file rather than a string literal here, so that this
|
|
11
|
+
project's own tooling can see inside it. This module only substitutes the
|
|
12
|
+
three sentinel names in it; see that package's docstring for why substitution
|
|
13
|
+
is textual rather than `str.format`.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import re
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
TEMPLATE_PATH = Path(__file__).resolve().parent.parent / "templates" / "golden_test_template.py"
|
|
22
|
+
|
|
23
|
+
#: Sentinel name in the template -> name of the substitution that replaces it.
|
|
24
|
+
PLACEHOLDERS = {
|
|
25
|
+
"NATIVEGATE_PACKAGE": "package",
|
|
26
|
+
"NATIVEGATE_SERVICE_NAME": "service",
|
|
27
|
+
"NATIVEGATE_GOLDEN_FILENAME": "golden_filename",
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
_PLACEHOLDER_RE = re.compile(
|
|
31
|
+
"|".join(sorted(map(re.escape, PLACEHOLDERS), key=len, reverse=True))
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _read_template() -> str:
|
|
36
|
+
return TEMPLATE_PATH.read_text(encoding="utf-8")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def generate_golden_test(package_name: str, service_name: str, golden_filename: str) -> str:
|
|
40
|
+
values = {
|
|
41
|
+
"package": package_name,
|
|
42
|
+
"service": service_name,
|
|
43
|
+
"golden_filename": golden_filename,
|
|
44
|
+
}
|
|
45
|
+
# One pass, so a substituted value that happens to contain a sentinel is
|
|
46
|
+
# never itself rewritten by a later replacement.
|
|
47
|
+
return _PLACEHOLDER_RE.sub(
|
|
48
|
+
lambda match: values[PLACEHOLDERS[match.group(0)]],
|
|
49
|
+
_read_template(),
|
|
50
|
+
)
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
"""Kubernetes manifests for a generated service (design.md sections 4, 20, 21).
|
|
2
|
+
|
|
3
|
+
`ngate init` created an empty `infrastructure/kubernetes/` and nothing
|
|
4
|
+
else, so every deployment was hand-written from scratch — which in practice
|
|
5
|
+
means the security context and the probes are whatever the person in a hurry
|
|
6
|
+
remembered. This generates them.
|
|
7
|
+
|
|
8
|
+
The manifests are deliberately *opinionated about safety and vague about
|
|
9
|
+
scale*: replica counts, CPU limits and image registries are yours, but the
|
|
10
|
+
security context, the probe wiring and the COMMON-block worker constraint are
|
|
11
|
+
not negotiable defaults, because each of them is a lesson already paid for
|
|
12
|
+
elsewhere in this codebase.
|
|
13
|
+
|
|
14
|
+
VALIDATION
|
|
15
|
+
|
|
16
|
+
`tests/test_k8s_gen.py` parses the output as YAML and asserts on the resulting
|
|
17
|
+
objects, because a substring check passes happily on a file `kubectl` rejects.
|
|
18
|
+
Beyond that, the generated manifests were deserialized into the official
|
|
19
|
+
`kubernetes` client's `V1Deployment` / `V1Service` models and round-tripped:
|
|
20
|
+
every field survived, so nothing here is a name the real API would silently
|
|
21
|
+
drop. That check is not in the suite — it would mean a dependency on the
|
|
22
|
+
Kubernetes client for one test — so re-run it by hand if you add a field.
|
|
23
|
+
|
|
24
|
+
WHY THE PROBES ARE SPLIT THE WAY THEY ARE
|
|
25
|
+
|
|
26
|
+
`readinessProbe` -> `/readyz` and `livenessProbe` -> `/healthz`, never the
|
|
27
|
+
other way and never the same endpoint for both:
|
|
28
|
+
|
|
29
|
+
* `/readyz` reports 503 while the process is draining after SIGTERM. Pointing
|
|
30
|
+
a *liveness* probe at it would get a draining pod killed mid-request instead
|
|
31
|
+
of drained — turning a clean rolling deploy into dropped connections.
|
|
32
|
+
* `/healthz` stays up during a drain. Pointing a *readiness* probe at it would
|
|
33
|
+
keep sending new work to a pod that is shutting down, which is the burst of
|
|
34
|
+
502s the drain exists to prevent.
|
|
35
|
+
|
|
36
|
+
WHY `readOnlyRootFilesystem` NEEDS THE emptyDir
|
|
37
|
+
|
|
38
|
+
A read-only root is worth having — it removes "write a binary and execute it"
|
|
39
|
+
from anything that gets code execution through the native library. But CPython
|
|
40
|
+
wants somewhere to write, and numerical code often wants scratch space, so
|
|
41
|
+
`/tmp` is mounted as an emptyDir. `PYTHONDONTWRITEBYTECODE` is set in the
|
|
42
|
+
image so the interpreter does not try to write `.pyc` next to the source.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
from __future__ import annotations
|
|
46
|
+
|
|
47
|
+
# Matched to docker_gen: Fortran is pinned to one worker because COMMON blocks
|
|
48
|
+
# are per-process, so a configure call and the compute that reads it must land
|
|
49
|
+
# in the same process. Drifting from that here would silently reintroduce the
|
|
50
|
+
# bug at deploy time, where it is far harder to see.
|
|
51
|
+
_DEFAULT_WORKERS = {"cpp": 2, "fortran": 1}
|
|
52
|
+
|
|
53
|
+
MANIFEST_FILENAME = "deployment.yaml"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def generate_k8s_manifests(
|
|
57
|
+
service_name: str,
|
|
58
|
+
language: str,
|
|
59
|
+
image: str | None = None,
|
|
60
|
+
*,
|
|
61
|
+
auth: str = "none",
|
|
62
|
+
replicas: int = 2,
|
|
63
|
+
) -> str:
|
|
64
|
+
"""A Deployment + Service for one generated service."""
|
|
65
|
+
image = image or f"{service_name}:latest"
|
|
66
|
+
workers = _DEFAULT_WORKERS.get(language, 1)
|
|
67
|
+
|
|
68
|
+
worker_note = (
|
|
69
|
+
" # One worker per POD, not per node: COMMON blocks are\n"
|
|
70
|
+
" # per-process. Scale with replicas, never by raising this,\n"
|
|
71
|
+
" # unless the exposed routines keep no state between calls.\n"
|
|
72
|
+
if language == "fortran"
|
|
73
|
+
else " # Per-request instances and no process-global state.\n"
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
# Only rendered for api_key services. A secretKeyRef to a Secret that does
|
|
77
|
+
# not exist keeps the pod in CreateContainerConfigError, which is a
|
|
78
|
+
# confusing failure to hand someone who never asked for authentication.
|
|
79
|
+
auth_env = ""
|
|
80
|
+
auth_note = ""
|
|
81
|
+
if auth == "api_key":
|
|
82
|
+
auth_env = f"""
|
|
83
|
+
- name: NATIVEGATE_API_KEYS
|
|
84
|
+
valueFrom:
|
|
85
|
+
secretKeyRef:
|
|
86
|
+
name: {service_name}-api-keys
|
|
87
|
+
key: keys"""
|
|
88
|
+
auth_note = f"""
|
|
89
|
+
#
|
|
90
|
+
# Generated with `api.auth: api_key`. Without keys this service will
|
|
91
|
+
# REFUSE-TO-START, deliberately — see docs/production-readiness.md.
|
|
92
|
+
# Create the Secret before deploying:
|
|
93
|
+
#
|
|
94
|
+
# kubectl create secret generic {service_name}-api-keys \\
|
|
95
|
+
# --from-literal=keys="$(openssl rand -hex 32)"
|
|
96
|
+
#"""
|
|
97
|
+
|
|
98
|
+
return f"""# Generated by nativegate. Do not edit by hand — re-run `ngate k8s {service_name}`.
|
|
99
|
+
#
|
|
100
|
+
# Replicas, resource limits and the image reference are starting points; the
|
|
101
|
+
# securityContext and the probe wiring are not. See generators/k8s_gen.py for
|
|
102
|
+
# why each is shaped the way it is.{auth_note}
|
|
103
|
+
apiVersion: apps/v1
|
|
104
|
+
kind: Deployment
|
|
105
|
+
metadata:
|
|
106
|
+
name: {service_name}
|
|
107
|
+
labels:
|
|
108
|
+
app: {service_name}
|
|
109
|
+
spec:
|
|
110
|
+
replicas: {replicas}
|
|
111
|
+
selector:
|
|
112
|
+
matchLabels:
|
|
113
|
+
app: {service_name}
|
|
114
|
+
strategy:
|
|
115
|
+
type: RollingUpdate
|
|
116
|
+
rollingUpdate:
|
|
117
|
+
# With maxUnavailable 0 a new pod must pass its readiness probe before an
|
|
118
|
+
# old one is removed, so a native extension that fails to load takes the
|
|
119
|
+
# rollout down instead of the service.
|
|
120
|
+
maxUnavailable: 0
|
|
121
|
+
maxSurge: 1
|
|
122
|
+
template:
|
|
123
|
+
metadata:
|
|
124
|
+
labels:
|
|
125
|
+
app: {service_name}
|
|
126
|
+
spec:
|
|
127
|
+
# Long enough for gunicorn's --graceful-timeout 30 to finish in-flight
|
|
128
|
+
# native calls after /readyz starts reporting 503.
|
|
129
|
+
terminationGracePeriodSeconds: 45
|
|
130
|
+
securityContext:
|
|
131
|
+
runAsNonRoot: true
|
|
132
|
+
runAsUser: 1000
|
|
133
|
+
fsGroup: 1000
|
|
134
|
+
seccompProfile:
|
|
135
|
+
type: RuntimeDefault
|
|
136
|
+
containers:
|
|
137
|
+
- name: {service_name}
|
|
138
|
+
image: {image}
|
|
139
|
+
imagePullPolicy: IfNotPresent
|
|
140
|
+
ports:
|
|
141
|
+
- name: http
|
|
142
|
+
containerPort: 8000
|
|
143
|
+
securityContext:
|
|
144
|
+
allowPrivilegeEscalation: false
|
|
145
|
+
readOnlyRootFilesystem: true
|
|
146
|
+
capabilities:
|
|
147
|
+
drop: ["ALL"]
|
|
148
|
+
env:
|
|
149
|
+
{worker_note} - name: WEB_CONCURRENCY
|
|
150
|
+
value: "{workers}"
|
|
151
|
+
- name: NATIVEGATE_MAX_REQUEST_BYTES
|
|
152
|
+
value: "1048576"{auth_env}
|
|
153
|
+
# Draining: reports 503 on SIGTERM so this pod leaves the Service's
|
|
154
|
+
# endpoints BEFORE it stops accepting. Must not be pointed at /healthz.
|
|
155
|
+
readinessProbe:
|
|
156
|
+
httpGet:
|
|
157
|
+
path: /readyz
|
|
158
|
+
port: http
|
|
159
|
+
periodSeconds: 5
|
|
160
|
+
timeoutSeconds: 3
|
|
161
|
+
failureThreshold: 2
|
|
162
|
+
# Stays up during a drain, so a draining pod is not killed mid-request.
|
|
163
|
+
# Must not be pointed at /readyz.
|
|
164
|
+
livenessProbe:
|
|
165
|
+
httpGet:
|
|
166
|
+
path: /healthz
|
|
167
|
+
port: http
|
|
168
|
+
periodSeconds: 15
|
|
169
|
+
timeoutSeconds: 3
|
|
170
|
+
failureThreshold: 3
|
|
171
|
+
# A native extension can take a while to load. Without this, a slow
|
|
172
|
+
# start is read as a failed start and the pod restarts forever.
|
|
173
|
+
startupProbe:
|
|
174
|
+
httpGet:
|
|
175
|
+
path: /healthz
|
|
176
|
+
port: http
|
|
177
|
+
periodSeconds: 3
|
|
178
|
+
failureThreshold: 30
|
|
179
|
+
resources:
|
|
180
|
+
requests:
|
|
181
|
+
cpu: "500m"
|
|
182
|
+
memory: "256Mi"
|
|
183
|
+
limits:
|
|
184
|
+
# No CPU limit on purpose: throttling a native numerical routine
|
|
185
|
+
# mid-call inflates latency badly and CPU is already bounded by
|
|
186
|
+
# WEB_CONCURRENCY. Memory IS limited, because a native leak
|
|
187
|
+
# otherwise takes the node down rather than the pod.
|
|
188
|
+
memory: "1Gi"
|
|
189
|
+
volumeMounts:
|
|
190
|
+
# readOnlyRootFilesystem needs somewhere writable: CPython wants a
|
|
191
|
+
# temp dir and numerical code often wants scratch space.
|
|
192
|
+
- name: tmp
|
|
193
|
+
mountPath: /tmp
|
|
194
|
+
volumes:
|
|
195
|
+
- name: tmp
|
|
196
|
+
emptyDir: {{}}
|
|
197
|
+
---
|
|
198
|
+
apiVersion: v1
|
|
199
|
+
kind: Service
|
|
200
|
+
metadata:
|
|
201
|
+
name: {service_name}
|
|
202
|
+
labels:
|
|
203
|
+
app: {service_name}
|
|
204
|
+
spec:
|
|
205
|
+
type: ClusterIP
|
|
206
|
+
selector:
|
|
207
|
+
app: {service_name}
|
|
208
|
+
ports:
|
|
209
|
+
- name: http
|
|
210
|
+
port: 80
|
|
211
|
+
targetPort: http
|
|
212
|
+
"""
|