mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
feat(ara): AraFirstRunTriggerBody + ara_first_run lambda skeleton (#1130)
Stage-2 entry point for the First Run use case. Adds the `ara_first_run` Lambda package mirroring the `postcode_splitter` template, its typed trigger contract, and a stub `FirstRunPipeline`. - `AraFirstRunTriggerBody`: thin command of five fields — `task_id`, `sub_task_id` (UUID, lifecycle), `portfolio_id`, `property_ids`, `scenario_ids` (int business IDs). No `model_config` override, so Pydantic's default `extra="ignore"` lets the FastAPI backend add fields without breaking deployed lambdas. UPRNs / Scenario defs are deliberately off the event — read from source-of-truth tables. - Thin `handler.py`: validate-and-delegate only, via a named `dispatch_first_run` seam (testable without the Lambda runtime). Subtask status (in-progress/complete/failed) + CloudWatch log URL come for free from the existing `@subtask_handler()` decorator. - `FirstRunPipeline` (orchestration/) stub: `run(command)` receives the validated command. Declares a structural `FirstRunCommand` Protocol (the three business fields) that `AraFirstRunTriggerBody` satisfies, so orchestration needs no application-layer import — rhymes with the `EpcFetcher`/`SolarFetcher` Protocols on IngestionOrchestrator (ADR-0011). Full Ingestion→Baseline→Modelling composition lands in #1136. - Dockerfile / requirements.txt / local_handler/ mirror postcode_splitter. TDD: 7 new tests (trigger-body validation incl. forward-compat + id-types, pipeline seam, handler delegation). pyright strict clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
1696cccba6
commit
75fbba60fc
15 changed files with 382 additions and 0 deletions
34
applications/ara_first_run/Dockerfile
Normal file
34
applications/ara_first_run/Dockerfile
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
FROM public.ecr.aws/lambda/python:3.11
|
||||
|
||||
# Postgres host/port/database are baked into the image at build time from
|
||||
# the deploy workflow's --build-arg values (GitHub Actions DEV_DB_* secrets),
|
||||
# mirroring applications/postcode_splitter/Dockerfile. They map onto the
|
||||
# POSTGRES_* names PostgresConfig.from_env reads. Username/password are NOT
|
||||
# baked in -- Terraform injects those as Lambda env vars from Secrets Manager.
|
||||
ARG DEV_DB_HOST
|
||||
ARG DEV_DB_PORT
|
||||
ARG DEV_DB_NAME
|
||||
|
||||
ENV POSTGRES_HOST=${DEV_DB_HOST}
|
||||
ENV POSTGRES_PORT=${DEV_DB_PORT}
|
||||
ENV POSTGRES_DATABASE=${DEV_DB_NAME}
|
||||
|
||||
WORKDIR /var/task
|
||||
|
||||
COPY applications/ara_first_run/requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Copy the layered source the handler imports from. DDD-shaped packages only —
|
||||
# no pandas, no legacy backend/.
|
||||
COPY domain/ domain/
|
||||
COPY infrastructure/ infrastructure/
|
||||
COPY orchestration/ orchestration/
|
||||
COPY repositories/ repositories/
|
||||
COPY utilities/ utilities/
|
||||
COPY applications/ applications/
|
||||
|
||||
# Place the handler at the Lambda task root so the runtime can resolve
|
||||
# ``main.handler`` without an extra package prefix.
|
||||
COPY applications/ara_first_run/handler.py /var/task/main.py
|
||||
|
||||
CMD ["main.handler"]
|
||||
0
applications/ara_first_run/__init__.py
Normal file
0
applications/ara_first_run/__init__.py
Normal file
25
applications/ara_first_run/ara_first_run_trigger_body.py
Normal file
25
applications/ara_first_run/ara_first_run_trigger_body.py
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class AraFirstRunTriggerBody(BaseModel):
|
||||
"""The SQS event the ``ara_first_run`` Lambda is triggered with.
|
||||
|
||||
A thin command. ``task_id``/``sub_task_id`` drive the SubTask lifecycle (the
|
||||
``@subtask_handler`` decorator reads them); the three business fields are what
|
||||
the pipeline threads downstream. UPRNs and Scenario definitions are
|
||||
deliberately absent — they are read from their source-of-truth tables, not
|
||||
carried on the event (issue #1130).
|
||||
|
||||
No ``model_config`` override: Pydantic's default ``extra="ignore"`` lets the
|
||||
FastAPI backend add fields to the payload without breaking deployed lambdas.
|
||||
"""
|
||||
|
||||
task_id: UUID
|
||||
sub_task_id: UUID
|
||||
portfolio_id: int
|
||||
property_ids: list[int]
|
||||
scenario_ids: list[int]
|
||||
34
applications/ara_first_run/handler.py
Normal file
34
applications/ara_first_run/handler.py
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Protocol
|
||||
|
||||
from applications.ara_first_run.ara_first_run_trigger_body import (
|
||||
AraFirstRunTriggerBody,
|
||||
)
|
||||
from orchestration.first_run_pipeline import FirstRunPipeline
|
||||
from orchestration.task_orchestrator import TaskOrchestrator
|
||||
from utilities.aws_lambda.subtask_handler import subtask_handler
|
||||
|
||||
|
||||
class _RunsFirstRun(Protocol):
|
||||
"""The slice of FirstRunPipeline the handler delegates to."""
|
||||
|
||||
def run(self, command: AraFirstRunTriggerBody) -> None: ...
|
||||
|
||||
|
||||
def dispatch_first_run(body: dict[str, Any], *, pipeline: _RunsFirstRun) -> None:
|
||||
"""Validate the raw event body and hand the command to the pipeline.
|
||||
|
||||
The handler's entire job — kept as a named seam so it is exercised without
|
||||
the Lambda runtime. No business logic lives here: validate, then delegate
|
||||
(issue #1130).
|
||||
"""
|
||||
trigger = AraFirstRunTriggerBody.model_validate(body)
|
||||
pipeline.run(trigger)
|
||||
|
||||
|
||||
@subtask_handler()
|
||||
def handler(
|
||||
body: dict[str, Any], context: Any, task_orchestrator: TaskOrchestrator
|
||||
) -> None:
|
||||
dispatch_first_run(body, pipeline=FirstRunPipeline())
|
||||
28
applications/ara_first_run/local_handler/.env.local.example
Normal file
28
applications/ara_first_run/local_handler/.env.local.example
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
# Local-test environment for the ara_first_run Lambda.
|
||||
#
|
||||
# cp .env.local.example .env.local then fill in the values below.
|
||||
#
|
||||
# .env.local is gitignored. The container hits a REAL Postgres (the SubTask
|
||||
# lifecycle store), so every value here points at infrastructure that exists.
|
||||
#
|
||||
# NOTE: the DDD code uses different env var names than the repo root .env. The
|
||||
# mapping (root .env name -> var here) is given per section. Keep comments on
|
||||
# their own lines — docker-compose's env_file parser folds a trailing "# ..."
|
||||
# into the value.
|
||||
|
||||
# --- Postgres (utilities/aws_lambda/default_orchestrator -> PostgresConfig.from_env) ---
|
||||
# POSTGRES_HOST <- DB_HOST, PORT <- DB_PORT, USERNAME <- DB_USERNAME,
|
||||
# PASSWORD <- DB_PASSWORD, DATABASE <- DB_NAME.
|
||||
POSTGRES_HOST=
|
||||
POSTGRES_PORT=5432
|
||||
POSTGRES_USERNAME=
|
||||
POSTGRES_PASSWORD=
|
||||
POSTGRES_DATABASE=
|
||||
# POSTGRES_DRIVER=psycopg2 (optional; defaults to psycopg2)
|
||||
|
||||
# --- AWS credentials for boto3 (used by later slices; the SubTask lifecycle
|
||||
# CloudWatch URL is read from the Lambda runtime's own AWS_* env in prod) ---
|
||||
AWS_ACCESS_KEY_ID=
|
||||
AWS_SECRET_ACCESS_KEY=
|
||||
AWS_DEFAULT_REGION=eu-west-2
|
||||
# AWS_SESSION_TOKEN= (only if using temporary/SSO credentials)
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
services:
|
||||
ara-first-run:
|
||||
build:
|
||||
context: ../../../
|
||||
dockerfile: applications/ara_first_run/Dockerfile
|
||||
ports:
|
||||
- "9002:8080"
|
||||
env_file:
|
||||
- .env.local
|
||||
30
applications/ara_first_run/local_handler/invoke_local_lambda.py
Executable file
30
applications/ara_first_run/local_handler/invoke_local_lambda.py
Executable file
|
|
@ -0,0 +1,30 @@
|
|||
#!/usr/bin/env python3
|
||||
import json
|
||||
import requests
|
||||
|
||||
HOST = "localhost"
|
||||
PORT = "9002"
|
||||
|
||||
LAMBDA_URL = f"http://{HOST}:{PORT}/2015-03-31/functions/function/invocations"
|
||||
|
||||
payload = {
|
||||
"Records": [
|
||||
{
|
||||
"body": json.dumps(
|
||||
{
|
||||
"task_id": "e295d89b-a7c5-4a9a-8b4e-b405fab1f298",
|
||||
"sub_task_id": "f4a9944f-41f0-4a33-8669-5016ec574068",
|
||||
"portfolio_id": 42,
|
||||
"property_ids": [101, 102, 103],
|
||||
"scenario_ids": [7, 8],
|
||||
}
|
||||
)
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
response = requests.post(LAMBDA_URL, json=payload)
|
||||
|
||||
print("Status code:", response.status_code)
|
||||
print("Response:")
|
||||
print(response.text)
|
||||
12
applications/ara_first_run/local_handler/run_local.sh
Executable file
12
applications/ara_first_run/local_handler/run_local.sh
Executable file
|
|
@ -0,0 +1,12 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
if [ ! -f .env.local ]; then
|
||||
cp .env.local.example .env.local
|
||||
echo "Created .env.local from the template — fill it in, then re-run." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
docker compose build --no-cache
|
||||
docker compose up --force-recreate
|
||||
4
applications/ara_first_run/requirements.txt
Normal file
4
applications/ara_first_run/requirements.txt
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
boto3
|
||||
pydantic
|
||||
sqlmodel
|
||||
psycopg2-binary
|
||||
36
orchestration/first_run_pipeline.py
Normal file
36
orchestration/first_run_pipeline.py
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import Protocol
|
||||
|
||||
|
||||
class FirstRunCommand(Protocol):
|
||||
"""The slice of the trigger the pipeline threads downstream.
|
||||
|
||||
Only the business fields — UPRNs and Scenario definitions are read from
|
||||
their source-of-truth tables, not carried here. ``task_id``/``sub_task_id``
|
||||
are deliberately absent: the SubTask lifecycle is the decorator's concern,
|
||||
not the pipeline's. ``AraFirstRunTriggerBody`` satisfies this structurally,
|
||||
so ``orchestration`` need not import the application-layer event type.
|
||||
"""
|
||||
|
||||
@property
|
||||
def portfolio_id(self) -> int: ...
|
||||
|
||||
@property
|
||||
def property_ids(self) -> list[int]: ...
|
||||
|
||||
@property
|
||||
def scenario_ids(self) -> list[int]: ...
|
||||
|
||||
|
||||
class FirstRunPipeline:
|
||||
"""Composes the First Run stages end-to-end (Ingestion -> Baseline ->
|
||||
Modelling), threading only ``property_ids`` between them through repos
|
||||
(ADR-0011).
|
||||
|
||||
Stub at this stage (#1130): ``run`` simply receives the validated command.
|
||||
The real three-stage composition lands in #1136.
|
||||
"""
|
||||
|
||||
def run(self, command: FirstRunCommand) -> None:
|
||||
return None
|
||||
0
tests/applications/__init__.py
Normal file
0
tests/applications/__init__.py
Normal file
0
tests/applications/ara_first_run/__init__.py
Normal file
0
tests/applications/ara_first_run/__init__.py
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from applications.ara_first_run.ara_first_run_trigger_body import (
|
||||
AraFirstRunTriggerBody,
|
||||
)
|
||||
|
||||
|
||||
def test_validates_well_formed_body_into_typed_fields() -> None:
|
||||
# Arrange
|
||||
body = {
|
||||
"task_id": "e295d89b-a7c5-4a9a-8b4e-b405fab1f298",
|
||||
"sub_task_id": "f4a9944f-41f0-4a33-8669-5016ec574068",
|
||||
"portfolio_id": 42,
|
||||
"property_ids": [101, 102, 103],
|
||||
"scenario_ids": [7, 8],
|
||||
}
|
||||
|
||||
# Act
|
||||
trigger = AraFirstRunTriggerBody.model_validate(body)
|
||||
|
||||
# Assert
|
||||
assert trigger.task_id == UUID("e295d89b-a7c5-4a9a-8b4e-b405fab1f298")
|
||||
assert trigger.sub_task_id == UUID("f4a9944f-41f0-4a33-8669-5016ec574068")
|
||||
assert trigger.portfolio_id == 42
|
||||
assert trigger.property_ids == [101, 102, 103]
|
||||
assert trigger.scenario_ids == [7, 8]
|
||||
|
||||
|
||||
def test_ignores_unknown_extra_fields() -> None:
|
||||
# Arrange — the FastAPI backend may add fields the deployed lambda predates.
|
||||
body = {
|
||||
"task_id": "e295d89b-a7c5-4a9a-8b4e-b405fab1f298",
|
||||
"sub_task_id": "f4a9944f-41f0-4a33-8669-5016ec574068",
|
||||
"portfolio_id": 42,
|
||||
"property_ids": [101],
|
||||
"scenario_ids": [7],
|
||||
"a_field_added_later_by_the_backend": "ignore me",
|
||||
}
|
||||
|
||||
# Act
|
||||
trigger = AraFirstRunTriggerBody.model_validate(body)
|
||||
|
||||
# Assert — the unknown field is dropped, not retained or rejected.
|
||||
assert not hasattr(trigger, "a_field_added_later_by_the_backend")
|
||||
assert trigger.portfolio_id == 42
|
||||
|
||||
|
||||
def test_rejects_body_missing_a_required_field() -> None:
|
||||
# Arrange — scenario_ids omitted.
|
||||
body = {
|
||||
"task_id": "e295d89b-a7c5-4a9a-8b4e-b405fab1f298",
|
||||
"sub_task_id": "f4a9944f-41f0-4a33-8669-5016ec574068",
|
||||
"portfolio_id": 42,
|
||||
"property_ids": [101],
|
||||
}
|
||||
|
||||
# Act / Assert
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
AraFirstRunTriggerBody.model_validate(body)
|
||||
assert "scenario_ids" in str(exc_info.value)
|
||||
|
||||
|
||||
def test_rejects_non_uuid_task_id() -> None:
|
||||
# Arrange
|
||||
body = {
|
||||
"task_id": "not-a-uuid",
|
||||
"sub_task_id": "f4a9944f-41f0-4a33-8669-5016ec574068",
|
||||
"portfolio_id": 42,
|
||||
"property_ids": [101],
|
||||
"scenario_ids": [7],
|
||||
}
|
||||
|
||||
# Act / Assert
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
AraFirstRunTriggerBody.model_validate(body)
|
||||
assert "task_id" in str(exc_info.value)
|
||||
|
||||
|
||||
def test_rejects_non_int_portfolio_id() -> None:
|
||||
# Arrange — business IDs are integers, not strings.
|
||||
body = {
|
||||
"task_id": "e295d89b-a7c5-4a9a-8b4e-b405fab1f298",
|
||||
"sub_task_id": "f4a9944f-41f0-4a33-8669-5016ec574068",
|
||||
"portfolio_id": "not-an-int",
|
||||
"property_ids": [101],
|
||||
"scenario_ids": [7],
|
||||
}
|
||||
|
||||
# Act / Assert
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
AraFirstRunTriggerBody.model_validate(body)
|
||||
assert "portfolio_id" in str(exc_info.value)
|
||||
44
tests/applications/ara_first_run/test_handler.py
Normal file
44
tests/applications/ara_first_run/test_handler.py
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
from uuid import UUID
|
||||
|
||||
from applications.ara_first_run.ara_first_run_trigger_body import (
|
||||
AraFirstRunTriggerBody,
|
||||
)
|
||||
from applications.ara_first_run.handler import dispatch_first_run
|
||||
from orchestration.first_run_pipeline import FirstRunCommand
|
||||
|
||||
|
||||
class _SpyPipeline:
|
||||
"""Records the command it is asked to run, instead of composing stages."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.received: Optional[FirstRunCommand] = None
|
||||
|
||||
def run(self, command: FirstRunCommand) -> None:
|
||||
self.received = command
|
||||
|
||||
|
||||
def test_validates_the_event_body_and_delegates_the_command_to_the_pipeline() -> None:
|
||||
# Arrange — a raw SQS body, as the decorator hands it to the handler.
|
||||
body = {
|
||||
"task_id": "e295d89b-a7c5-4a9a-8b4e-b405fab1f298",
|
||||
"sub_task_id": "f4a9944f-41f0-4a33-8669-5016ec574068",
|
||||
"portfolio_id": 42,
|
||||
"property_ids": [101, 102],
|
||||
"scenario_ids": [7],
|
||||
}
|
||||
pipeline = _SpyPipeline()
|
||||
|
||||
# Act
|
||||
dispatch_first_run(body, pipeline=pipeline)
|
||||
|
||||
# Assert — the raw body was validated into the typed trigger and handed
|
||||
# straight on, untouched.
|
||||
received = pipeline.received
|
||||
assert isinstance(received, AraFirstRunTriggerBody)
|
||||
assert received.task_id == UUID("e295d89b-a7c5-4a9a-8b4e-b405fab1f298")
|
||||
assert received.portfolio_id == 42
|
||||
assert received.property_ids == [101, 102]
|
||||
assert received.scenario_ids == [7]
|
||||
29
tests/orchestration/test_first_run_pipeline.py
Normal file
29
tests/orchestration/test_first_run_pipeline.py
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from orchestration.first_run_pipeline import FirstRunCommand, FirstRunPipeline
|
||||
|
||||
|
||||
@dataclass
|
||||
class _FakeCommand:
|
||||
"""A stand-in for AraFirstRunTriggerBody — structurally a FirstRunCommand."""
|
||||
|
||||
portfolio_id: int
|
||||
property_ids: list[int]
|
||||
scenario_ids: list[int]
|
||||
|
||||
|
||||
def test_run_accepts_the_validated_command() -> None:
|
||||
# Arrange
|
||||
command: FirstRunCommand = _FakeCommand(
|
||||
portfolio_id=42, property_ids=[101, 102], scenario_ids=[7]
|
||||
)
|
||||
pipeline = FirstRunPipeline()
|
||||
|
||||
# Act
|
||||
result = pipeline.run(command)
|
||||
|
||||
# Assert — the stub simply receives the command; full Ingestion -> Baseline
|
||||
# -> Modelling composition lands in #1136.
|
||||
assert result is None
|
||||
Loading…
Add table
Reference in a new issue