Commit fe3c8283 authored by George Papathanail's avatar George Papathanail
Browse files

Merge branch 'refactor/deployments' into 'develop'

feat: implement multi-zone edge application deployments (/deployments)

See merge request !26
parents aa2cd89f b1133f81
Loading
Loading
Loading
Loading
Loading
+17 −36
Changes for Dockerfile: 17 added lines, 36 removed lines.
Original line number Diff line number Diff line
FROM ghcr.io/astral-sh/uv:0.12.19 AS uv

FROM python:3.12-slim AS builder

COPY --from=uv /uv /bin/uv

WORKDIR /app

# DB build arg: base | postgres | mongo
ARG DB=base
# Install exactly what uv.lock pins (same as CI), so a new upstream release
# can't change the image without a lockfile change. uv verifies every package
# against the hashes in uv.lock, so the trusted hosts only relax TLS checks for
# networks that intercept HTTPS, not package integrity.
ENV UV_COMPILE_BYTECODE=1 \
    UV_LINK_MODE=copy \
    UV_PYTHON_DOWNLOADS=never \
    UV_HTTP_TIMEOUT=120 \
    UV_INSECURE_HOST="pypi.org files.pythonhosted.org"

ENV PIP_DEFAULT_TIMEOUT=120
# Dependencies first, so this layer is cached until uv.lock changes.
COPY pyproject.toml uv.lock ./
RUN uv sync --locked --no-install-project

COPY pyproject.toml ./
COPY README.md ./
COPY src/ ./src/

RUN python -m venv .venv && \
    .venv/bin/pip install \
      --no-cache-dir \
      --default-timeout=120 \
      --trusted-host pypi.org \
      --trusted-host files.pythonhosted.org \
      --upgrade pip setuptools wheel && \
    if [ "$DB" = "postgres" ]; then \
        .venv/bin/pip install \
          --no-cache-dir \
          --default-timeout=120 \
          --trusted-host pypi.org \
          --trusted-host files.pythonhosted.org \
          ".[postgres]"; \
    elif [ "$DB" = "mongo" ]; then \
        .venv/bin/pip install \
          --no-cache-dir \
          --default-timeout=120 \
          --trusted-host pypi.org \
          --trusted-host files.pythonhosted.org \
          ".[mongo]"; \
    else \
        .venv/bin/pip install \
          --no-cache-dir \
          --default-timeout=120 \
          --trusted-host pypi.org \
          --trusted-host files.pythonhosted.org \
          "."; \
    fi
RUN uv sync --locked --no-editable

FROM python:3.12-slim AS runtime

WORKDIR /app

COPY --from=builder /app/.venv /app/.venv
COPY src/ ./src/

ENV PATH="/app/.venv/bin:$PATH"
ENV PYTHONPATH="/app"
ENV PYTHONUNBUFFERED=1
ENV PYTHONDONTWRITEBYTECODE=1

+9 −12
Changes for README.md: 9 added lines, 12 removed lines.
Original line number Diff line number Diff line
@@ -85,25 +85,22 @@ via the `conformance` marker.
#### Vendored spec pins (update this list when bumping a pin)

**Edge Application Management** — [camaraproject/EdgeApplicationManagement](https://github.com/camaraproject/EdgeApplicationManagement)
at commit `0a64e153e8b860f9a8b7c045fe2e840e7278dada` (API version `wip`, served
under `{apiRoot}/edge-application-management/vwip`), vendored in
`src/open_exposure_gateway/api/camara/edge_application_management/vwip/`:
API version `0.1.0-alpha.1` (served under
`{apiRoot}/edge-application-management/v0.1alpha1`), vendored in
`src/open_exposure_gateway/api/camara/edge_application_management/v0_1_0_alpha_1/`:

| Vendored file | Upstream file |
|---|---|
| `API_definitions/edge-application-management.yaml` | `code/API_definitions/edge-application-management.yaml` |
| `common/CAMARA_common.yaml` | `code/common/CAMARA_common.yaml` |
| `API_definitions/edge-application-management-0.1.0-alpha.1.yaml` | `code/API_definitions/edge-application-management.yaml` |

To refresh or bump the pin, fetch **both files from the same commit** (a
mismatched pair breaks the spec's relative `$ref`s or silently changes its
meaning), from the pin folder:
The `0.1.0-alpha.1` bundle is self-contained (no external `$ref`s), so it is a
single vendored file — the previous `common/` pair is no longer needed. To
refresh or bump the pin, fetch the release's bundled spec from the pin folder:

```sh
SHA=0a64e153e8b860f9a8b7c045fe2e840e7278dada
curl -sL -o API_definitions/edge-application-management.yaml \
SHA=<upstream commit for the target release>
curl -sL -o API_definitions/edge-application-management-0.1.0-alpha.1.yaml \
  "https://raw.githubusercontent.com/camaraproject/EdgeApplicationManagement/$SHA/code/API_definitions/edge-application-management.yaml"
curl -sL -o common/CAMARA_common.yaml \
  "https://raw.githubusercontent.com/camaraproject/EdgeApplicationManagement/$SHA/code/common/CAMARA_common.yaml"
```

### Running with Docker
+30 −0
Changes for src/open_exposure_gateway/adapters/database/mappers.py: 30 added lines, 0 removed lines.
Original line number Diff line number Diff line
from uuid import UUID

from open_exposure_gateway.adapters.database.sql import (
    AppDeploymentRow,
    AppInstanceRow,
    AppRegistrationRow,
    CallbackDeliveryRow,
@@ -7,6 +10,7 @@ from open_exposure_gateway.adapters.database.sql import (
    QodSessionRow,
)
from open_exposure_gateway.domain.models import (
    AppDeployment,
    AppInstance,
    AppRegistration,
    CallbackDelivery,
@@ -110,6 +114,32 @@ class AppInstanceMapper:
        )


class AppDeploymentMapper:
    @staticmethod
    def to_domain(row: AppDeploymentRow) -> AppDeployment:
        return AppDeployment(
            app_deployment_id=row.app_deployment_id,
            operation_id=row.operation_id,
            app_registration_id=row.app_registration_id,
            app_deployment_name=row.app_deployment_name,
            edge_cloud_zones=[UUID(zone_id) for zone_id in row.edge_cloud_zones],
            state=row.state,
            created_at=row.created_at,
            updated_at=row.updated_at,
        )

    @staticmethod
    def to_row(domain: AppDeployment) -> AppDeploymentRow:
        return AppDeploymentRow(
            app_deployment_id=domain.app_deployment_id,
            operation_id=domain.operation_id,
            app_registration_id=domain.app_registration_id,
            app_deployment_name=domain.app_deployment_name,
            edge_cloud_zones=[str(zone_id) for zone_id in domain.edge_cloud_zones],
            state=domain.state,
        )


class QodSessionMapper:
    @staticmethod
    def to_domain(row: QodSessionRow) -> QodSession:
+10 −0
Changes for src/open_exposure_gateway/adapters/database/repos/app_instances.py: 10 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -25,6 +25,16 @@ class SqlAppInstanceRepository(AppInstanceRepository):
        row = await self._session.scalar(stmt)
        return AppInstanceMapper.to_domain(row) if row is not None else None

    async def list_by_operation_id(self, operation_id: UUID) -> list[AppInstance]:
        stmt = select(AppInstanceRow).where(AppInstanceRow.operation_id == operation_id)
        rows = await self._session.scalars(stmt)
        return [AppInstanceMapper.to_domain(row) for row in rows]

    async def list_by_app_deployment_id(self, app_deployment_id: UUID) -> list[AppInstance]:
        stmt = select(AppInstanceRow).where(AppInstanceRow.app_deployment_id == app_deployment_id)
        rows = await self._session.scalars(stmt)
        return [AppInstanceMapper.to_domain(row) for row in rows]

    async def exists_for_app_registration(self, app_registration_id: UUID) -> bool:
        stmt = select(AppInstanceRow.app_instance_id).where(
            AppInstanceRow.app_registration_id == app_registration_id,
+46 −0
Changes for src/open_exposure_gateway/adapters/database/repos/deployments.py: 46 added lines, 0 removed lines.
Original line number Diff line number Diff line
from uuid import UUID

from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession

from open_exposure_gateway.adapters.database.mappers import AppDeploymentMapper
from open_exposure_gateway.adapters.database.sql import AppDeploymentRow
from open_exposure_gateway.domain.models import AppDeployment
from open_exposure_gateway.ports.database.deployments import AppDeploymentRepository


class SqlAppDeploymentRepository(AppDeploymentRepository):
    def __init__(self, session: AsyncSession) -> None:
        self._session = session

    async def get_by_id(self, app_deployment_id: UUID) -> AppDeployment | None:
        stmt = select(AppDeploymentRow).where(
            AppDeploymentRow.app_deployment_id == app_deployment_id
        )
        row = await self._session.scalar(stmt)
        return AppDeploymentMapper.to_domain(row) if row is not None else None

    async def get_by_operation_id(self, operation_id: UUID) -> AppDeployment | None:
        stmt = select(AppDeploymentRow).where(AppDeploymentRow.operation_id == operation_id)
        row = await self._session.scalar(stmt)
        return AppDeploymentMapper.to_domain(row) if row is not None else None

    async def list_by_app_registration_id(self, app_registration_id: UUID) -> list[AppDeployment]:
        stmt = select(AppDeploymentRow).where(
            AppDeploymentRow.app_registration_id == app_registration_id
        )
        rows = await self._session.scalars(stmt)
        return [AppDeploymentMapper.to_domain(row) for row in rows]

    async def list_all(self) -> list[AppDeployment]:
        stmt = select(AppDeploymentRow)
        rows = await self._session.scalars(stmt)
        return [AppDeploymentMapper.to_domain(row) for row in rows]

    async def save(self, app_deployment: AppDeployment) -> AppDeployment:
        merged = await self._session.merge(AppDeploymentMapper.to_row(app_deployment))
        await self._session.flush()
        saved = await self.get_by_id(merged.app_deployment_id)
        if saved is None:
            raise RuntimeError("Saved app deployment could not be reloaded")
        return saved
Loading