diff --git a/.github/workflows/backend.yml b/.github/workflows/backend.yml index e934884b..d8e60114 100644 --- a/.github/workflows/backend.yml +++ b/.github/workflows/backend.yml @@ -237,6 +237,89 @@ jobs: - name: Deploy kb-sync worker image run: bash scripts/build/deploy-image-lambda-one.sh kb-sync-worker + build-kb-migration: + name: Build kb-migration image + needs: test-backend + # Native ARM64 runner — all four kb-migration Lambdas are arm64 (see the + # managed-kb CDK construct), matching the kb-sync pattern. + runs-on: ubuntu-24.04-arm + environment: ${{ (github.ref == 'refs/heads/main' && 'production') || 'development' }} + + permissions: + id-token: write + contents: read + + env: + CDK_AWS_REGION: ${{ vars.AWS_REGION }} + CDK_AWS_ACCOUNT: ${{ vars.CDK_AWS_ACCOUNT }} + CDK_PROJECT_PREFIX: ${{ vars.CDK_PROJECT_PREFIX }} + AWS_REGION: ${{ vars.AWS_REGION }} + AWS_ACCOUNT_ID: ${{ vars.CDK_AWS_ACCOUNT }} + AWS_ROLE_ARN: ${{ secrets.AWS_ROLE_ARN }} + AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + + outputs: + image_tag: ${{ steps.build.outputs.image_tag }} + + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: ./.github/actions/build-and-push-image + id: build + with: + image-name: kb-migration + aws-region: ${{ vars.AWS_REGION || 'us-west-2' }} + aws-role-arn: ${{ secrets.AWS_ROLE_ARN }} + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + + deploy-kb-migration-code: + name: Deploy kb-migration Lambda images + # ONE image, FOUR functions: dispatcher, worker, reconciler and ingestion + # consumer share the kb-migration image and differ only in + # ImageConfig.Command (CDK-owned), so a single job points all four at the + # freshly-built tag. + # + # This is the job that replaces the bootstrap stub + # (infrastructure/bootstrap-assets/kb-migration/) with the real handlers. + # Until it has run once, an enrolled knowledge base sits in `shadow` while + # the dispatcher ticks into a no-op — safe, because the work keys are sparse + # and the first real tick picks up everything that accumulated. + needs: [build-kb-migration, test-backend] + runs-on: ubuntu-24.04 + environment: ${{ (github.ref == 'refs/heads/main' && 'production') || 'development' }} + + permissions: + id-token: write + contents: read + + env: + CDK_AWS_REGION: ${{ vars.AWS_REGION }} + CDK_AWS_ACCOUNT: ${{ vars.CDK_AWS_ACCOUNT }} + CDK_PROJECT_PREFIX: ${{ vars.CDK_PROJECT_PREFIX }} + AWS_REGION: ${{ vars.AWS_REGION }} + AWS_ACCOUNT_ID: ${{ vars.CDK_AWS_ACCOUNT }} + AWS_ROLE_ARN: ${{ secrets.AWS_ROLE_ARN }} + AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: ./.github/actions/configure-aws-credentials + with: + aws-region: ${{ vars.AWS_REGION || 'us-west-2' }} + aws-role-arn: ${{ secrets.AWS_ROLE_ARN }} + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + - name: Deploy kb-migration dispatcher image + run: bash scripts/build/deploy-image-lambda-one.sh kb-migration-dispatcher + - name: Deploy kb-migration worker image + run: bash scripts/build/deploy-image-lambda-one.sh kb-migration-worker + - name: Deploy kb-migration reconciler image + run: bash scripts/build/deploy-image-lambda-one.sh kb-migration-reconciler + - name: Deploy kb-migration ingestion consumer image + run: bash scripts/build/deploy-image-lambda-one.sh kb-migration-ingestion-consumer + build-scheduled-runs: name: Build scheduled-runs image needs: test-backend diff --git a/backend/Dockerfile.kb-migration b/backend/Dockerfile.kb-migration new file mode 100644 index 00000000..587001b2 --- /dev/null +++ b/backend/Dockerfile.kb-migration @@ -0,0 +1,78 @@ +# kb-migration Lambda image — the four managed-knowledge-base Lambdas. +# +# ONE image, FOUR Lambda functions. The CDK construct +# (infrastructure/lib/constructs/managed-kb/kb-migration-construct.ts) points +# all four at this image and selects a handler per function through +# `ImageConfig.Command`. The command override is function *configuration*, so +# the workflow's `update-function-code --image-uri` swaps code on all four +# without touching their handlers: +# +# dispatcher apis.app_api.kb_migration.dispatcher.lambda_handler +# worker apis.app_api.kb_migration.worker.lambda_handler +# reconciler apis.app_api.kb_migration.reconciler.lambda_handler +# ingestion consumer apis.app_api.kb_migration.ingestion_consumer.lambda_handler +# +# THIS IMAGE REPLACES A BOOTSTRAP STUB. +# `infrastructure/bootstrap-assets/kb-migration/` is what PlatformStack ships on +# first deploy: four no-op handlers that log and return. Until this image is +# pushed, an enrolled knowledge base sits in `shadow` while the dispatcher ticks +# into a stub — which is safe, because work keys are sparse and the first real +# dispatcher tick picks up everything that accumulated. The stub directory must +# stay byte-stable; see the warning in its Dockerfile. +# +# WHY THE COPY SURFACE IS THIS SMALL +# The whole import closure of the four handlers is 16 first-party modules. That +# is not luck: `kb_backend` is a separate package with an EMPTY `__init__.py` +# and stdlib-only module scope, specifically so this image does not have to +# carry `apis.shared.assistants` — whose `__init__` imports `rag_service`, which +# imports the embeddings stack at module scope, which blows the image-size +# budget. `backend/tests/architecture/test_kb_backend_boundary.py` enforces it. +# +# Keep this surface minimal but COMPLETE: it must cover the handlers' whole +# closure INCLUDING function-local imports, which still run on invocation. A +# module the closure reaches but this list omits either kills every cold start +# or — worse — trips an `except ImportError` branch and silently no-ops. +# `backend/tests/supply_chain/test_lambda_image_imports.py` enforces the closure. +# When adding a COPY here, also add the path to the kb-migration +# SOURCE_DIRS/MANIFESTS in scripts/build/build-one.sh so the content-hash tag +# notices the change — otherwise the image is rebuilt under an unchanged tag and +# the deploy is a silent no-op. +# +# Base image digest-pinned to the same digest as the other Lambda images +# (backend/Dockerfile.kb-sync, .rag-ingestion, .scheduled-runs); the +# supply-chain dockerfile-pinning test asserts they agree. + +FROM public.ecr.aws/lambda/python:3.12@sha256:745b0eb8a9787e9c4bfd4fc4cae942399a2225831c96394ae70c0c2a7c7c6168 + +# Dependencies (exact pins). The boto3 pin is what makes the managed knowledge +# base API reachable at all — the base image's bundled copy predates it. See the +# requirements file for the specifics. +COPY backend/src/apis/app_api/kb_migration/requirements.txt /tmp/requirements.txt +RUN pip install --no-cache-dir -r /tmp/requirements.txt + +# Application code — namespace-package layout mirrors backend/src. +# `apis/` and `apis/app_api/` have no `__init__.py` in the repo and rely on +# implicit namespace packages, exactly as the kb-sync image does. +COPY backend/src/apis/shared/__init__.py ${LAMBDA_TASK_ROOT}/apis/shared/__init__.py +COPY backend/src/apis/shared/timestamps.py ${LAMBDA_TASK_ROOT}/apis/shared/timestamps.py + +# observability/emf.py — the metrics helpers publish through it. Copied as the +# package so its `__init__` resolves. +COPY backend/src/apis/shared/observability/ ${LAMBDA_TASK_ROOT}/apis/shared/observability/ + +# kb_backend/ — the whole package. Ten of its modules are in the closure +# (records, protocol, provisioning, managed_backend, byte_cap, tombstones, +# idleness, metrics, tags, and the empty __init__); the remaining few +# (resolver, s3vectors_backend, dual_read, query_guard, resource_policy) are +# not reached from these handlers but ride along because they are small, pure +# Python, and copying the package whole means a future function-local import +# cannot silently fall outside the image. +COPY backend/src/apis/shared/kb_backend/ ${LAMBDA_TASK_ROOT}/apis/shared/kb_backend/ + +# The handlers themselves. +COPY backend/src/apis/app_api/kb_migration/ ${LAMBDA_TASK_ROOT}/apis/app_api/kb_migration/ + +# Default command: the dispatcher. The other three functions override CMD +# through their ImageConfig, so this default is only what an unconfigured +# function would run. +CMD ["apis.app_api.kb_migration.dispatcher.lambda_handler"] diff --git a/backend/src/apis/app_api/kb_migration/requirements.txt b/backend/src/apis/app_api/kb_migration/requirements.txt new file mode 100644 index 00000000..0f1a5cf3 --- /dev/null +++ b/backend/src/apis/app_api/kb_migration/requirements.txt @@ -0,0 +1,32 @@ +# kb-migration Lambda image dependencies (backend/Dockerfile.kb-migration). +# Exact pins per repo policy; versions match backend/uv.lock. +# +# ⚠️ THE boto3 PIN IS LOAD-BEARING, NOT HYGIENE. +# +# The Lambda Python base image bundles its own, older boto3. That bundled +# version's packaged service model has no `MANAGED` member in +# `CreateKnowledgeBase`'s `knowledgeBaseConfiguration.type` enum and no +# `managedKnowledgeBaseConfiguration` shape at all, so every provisioning call +# would fail with a ParamValidationError naming a parameter that looks correct +# in our source. Installing this pin *over* the bundled copy is what makes the +# managed knowledge base API reachable from a Lambda. +# +# Verified against the pinned version rather than assumed (spec task 15.1's +# static half): `type` enum is ['VECTOR','KENDRA','SQL','MANAGED'], +# `managedKnowledgeBaseConfiguration` carries the embedding pin and encryption +# members, `embeddingDataType` enum is ['FLOAT32','BINARY'], and all four +# document operations — Ingest/Get/List/DeleteKnowledgeBaseDocuments — exist. +# No `AWS_DATA_PATH` side-load is required, and none must be relied on. +boto3==1.43.68 +botocore==1.43.68 + +# Nothing else. The four handlers' whole import closure is 16 first-party +# modules plus these two, which is the point of `kb_backend` being its own +# package with an empty `__init__` and stdlib-only module scope — pulling +# `apis.shared.assistants` instead would drag the embeddings stack, and with it +# the image-size budget, into a Lambda that never needs either. +# Enforced by backend/tests/architecture/test_kb_backend_boundary.py. +# +# In particular this image has NO FastAPI and NO pydantic: KB_Record is a +# dataclass precisely so a size-constrained image need not carry validation +# machinery it would then have to justify. diff --git a/backend/tests/supply_chain/test_dockerfile_pinning.py b/backend/tests/supply_chain/test_dockerfile_pinning.py index 47034caf..11a3bb84 100644 --- a/backend/tests/supply_chain/test_dockerfile_pinning.py +++ b/backend/tests/supply_chain/test_dockerfile_pinning.py @@ -16,6 +16,7 @@ BACKEND_DIR / "Dockerfile.inference-api", BACKEND_DIR / "Dockerfile.rag-ingestion", BACKEND_DIR / "Dockerfile.kb-sync", + BACKEND_DIR / "Dockerfile.kb-migration", BACKEND_DIR / "Dockerfile.scheduled-runs", ] @@ -172,3 +173,96 @@ def test_dockerfile_apt_get_packages_have_version_pins(): f"Found {len(violations)} package(s) without version pins " f"(out of {total_packages} total):\n" + "\n".join(violations) ) + + +class TestTheKbMigrationBoto3PinIsLoadBearing: + """The kb-migration image's boto3 pin is not hygiene — it is the feature. + + `public.ecr.aws/lambda/python:3.12` at the digest every Lambda image here + pins bundles **boto3 1.40.4**, whose packaged `bedrock-agent` model offers + `type` enum ``['VECTOR', 'KENDRA', 'SQL']`` and has **no** + ``managedKnowledgeBaseConfiguration`` shape at all. Measured, not assumed:: + + docker run --rm --entrypoint python public.ecr.aws/lambda/python:3.12@sha256:745b... \\ + -c "import boto3; print(boto3.__version__)" # 1.40.4 + + So without the pin installed over the bundled copy, every + `CreateKnowledgeBase` call from these Lambdas fails with a + ParamValidationError naming a parameter that looks perfectly correct in our + source, and the managed knowledge base feature cannot work at all. + + Deleting or downgrading the pin therefore breaks the feature silently at + runtime rather than loudly at build time — nothing else in the repo would + notice. Hence this test. + """ + + REQUIREMENTS = BACKEND_DIR / "src/apis/app_api/kb_migration/requirements.txt" + + #: The floor is the version whose packaged model first carries the managed + #: shapes, established by the spec's evaluation. Pinned as a literal because + #: it is a property of AWS's published service model, not a knob: comparing + #: it against the repo's own pin would be a tautology that follows the pin + #: wherever it moves. + MINIMUM_BOTO3 = (1, 43, 68) + + def test_boto3_is_pinned_exactly(self): + body = self.REQUIREMENTS.read_text(encoding="utf-8") + assert re.search(r"^boto3==", body, re.MULTILINE), ( + "kb-migration requirements.txt does not pin boto3 exactly; the image " + "would fall back to the base image's 1.40.4, whose service model has " + "no MANAGED knowledge base support" + ) + + def test_the_pin_is_at_or_above_the_managed_kb_floor(self): + body = self.REQUIREMENTS.read_text(encoding="utf-8") + m = re.search(r"^boto3==(\d+)\.(\d+)\.(\d+)", body, re.MULTILINE) + assert m, "could not parse the boto3 pin" + pinned = tuple(int(g) for g in m.groups()) + assert pinned >= self.MINIMUM_BOTO3, ( + f"boto3=={'.'.join(map(str, pinned))} predates managed knowledge base " + f"support (need >= {'.'.join(map(str, self.MINIMUM_BOTO3))})" + ) + + def test_the_installed_model_actually_carries_the_managed_shapes(self): + """Asserts the capability, not just the number. + + A version string is evidence only if the shapes are really there. This + reads the *packaged* model with no ``AWS_DATA_PATH`` side-load, which is + exactly what the Lambda will do. + """ + import botocore.session + + model = botocore.session.get_session().get_service_model("bedrock-agent") + config = model.operation_model("CreateKnowledgeBase").input_shape.members[ + "knowledgeBaseConfiguration" + ] + + assert "MANAGED" in (config.members["type"].enum or []), ( + "the installed botocore's bedrock-agent model has no MANAGED knowledge " + "base type; the kb-migration image cannot provision" + ) + assert "managedKnowledgeBaseConfiguration" in config.members + + managed = config.members["managedKnowledgeBaseConfiguration"] + # Requirement 8.5's embedding pin has to be expressible, which is why + # `managedKnowledgeBaseConfiguration={}` was wrong despite the shape + # having no required members. + assert "embeddingModelArn" in managed.members + assert "embeddingModelConfiguration" in managed.members + + # Uppercase. The enum rejects `float32`, which cost this feature a defect. + data_type = managed.members["embeddingModelConfiguration"].members[ + "bedrockEmbeddingModelConfiguration" + ].members["embeddingDataType"] + assert "FLOAT32" in (data_type.enum or []) + + for operation in ( + "IngestKnowledgeBaseDocuments", + "GetKnowledgeBaseDocuments", + "ListKnowledgeBaseDocuments", + "DeleteKnowledgeBaseDocuments", + ): + assert operation in model.operation_names, ( + f"{operation} is absent from the installed model; direct ingestion " + f"is how this feature adds documents without an S3 data source" + ) diff --git a/backend/tests/supply_chain/test_lambda_image_imports.py b/backend/tests/supply_chain/test_lambda_image_imports.py index 92e1d9e3..30a61e7a 100644 --- a/backend/tests/supply_chain/test_lambda_image_imports.py +++ b/backend/tests/supply_chain/test_lambda_image_imports.py @@ -60,6 +60,19 @@ ], [], ), + # Four functions, one image, handler selected per function by + # ImageConfig.Command — so all four entrypoints must be walked, not just + # the Dockerfile's default CMD. + "kb-migration": ( + "backend/Dockerfile.kb-migration", + [ + "apis/app_api/kb_migration/dispatcher.py", + "apis/app_api/kb_migration/worker.py", + "apis/app_api/kb_migration/reconciler.py", + "apis/app_api/kb_migration/ingestion_consumer.py", + ], + [], + ), "scheduled-runs": ( "backend/Dockerfile.scheduled-runs", [ diff --git a/scripts/build/build-one.sh b/scripts/build/build-one.sh index 46da07d0..b75a1896 100755 --- a/scripts/build/build-one.sh +++ b/scripts/build/build-one.sh @@ -135,9 +135,38 @@ case "$SERVICE" in PLATFORM="linux/arm64" SSM_KEY="/${CDK_PROJECT_PREFIX}/kb-sync/image-tag" ;; + kb-migration) + DOCKERFILE="backend/Dockerfile.kb-migration" + # ONE image, FOUR Lambdas (dispatcher + worker + reconciler + + # ingestion consumer, selected per function by ImageConfig + # command overrides). Keep SOURCE_DIRS in lockstep with the + # Dockerfile's COPY list — a path copied but not hashed here + # would ship stale code under an unchanged content-hash tag, + # making the deploy a silent no-op. + # + # Deliberately short: the handlers' whole import closure is 16 + # first-party modules, because kb_backend is its own package with + # stdlib-only module scope. If this list ever needs + # apis/shared/assistants, something has broken the boundary that + # test_kb_backend_boundary.py guards. + SOURCE_DIRS=( + "backend/src/apis/app_api/kb_migration" + "backend/src/apis/shared/kb_backend" + "backend/src/apis/shared/observability" + ) + # Single-file COPYs hashed as manifests (same as kb-sync); + # kb_migration/requirements.txt lives inside the first source dir. + MANIFESTS=( + "backend/src/apis/shared/__init__.py" + "backend/src/apis/shared/timestamps.py" + ) + # All four kb-migration Lambdas are arm64 (see the managed-kb + # CDK construct). + PLATFORM="linux/arm64" + SSM_KEY="/${CDK_PROJECT_PREFIX}/kb-migration/image-tag" + ;; scheduled-runs) - DOCKERFILE="backend/Dockerfile.scheduled-runs" - # One image, two Lambdas (dispatcher + worker via ImageConfig + DOCKERFILE="backend/Dockerfile.scheduled-runs" # One image, two Lambdas (dispatcher + worker via ImageConfig # command overrides). Keep SOURCE_DIRS in lockstep with the # Dockerfile's COPY list — a path copied but not hashed here # would ship stale code under an unchanged content-hash tag. diff --git a/scripts/build/deploy-image-lambda-one.sh b/scripts/build/deploy-image-lambda-one.sh index a5ea1767..d99bcb2d 100755 --- a/scripts/build/deploy-image-lambda-one.sh +++ b/scripts/build/deploy-image-lambda-one.sh @@ -9,7 +9,9 @@ # # Where is one of: # rag-ingestion | kb-sync-dispatcher | kb-sync-worker | -# scheduled-runs-dispatcher | scheduled-runs-worker +# scheduled-runs-dispatcher | scheduled-runs-worker | +# kb-migration-dispatcher | kb-migration-worker | +# kb-migration-reconciler | kb-migration-ingestion-consumer # # kb-sync-dispatcher/kb-sync-worker (and scheduled-runs-dispatcher/ # scheduled-runs-worker) are pairs of Lambda functions sharing a single @@ -25,7 +27,7 @@ set -euo pipefail if [[ $# -ne 1 ]]; then echo "Usage: $0 " >&2 - echo " service: rag-ingestion | kb-sync-dispatcher | kb-sync-worker | scheduled-runs-dispatcher | scheduled-runs-worker" >&2 + echo " service: rag-ingestion | kb-sync-dispatcher | kb-sync-worker | scheduled-runs-dispatcher | scheduled-runs-worker | kb-migration-dispatcher | kb-migration-worker | kb-migration-reconciler | kb-migration-ingestion-consumer" >&2 exit 1 fi @@ -56,6 +58,26 @@ case "$SERVICE" in IMAGE_URI_SSM="/${CDK_PROJECT_PREFIX}/kb-sync/image-tag" ECR_REPO_URI="${REGISTRY}/${CDK_PROJECT_PREFIX}-kb-sync" ;; + kb-migration-dispatcher) + FUNCTION_NAME_SSM="/${CDK_PROJECT_PREFIX}/kb-migration/dispatcher-function-name" + IMAGE_URI_SSM="/${CDK_PROJECT_PREFIX}/kb-migration/image-tag" + ECR_REPO_URI="${REGISTRY}/${CDK_PROJECT_PREFIX}-kb-migration" + ;; + kb-migration-worker) + FUNCTION_NAME_SSM="/${CDK_PROJECT_PREFIX}/kb-migration/worker-function-name" + IMAGE_URI_SSM="/${CDK_PROJECT_PREFIX}/kb-migration/image-tag" + ECR_REPO_URI="${REGISTRY}/${CDK_PROJECT_PREFIX}-kb-migration" + ;; + kb-migration-reconciler) + FUNCTION_NAME_SSM="/${CDK_PROJECT_PREFIX}/kb-migration/reconciler-function-name" + IMAGE_URI_SSM="/${CDK_PROJECT_PREFIX}/kb-migration/image-tag" + ECR_REPO_URI="${REGISTRY}/${CDK_PROJECT_PREFIX}-kb-migration" + ;; + kb-migration-ingestion-consumer) + FUNCTION_NAME_SSM="/${CDK_PROJECT_PREFIX}/kb-migration/ingestion-consumer-function-name" + IMAGE_URI_SSM="/${CDK_PROJECT_PREFIX}/kb-migration/image-tag" + ECR_REPO_URI="${REGISTRY}/${CDK_PROJECT_PREFIX}-kb-migration" + ;; scheduled-runs-dispatcher) FUNCTION_NAME_SSM="/${CDK_PROJECT_PREFIX}/scheduled-runs/dispatcher-function-name" IMAGE_URI_SSM="/${CDK_PROJECT_PREFIX}/scheduled-runs/image-tag" @@ -68,7 +90,7 @@ case "$SERVICE" in ;; *) echo "Unknown service: $SERVICE" >&2 - echo "Expected one of: rag-ingestion | kb-sync-dispatcher | kb-sync-worker | scheduled-runs-dispatcher | scheduled-runs-worker" >&2 + echo "Expected one of: rag-ingestion | kb-sync-dispatcher | kb-sync-worker | scheduled-runs-dispatcher | scheduled-runs-worker | kb-migration-dispatcher | kb-migration-worker | kb-migration-reconciler | kb-migration-ingestion-consumer" >&2 exit 1 ;; esac