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.
Files changed (50) hide show
  1. nativegate/__init__.py +1 -0
  2. nativegate/__main__.py +4 -0
  3. nativegate/buildinfo.py +344 -0
  4. nativegate/cli.py +2007 -0
  5. nativegate/config.py +991 -0
  6. nativegate/declared_invariants.py +565 -0
  7. nativegate/discovery.py +167 -0
  8. nativegate/driverbuild.py +626 -0
  9. nativegate/drivers/__init__.py +5 -0
  10. nativegate/drivers/cpp.py +616 -0
  11. nativegate/drivers/fortran.py +507 -0
  12. nativegate/generators/__init__.py +0 -0
  13. nativegate/generators/cmake_gen.py +101 -0
  14. nativegate/generators/docker_gen.py +614 -0
  15. nativegate/generators/error_gen.py +104 -0
  16. nativegate/generators/f2py_gen.py +91 -0
  17. nativegate/generators/gateway_gen.py +110 -0
  18. nativegate/generators/golden_gen.py +50 -0
  19. nativegate/generators/k8s_gen.py +212 -0
  20. nativegate/generators/mcp_gen.py +281 -0
  21. nativegate/generators/middleware_gen.py +717 -0
  22. nativegate/generators/pybind_gen.py +406 -0
  23. nativegate/generators/pyproject_gen.py +61 -0
  24. nativegate/generators/python_pkg_gen.py +1164 -0
  25. nativegate/generators/test_gen.py +160 -0
  26. nativegate/golden.py +747 -0
  27. nativegate/invariants.py +532 -0
  28. nativegate/ir.py +789 -0
  29. nativegate/lattice.py +350 -0
  30. nativegate/locking.py +216 -0
  31. nativegate/oracle.py +904 -0
  32. nativegate/parsers/__init__.py +0 -0
  33. nativegate/parsers/cpp.py +105 -0
  34. nativegate/parsers/cpp_ast.py +1652 -0
  35. nativegate/parsers/cpp_regex.py +812 -0
  36. nativegate/parsers/fixed_form.py +868 -0
  37. nativegate/parsers/fortran.py +157 -0
  38. nativegate/parsers/fortran_fparser.py +1116 -0
  39. nativegate/parsers/fortran_regex.py +686 -0
  40. nativegate/preprocess.py +335 -0
  41. nativegate/structural_invariants.py +762 -0
  42. nativegate/suggest.py +208 -0
  43. nativegate/templates/__init__.py +20 -0
  44. nativegate/templates/golden_test_template.py +248 -0
  45. nativegate/wire.py +438 -0
  46. nativegate-0.1.0.dist-info/METADATA +547 -0
  47. nativegate-0.1.0.dist-info/RECORD +50 -0
  48. nativegate-0.1.0.dist-info/WHEEL +5 -0
  49. nativegate-0.1.0.dist-info/entry_points.txt +3 -0
  50. 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
+ """