Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 83 additions & 0 deletions .github/workflows/backend.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
78 changes: 78 additions & 0 deletions backend/Dockerfile.kb-migration
Original file line number Diff line number Diff line change
@@ -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"]
32 changes: 32 additions & 0 deletions backend/src/apis/app_api/kb_migration/requirements.txt
Original file line number Diff line number Diff line change
@@ -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.
94 changes: 94 additions & 0 deletions backend/tests/supply_chain/test_dockerfile_pinning.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
]

Expand Down Expand Up @@ -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"
)
13 changes: 13 additions & 0 deletions backend/tests/supply_chain/test_lambda_image_imports.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
[
Expand Down
33 changes: 31 additions & 2 deletions scripts/build/build-one.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading