diff --git a/.github/workflows/dev.yml b/.github/workflows/dev.yml index 3066b26..8403cb6 100644 --- a/.github/workflows/dev.yml +++ b/.github/workflows/dev.yml @@ -4,7 +4,8 @@ on: # schedule: # - cron: '45 6 * * 0' # At 06:45 on Sunday pull_request: - branches: [main, deploy-test] + # No `branches:` filter — stacked migration PRs target feature branches, not + # main/deploy-test, and a filter would silently skip their CI (runbook Trap #14). # Allows you to run this workflow manually from the Actions tab workflow_dispatch: diff --git a/.yarnrc.yml b/.yarnrc.yml new file mode 100644 index 0000000..636a3db --- /dev/null +++ b/.yarnrc.yml @@ -0,0 +1,11 @@ +nodeLinker: node-modules + +# Supply-chain hardening (runbook §4.6): refuse npm packages younger than 7 days, +# but allow project-internal packages through so dev velocity isn't blocked. +npmMinimalAgeGate: "7d" +npmPreapprovedPackages: + - "nshm-*" + - "nzshm-*" + - "solvis-*" + - "weka-*" + - "toshi-*" diff --git a/Dockerfile b/Dockerfile index 5ec259f..e5aa8f2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -19,6 +19,7 @@ RUN dnf install git-core -y &&\ dnf remove git-core -y &&\ dnf clean all -# lambda entry point -CMD ["solvis_graphql_api.solvis_graphql_api.app"] -ENTRYPOINT ["/bin/bash", "-c"] +# lambda entry point — Mangum handler (was the serverless-wsgi handler.py workaround). +# ENTRYPOINT is the base image's Runtime Interface Client (/lambda-entrypoint.sh); CMD is +# the handler. Matches serverless.yml functions.ecr-app.image.{entryPoint,command}. +CMD ["solvis_graphql_api.app.handler"] diff --git a/docs/MIGRATION_LOG.md b/docs/MIGRATION_LOG.md index 2277b13..50b590d 100644 --- a/docs/MIGRATION_LOG.md +++ b/docs/MIGRATION_LOG.md @@ -105,13 +105,13 @@ Source: code exploration 2026-06-24 on `deploy-test` @ `93f6f62`. Version **0.9. - kororaa-sourced (3): transformed gateway → solvis-native (real frontend traffic) - [ ] Inventory `serverless.yml` (stages, ECR, IAM, warmup) + secrets (`TEST` env + repo keys) -### Phase 1 — Bootstrap (poetry→uv, FastAPI/Mangum, container) -- [ ] `poetry2uv` (dockerbash skill): drop poetry, generate `uv.lock`, ruff config, README/docs -- [ ] Add `strawberry-graphql`/`fastapi`/`mangum`/`pydantic`; keep legacy graphene/flask alongside until cutover -- [ ] New `app.py` (FastAPI + `GraphQLRouter` + Mangum); `StrawberryConfig(auto_camel_case=False)` -- [ ] Replace `handler.py` with Mangum; update `Dockerfile` CMD + serverless `image.command`; drop `serverless-wsgi` plugin + `custom.wsgi` -- [ ] `.yarnrc.yml`: add age gate + preapproved scopes; pin `packageManager` -- [ ] Verify container boots locally (`docker build` + invoke `{ __typename }`) +### Phase 1 — Bootstrap (poetry→uv, FastAPI/Mangum, container) ✅ +- [x] `poetry2uv` (external run, PR #87): poetry→uv, ruff, age gate, CI→`-uv` +- [x] Add `strawberry-graphql` 0.316 / `fastapi` 0.137 / `mangum` 0.21 / `pydantic` 2.13; legacy graphene/flask kept alongside until cutover +- [x] New `app.py` (FastAPI + `GraphQLRouter` + Mangum + CORS); `strawberry_schema.py` minimal `QueryRoot.about`, `StrawberryConfig(auto_camel_case=False)` +- [x] Container entry → Mangum: `serverless.yml` `image.command` = `solvis_graphql_api.app.handler`; dropped `serverless-wsgi` plugin + `custom.wsgi`; `Dockerfile` CMD = `app.handler` (removed bogus `/bin/bash -c` entrypoint). `handler.py` left in place (unused; deleted at cutover) +- [x] `.yarnrc.yml`: tracked + age gate + preapproved scopes (`packageManager` already pinned `yarn@4.10.3`) +- [x] Container verified locally: amd64 `docker build` OK; `app.handler` imports inside the image; `uvicorn` TestClient `{ about }` byte-matches legacy. Removed `branches:` filter (Trap #14) so the stack gets CI. ### Phase 2 — Data layer + schema migration - [ ] `BinaryLargeObjectModel` (PynamoDB) → pydantic + boto3 CRUD; **preserve `BinaryLargeObject` wrapper API**; keep `migrate()`/`drop_tables()` @@ -156,4 +156,13 @@ Source: code exploration 2026-06-24 on `deploy-test` @ `93f6f62`. Version **0.9. - All green: corpus gate 15 passed; full suite **77 passed / 10 skipped**; ruff clean. - **Next:** Phase 1 — FastAPI/Mangum scaffold + container `handler.py` → Mangum (verify via local `docker build`), `.yarnrc` age gate, drop `serverless-wsgi`. +### 2026-06-24 — Phase 1 bootstrap (FastAPI/Mangum + container swap) +- **Stack added** alongside legacy: `strawberry-graphql` 0.316, `fastapi` 0.137, `mangum` 0.21, `pydantic` 2.13 (`uv lock`/`sync` clean). `app.py` = FastAPI + `GraphQLRouter` + Mangum + CORS; `strawberry_schema.py` = minimal `QueryRoot.about` at SDL parity (`auto_camel_case=False`, `about: String` nullable, description matches). + - **Root type named `QueryRoot`** (not Strawberry's default `Query`) — legacy is `query: QueryRoot` and the vendored kororaa corpus fragments are `on QueryRoot` (Model G4/relay-parity territory). + - **`/schema.py` collision** (Model G4): legacy Graphene `schema_root` occupies `solvis_graphql_api.schema`, so the new schema is `strawberry_schema.py`; rename at cutover. +- **Container entry → Mangum.** `serverless.yml` `functions.ecr-app.image.command` = `solvis_graphql_api.app.handler`; dropped `serverless-wsgi` plugin + `custom.wsgi`; `Dockerfile` CMD = `app.handler` (and removed the bogus `ENTRYPOINT ["/bin/bash","-c"]` — the base image's `/lambda-entrypoint.sh` RIC stands). `handler.py` (the serverless-wsgi container workaround) is now unused — deleted at cutover. +- **Verified the container for real** (your steer): `requirements.txt` regenerated from `uv.lock` (`uv export --no-dev`, gitignored); **amd64 `docker build` succeeded**; `docker run … python -c "import solvis_graphql_api.app"` → Mangum handler + FastAPI app import clean inside the image. Local `uvicorn`/TestClient: `{ about }` byte-matches legacy. Full legacy suite still **77 passed / 10 skipped**; ruff clean. +- **CI:** removed the `dev.yml` `branches:` filter so stacked PRs get CI (Trap #14); added `.yarnrc.yml` age gate (§4.6). +- **Next:** Phase 2 — the headline work: `BinaryLargeObjectModel` (PynamoDB) → pydantic + boto3 behind the `BinaryLargeObject` wrapper, then port the ~25 graphene types to Strawberry at SDL parity. + diff --git a/pyproject.toml b/pyproject.toml index a0a4653..0cff075 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,16 +16,23 @@ classifiers=[ 'Programming Language :: Python :: 3.12', ] dependencies = [ + # --- new Strawberry / FastAPI / Mangum stack --- + "strawberry-graphql>=0.243", + "fastapi>=0.115", + "mangum>=0.18", + "pydantic>=2.0", + # --- legacy Graphene / Flask stack (removed at cutover) --- "flask>=3.0.3", "flask-cors>=6.0", "graphene>=3.3", "graphql-server==3.0.0b7", + "serverless-wsgi>=3.0", + # --- data + compute (unchanged) --- "matplotlib>=3.10.7,<4.0.0", "nzshm-common>=0.9.0,<1.0", "nzshm-model>=0.14.0,<1.0", "pynamodb>=6.0.0", "pyyaml>=6.0.1", - "serverless-wsgi>=3.0", "solvis>=1.2.0,<2.0", ] @@ -38,6 +45,7 @@ dev = [ "bump2version", "click", "deepdiff", + "httpx", "moto", "mypy", "pip-audit", @@ -45,6 +53,7 @@ dev = [ "pytest-cov", "pytest", "ruff", + "uvicorn", "safety", "sgqlc", "shapely", diff --git a/serverless.yml b/serverless.yml index dadc19f..cee477a 100644 --- a/serverless.yml +++ b/serverless.yml @@ -7,7 +7,6 @@ service: nzshm22-solvis-graphql-api configValidationMode: error plugins: - - serverless-wsgi - serverless-plugin-warmup package: @@ -59,12 +58,6 @@ custom: s3_role_resource: ${self:custom.s3_bucket_arn} url_default_ttl: 60 # presigned URL TTL - #serverless-wsgi settings - wsgi: - app: solvis_graphql_api.solvis_graphql_api.app - packRequirements: false - pythonBin: python3.12 - #serverless-s3-local settings s3: host: localhost @@ -159,7 +152,7 @@ functions: image: name: app_image_0 command: - - solvis_graphql_api.handler.handler + - solvis_graphql_api.app.handler # Mangum (was serverless-wsgi handler.handler) entryPoint: - '/lambda-entrypoint.sh' diff --git a/solvis_graphql_api/app.py b/solvis_graphql_api/app.py new file mode 100644 index 0000000..6a70af6 --- /dev/null +++ b/solvis_graphql_api/app.py @@ -0,0 +1,45 @@ +"""FastAPI + Mangum entry point — migration scaffold. + +Replaces the Flask + ``serverless-wsgi`` container entry (``handler.py``). Mangum is +packaging-agnostic, so it runs inside the ECR container without the serverless-wsgi +workaround that ``handler.py`` exists for. Serves the Strawberry schema at ``/graphql``. + +The Lambda handler is ``solvis_graphql_api.app.handler`` (wired in ``serverless.yml`` +``functions.ecr-app.image.command`` and the ``Dockerfile`` CMD). The legacy Flask app +(``solvis_graphql_api.solvis_graphql_api:app``) stays importable until cutover. +""" + +import logging +import logging.config +import os + +import yaml +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from mangum import Mangum +from strawberry.fastapi import GraphQLRouter + +from solvis_graphql_api.strawberry_schema import schema + +LOGGING_CFG = os.getenv("LOGGING_CFG", "solvis_graphql_api/logging_aws.yaml") +logger = logging.getLogger(__name__) + + +def _configure_logging() -> None: + if os.path.exists(LOGGING_CFG): # pragma: no cover + with open(LOGGING_CFG) as f: + logging.config.dictConfig(yaml.safe_load(f.read())) + else: # pragma: no cover + logging.basicConfig(level=logging.INFO) + + +_configure_logging() + +app = FastAPI() +# mirror the legacy flask_cors CORS(app) default (allow all origins) +app.add_middleware( + CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"] +) +app.include_router(GraphQLRouter(schema), prefix="/graphql") + +handler = Mangum(app) diff --git a/solvis_graphql_api/strawberry_schema.py b/solvis_graphql_api/strawberry_schema.py new file mode 100644 index 0000000..5e7dfac --- /dev/null +++ b/solvis_graphql_api/strawberry_schema.py @@ -0,0 +1,32 @@ +"""Strawberry schema — migration scaffold. + +Phase 1 stands up a **minimal** Query so the FastAPI/Mangum app boots and serves a real +field at SDL parity; the full type port lands in Phase 2. + +Named ``strawberry_schema.py`` (not ``schema.py``) because the legacy Graphene schema +occupies the ``solvis_graphql_api.schema`` module path during the migration — a Phase 1 +trap surfaced by the Model pilot. Rename to ``schema.py`` at cutover once the Graphene +schema is removed. +""" + +import strawberry +from strawberry.schema.config import StrawberryConfig + +import solvis_graphql_api + + +@strawberry.type +class QueryRoot: + # root type MUST be named QueryRoot — legacy schema is `query: QueryRoot`, and the + # vendored kororaa corpus fragments are `on QueryRoot` + @strawberry.field(description="About this Solvis API ") + def about(self) -> str | None: + # nullable (-> str | None) to match graphene's nullable String (Model T2) + return f"Hello World, I am solvis_graphql_api! Version: {solvis_graphql_api.__version__}" + + +schema = strawberry.Schema( + query=QueryRoot, + # legacy schema is auto_camelcase=False — required for snake_case field parity (Trap #2) + config=StrawberryConfig(auto_camel_case=False), +) diff --git a/uv.lock b/uv.lock index 64a86d0..1ab8a31 100644 --- a/uv.lock +++ b/uv.lock @@ -586,6 +586,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8a/3c/1a983b9a745d7f83d53f057bcc5bf79ba6a2bbc08266b3f0c7d6fe630c9b/coverage-7.14.1-py3-none-any.whl", hash = "sha256:a252f21c27e38347e60111a3266b03827422a7d5525951aceee313aa68bab1d2", size = 211815, upload-time = "2026-05-26T20:41:34.078Z" }, ] +[[package]] +name = "cross-web" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4a/a0/bdb8370215987cc5bde497cb8b976b17ab14841566ecef2aab6ae3e6c7b0/cross_web-0.7.0.tar.gz", hash = "sha256:15fbc8b9a824a055db8127fd6e43e0773074f620fdecb6b2b587d3d0a2bdd459", size = 332407, upload-time = "2026-05-19T14:18:48.849Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7a/4a/78b52ec2edcbd9b123638f4e0421fd8699ebe653ebf63ac5f82abd2665bc/cross_web-0.7.0-py3-none-any.whl", hash = "sha256:ddea9be3c68b48eaf16561847a5831a559786949c544b3701432e00a4e8d19d9", size = 25207, upload-time = "2026-05-19T14:18:47.614Z" }, +] + [[package]] name = "cryptography" version = "49.0.0" @@ -722,6 +734,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/56/26/035d1c308882514a1e6ddca27f9d3e570d67a0e293e7b4d910a70c8fe32b/dparse-0.6.4-py3-none-any.whl", hash = "sha256:fbab4d50d54d0e739fbb4dedfc3d92771003a5b9aa8545ca7a7045e3b174af57", size = 11925, upload-time = "2024-11-08T16:52:03.844Z" }, ] +[[package]] +name = "fastapi" +version = "0.137.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d5/b1/e5b92c59d2c37817e77c1a8c2fc1f79cdcc04c68253e5406b43e3204cba7/fastapi-0.137.1.tar.gz", hash = "sha256:822360704230d9533d8d9475399613525968aa2f0b5bd2a3ccc9f18c88fd541c", size = 408293, upload-time = "2026-06-15T11:28:20.79Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/35/380b9a5922f4340e51c309cde09e5bd32e62f02302971bee30dc15aa0624/fastapi-0.137.1-py3-none-any.whl", hash = "sha256:64f6983c59e45c4b9fdc44e57cb8035c2451ee91ea8e8ec042aca37de7cf6b69", size = 121877, upload-time = "2026-06-15T11:28:19.523Z" }, +] + [[package]] name = "filelock" version = "3.29.4" @@ -1310,6 +1338,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7f/2c/0f1e93c636720e8a3eb59af2bfda99d98b55891e1c53bc30c2e0e865f01b/lxml-6.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:58bb955caba94e467d2a96da17660d2d704e0675894cba21ab8a775b8621fd1c", size = 3817223, upload-time = "2026-05-19T19:22:56.823Z" }, ] +[[package]] +name = "mangum" +version = "0.21.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/89/cb/d9f4d685a0b8eceac10991e15ac471d9568e4e42c2489ae9bf072828c1c2/mangum-0.21.0.tar.gz", hash = "sha256:e31ed72d67f9958fa4379f65df77729906dec6dfa00afa6ed4e06c77833000de", size = 89130, upload-time = "2026-02-01T17:17:42.816Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/50/e3c694b8e122551e4557450219283771334dee2ed5734a8398c8b8018c50/mangum-0.21.0-py3-none-any.whl", hash = "sha256:309e48f5c629542516c5106ecf079f4ec08809ed50df882238d98fe1392820c7", size = 17146, upload-time = "2026-02-01T17:17:41.553Z" }, +] + [[package]] name = "markdown-it-py" version = "4.2.0" @@ -2953,17 +2993,21 @@ name = "solvis-graphql-api" version = "0.9.2" source = { editable = "." } dependencies = [ + { name = "fastapi" }, { name = "flask" }, { name = "flask-cors" }, { name = "graphene" }, { name = "graphql-server" }, + { name = "mangum" }, { name = "matplotlib" }, { name = "nzshm-common" }, { name = "nzshm-model" }, + { name = "pydantic" }, { name = "pynamodb" }, { name = "pyyaml" }, { name = "serverless-wsgi" }, { name = "solvis" }, + { name = "strawberry-graphql" }, ] [package.dev-dependencies] @@ -2971,6 +3015,7 @@ dev = [ { name = "bump2version" }, { name = "click" }, { name = "deepdiff" }, + { name = "httpx" }, { name = "moto" }, { name = "mypy" }, { name = "pip-audit" }, @@ -2988,21 +3033,26 @@ dev = [ { name = "types-python-dateutil" }, { name = "types-pyyaml" }, { name = "types-toml" }, + { name = "uvicorn" }, ] [package.metadata] requires-dist = [ + { name = "fastapi", specifier = ">=0.115" }, { name = "flask", specifier = ">=3.0.3" }, { name = "flask-cors", specifier = ">=6.0" }, { name = "graphene", specifier = ">=3.3" }, { name = "graphql-server", specifier = "==3.0.0b7" }, + { name = "mangum", specifier = ">=0.18" }, { name = "matplotlib", specifier = ">=3.10.7,<4.0.0" }, { name = "nzshm-common", specifier = ">=0.9.0,<1.0" }, { name = "nzshm-model", specifier = ">=0.14.0,<1.0" }, + { name = "pydantic", specifier = ">=2.0" }, { name = "pynamodb", specifier = ">=6.0.0" }, { name = "pyyaml", specifier = ">=6.0.1" }, { name = "serverless-wsgi", specifier = ">=3.0" }, { name = "solvis", specifier = ">=1.2.0,<2.0" }, + { name = "strawberry-graphql", specifier = ">=0.243" }, ] [package.metadata.requires-dev] @@ -3010,6 +3060,7 @@ dev = [ { name = "bump2version" }, { name = "click" }, { name = "deepdiff" }, + { name = "httpx" }, { name = "moto" }, { name = "mypy" }, { name = "pip-audit" }, @@ -3027,6 +3078,7 @@ dev = [ { name = "types-python-dateutil" }, { name = "types-pyyaml" }, { name = "types-toml" }, + { name = "uvicorn" }, ] [[package]] @@ -3038,6 +3090,35 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload-time = "2021-05-16T22:03:41.177Z" }, ] +[[package]] +name = "starlette" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/e3/7c1dc7381d9f8ab7d854328ebfa884e62cb3f3d8549ddfd37c7814f42afa/starlette-1.3.1.tar.gz", hash = "sha256:05d0213193f2fbaae60e2ecb593b4add4262ad4e46536b54abe36f11a71724e0", size = 2703240, upload-time = "2026-06-12T09:23:11.602Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/bb/2799cc2ede3ed41131f8975621e7213dfc7ef4acbbaadfa440f32500c370/starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6", size = 73632, upload-time = "2026-06-12T09:23:10.017Z" }, +] + +[[package]] +name = "strawberry-graphql" +version = "0.316.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cross-web" }, + { name = "graphql-core" }, + { name = "packaging" }, + { name = "python-dateutil" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cf/09/f189fd3c70544322eeba0398ccaa980d17857894c0a66d447aa0b5704686/strawberry_graphql-0.316.0.tar.gz", hash = "sha256:bee5ce0e20d522325bd1ed2db186c812c03c2ea7db29f997b35e7d694649820c", size = 223985, upload-time = "2026-05-19T17:06:10.443Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/dd/b2cf54803cbd0d37e3ce73c7274bdb144ba83743bc83351b5790eb2a5fad/strawberry_graphql-0.316.0-py3-none-any.whl", hash = "sha256:222e16fbbf953f5a1fa75f62bf9a8e95f274180f928f70bec459b566053aa2cb", size = 326530, upload-time = "2026-05-19T17:06:07.844Z" }, +] + [[package]] name = "tenacity" version = "9.1.4" @@ -3338,6 +3419,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5a/ef/8b1d92f9501963ef8694bb17ad80ba9926d049240d2da0a4f879aa37f3e2/uv-0.11.21-py3-none-win_arm64.whl", hash = "sha256:f64a851e429e6afb96f3a0b688995757ed3697bf1078509e2da8220ffc9805cd", size = 23715885, upload-time = "2026-06-11T18:18:48.596Z" }, ] +[[package]] +name = "uvicorn" +version = "0.49.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c4/1f/fa18009dea8469069cca78a4e877a008ab78f08b064bfc9ab891579077ff/uvicorn-0.49.0.tar.gz", hash = "sha256:ebf4271aa580d9de97f93192d4595176df6e91f9aae919ca73e4fc07df1e66a3", size = 91284, upload-time = "2026-06-03T22:01:30.448Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/fa/e1388bbcf24ef3274f45c0c1c7b501fd14971037c1b6ee23610553307497/uvicorn-0.49.0-py3-none-any.whl", hash = "sha256:ba3d14c3ee7e41c6c654c46c9eb489d33213cdd30aa1696eab1374337c13f68f", size = 71376, upload-time = "2026-06-03T22:01:29.037Z" }, +] + [[package]] name = "virtualenv" version = "21.5.1"