From 0e445de386942728e5d6556b3b5ca32d981ef38b Mon Sep 17 00:00:00 2001 From: vitor-aignosi Date: Tue, 25 Aug 2026 10:40:46 -0300 Subject: [PATCH] QTZPOC-20 QTZPOC-20: Implement .sientia model bundle import workflow Introduces a new Temporal workflow and activities to import .sientia model bundles. This enables: - Secure decryption and validation of bundles through a seven-gate process. - Provisioning imported models in MLflow and updating the MongoDB model listing. - Comprehensive status tracking in the public.experiment_run table with new IMPORT_SUCCESS/IMPORT_ERROR states. - Dedicated metrics, robust error handling, and configurable settings. - Includes updated e2e database fixtures and extensive unit/integration tests for bundle format, reader, and overall workflow. --- e2e/fixtures/experiment_run.sql | 67 ++ laborious/activities/activities.py | 45 +- laborious/activities/model_import.py | 1044 +++++++++++++++++ laborious/activities/model_import_errors.py | 348 ++++++ laborious/metrics.py | 39 + laborious/utils/bundle/__init__.py | 27 + laborious/utils/bundle/format.py | 216 ++++ laborious/utils/bundle/reader.py | 473 ++++++++ laborious/utils/bundle/steps.py | 55 + laborious/utils/connectors_config.py | 72 +- laborious/utils/import_password.py | 98 ++ laborious/worker/worker.py | 47 + laborious/workflows/import_model.py | 416 +++++++ requirements-local.txt | 14 +- requirements.txt | 21 +- tests/fixtures/bundle/README.md | 86 ++ .../bundle/golden_model_v3.metadata.json | 24 + tests/fixtures/bundle/golden_model_v3.sientia | Bin 0 -> 110767 bytes tests/fixtures/bundle/regenerate.py | 185 +++ tests/helpers/__init__.py | 0 tests/helpers/bundle_factory.py | 272 +++++ tests/laborious/utils/bundle/__init__.py | 0 tests/laborious/utils/bundle/conftest.py | 34 + tests/laborious/utils/bundle/test_format.py | 227 ++++ tests/laborious/utils/bundle/test_reader.py | 460 ++++++++ .../laborious/utils/test_connectors_config.py | 113 +- tests/laborious/utils/test_import_password.py | 99 ++ 27 files changed, 4471 insertions(+), 11 deletions(-) create mode 100644 e2e/fixtures/experiment_run.sql create mode 100644 laborious/activities/model_import.py create mode 100644 laborious/activities/model_import_errors.py create mode 100644 laborious/utils/bundle/__init__.py create mode 100644 laborious/utils/bundle/format.py create mode 100644 laborious/utils/bundle/reader.py create mode 100644 laborious/utils/bundle/steps.py create mode 100644 laborious/utils/import_password.py create mode 100644 laborious/workflows/import_model.py create mode 100644 tests/fixtures/bundle/README.md create mode 100644 tests/fixtures/bundle/golden_model_v3.metadata.json create mode 100644 tests/fixtures/bundle/golden_model_v3.sientia create mode 100644 tests/fixtures/bundle/regenerate.py create mode 100644 tests/helpers/__init__.py create mode 100644 tests/helpers/bundle_factory.py create mode 100644 tests/laborious/utils/bundle/__init__.py create mode 100644 tests/laborious/utils/bundle/conftest.py create mode 100644 tests/laborious/utils/bundle/test_format.py create mode 100644 tests/laborious/utils/bundle/test_reader.py create mode 100644 tests/laborious/utils/test_import_password.py diff --git a/e2e/fixtures/experiment_run.sql b/e2e/fixtures/experiment_run.sql new file mode 100644 index 0000000..d43cfd1 --- /dev/null +++ b/e2e/fixtures/experiment_run.sql @@ -0,0 +1,67 @@ +-- public.experiment_run as migrations V1 … V12 leave it, for the e2e Postgres container. +-- +-- Source of truth: sientia-dataops-database-migrations, +-- postgres/sientia-core-mlops-bff/migrations/ — this file mirrors the *final state* of: +-- +-- V1 create_table_experiment_run (13 columns, one index) +-- V2 update_check_constraint_on_status (named the status CHECK) +-- V3 update_check_constraints (named every other CHECK) +-- V4 update_status_column_size_and_add_orchestrator_validation_error (status -> VARCHAR(50)) +-- V5 migrate_mage_statuses_to_orchestrator (data only) +-- V6 rename_mage_response_data_column (-> orchestrator_response_data) +-- V7 migrate_mlflow_statuses_to_tracking (data only) +-- V8 remove_orchestrator_request_sent_status (status vocabulary) +-- V9 remove_file_upload_statuses (status vocabulary) +-- V10 remove_unused_statuses (status vocabulary) +-- V11 add_run_type_to_experiment_run (run_type + reindexed) +-- V12 add_import_statuses_to_experiment_run (IMPORT_SUCCESS / IMPORT_ERROR + pairing) +-- +-- The same drift trade already accepted for the bundle fixtures: this is a copy, and the tests +-- that read it are what make the copy auditable. If a migration lands there, update this file and +-- the version list above. Nothing in this repository owns the DDL — no import ever executes it +-- outside the test suite. + +CREATE TABLE IF NOT EXISTS public.experiment_run ( + id BIGSERIAL CONSTRAINT pk_experiment_run PRIMARY KEY, + experiment_name VARCHAR(50) NOT NULL, + run_name VARCHAR(50), + username VARCHAR(320) NOT NULL, + status VARCHAR(50) NOT NULL, + error_message VARCHAR(1024), + created_at TIMESTAMP WITHOUT TIME ZONE NOT NULL, + updated_at TIMESTAMP WITHOUT TIME ZONE NOT NULL, + bucket_name VARCHAR(63), + file_name VARCHAR(1024), + request_data JSONB NOT NULL, + orchestrator_response_data JSONB, + run_type VARCHAR(20) NOT NULL DEFAULT 'TRAIN', + + CONSTRAINT chk_experiment_run_experiment_name_min_length CHECK (LENGTH(experiment_name) >= 3), + CONSTRAINT chk_experiment_run_run_name_min_length CHECK (LENGTH(run_name) >= 3), + CONSTRAINT chk_experiment_run_username_min_length CHECK (LENGTH(username) >= 3), + CONSTRAINT chk_experiment_run_username_pattern + CHECK (username ~* '^[a-zA-Z0-9_!#$%&''*+/=?`{|}~^.-]+@[a-zA-Z0-9.-]+$'), + CONSTRAINT chk_experiment_run_error_message_min_length CHECK (LENGTH(error_message) >= 3), + CONSTRAINT chk_experiment_run_bucket_name_min_length CHECK (LENGTH(bucket_name) >= 3), + CONSTRAINT chk_experiment_run_bucket_name_pattern CHECK (bucket_name ~* '^[a-z0-9-]+$'), + CONSTRAINT chk_experiment_run_file_name_min_length CHECK (LENGTH(file_name) >= 3), + CONSTRAINT chk_experiment_run_file_name_pattern + CHECK (file_name ~* '^[a-zA-Z0-9 /._-]+$'), + + -- V12: the two shared stage values plus the training and import terminals. + CONSTRAINT chk_experiment_run_status CHECK (status IN ('PENDING', 'ORCHESTRATOR_REQUEST_ERROR', + 'ORCHESTRATOR_WAITING_PROC', + 'ORCHESTRATOR_VALIDATION_ERROR', + 'TRAINING_SUCCESS', 'TRAINING_ERROR', + 'IMPORT_SUCCESS', 'IMPORT_ERROR')), + CONSTRAINT chk_experiment_run_run_type CHECK (run_type IN ('TRAIN', 'IMPORT')), + + -- V12: each pair of terminal values belongs to its own run_type. The shared values stay out. + CONSTRAINT chk_experiment_run_terminal_status_run_type CHECK ( + (run_type = 'IMPORT' OR status NOT IN ('IMPORT_SUCCESS', 'IMPORT_ERROR')) + AND (run_type = 'TRAIN' OR status NOT IN ('TRAINING_SUCCESS', 'TRAINING_ERROR')) + ) +); + +CREATE INDEX IF NOT EXISTS idx_experiment_run_user_exp_status_date_type + ON public.experiment_run (username, experiment_name, status, created_at, run_type); diff --git a/laborious/activities/activities.py b/laborious/activities/activities.py index df78a08..f4a9a2c 100644 --- a/laborious/activities/activities.py +++ b/laborious/activities/activities.py @@ -7,16 +7,22 @@ with workflow.unsafe.imports_passed_through(): from sientia_do.notifications.handlers import CoreNotificationHandler as NotificationHandler from sientia_do.observability.logger import Logger from sientia_do.repository.minio_repository import MinioRepository + from sientia_do.repository.mongodb_repository import MongoDBRepository from laborious.activities.api import API from laborious.activities.gates import Gates from laborious.activities.mlflow import MLFlow + from laborious.activities.model_import import ModelImport from laborious.activities.model_metrics import ModelMetrics from laborious.activities.opc import OPC from laborious.activities.storage import Storage + from laborious.utils.connectors_config import ( + build_import_config, + build_import_status_config, + ) -class Activities(Storage, MLFlow, Gates, OPC, ModelMetrics, API): +class Activities(Storage, MLFlow, Gates, OPC, ModelMetrics, API, ModelImport): """ Main activities orchestrator for the Laborious system. @@ -50,6 +56,9 @@ class Activities(Storage, MLFlow, Gates, OPC, ModelMetrics, API): pi_web_api_config: dict[str, Any], logger: Logger, notification_handler: NotificationHandler, + mongo_config: dict[str, Any] | None = None, + import_config: dict[str, Any] | None = None, + import_status_config: dict[str, Any] | None = None, ): """ Initialize the Activities orchestrator with all required configurations. @@ -68,6 +77,13 @@ class Activities(Storage, MLFlow, Gates, OPC, ModelMetrics, API): Required keys: base_url, auth_type, auth_token logger: Logger instance for observability and debugging notification_handler: Notification handler for alerts and monitoring + mongo_config: MongoDB configuration for the model listing the import writes. When + absent, the import activities that need it refuse rather than guess — the + notification handler keeps its own client either way. + import_config: `build_import_config()`; read from the environment when absent + import_status_config: `build_import_status_config()`; read from the environment when + absent. This is the BFF database holding the import log, a second Postgres + connection that is never the one Storage holds. Raises: Exception: If any parent class initialization fails @@ -147,6 +163,32 @@ class Activities(Storage, MLFlow, Gates, OPC, ModelMetrics, API): metrics_controller=metrics_controller, ) + mongo_repository = ( + MongoDBRepository( + connection_string=mongo_config['connection_string'], + database_name=mongo_config['database_name'], + logger=logger, + notification_handler=notification_handler, + metrics_controller=metrics_controller, + ) + if mongo_config + else None + ) + + ModelImport.__init__( + self, + status_config=import_status_config or build_import_status_config(), + import_config=import_config or build_import_config(), + minio_repository=minio_repository, + # The MLflow repository is shared rather than rebuilt: setting the tracking URI is a + # process-wide side effect, so two instances would be two chances to disagree. + mlflow_repository=self.model_monitoring_repository, + mongo_repository=mongo_repository, + logger=logger, + notification_handler=notification_handler, + metrics_controller=metrics_controller, + ) + async def shutdown(self): """ Gracefully shutdown all activities and clean up resources. @@ -167,3 +209,4 @@ class Activities(Storage, MLFlow, Gates, OPC, ModelMetrics, API): await OPC.aclose(self) ModelMetrics.close(self) API.close(self) + ModelImport.close(self) diff --git a/laborious/activities/model_import.py b/laborious/activities/model_import.py new file mode 100644 index 0000000..125ee97 --- /dev/null +++ b/laborious/activities/model_import.py @@ -0,0 +1,1044 @@ +"""Model import activities: the import log row, the bundle, MLflow, the model document, cleanup. + +The import log row is **not created here**. It is `public.experiment_run` in the Postgres database +`sientia-core-mlops-bff`; the frontend inserts it through the BFF with `run_type='IMPORT'` and +`status='PENDING'`, and passes its `id` in the workflow input. Every write in this module is an +`UPDATE ... WHERE id = :import_run_id`, on an engine of its own against that database — never the +`sientia` database Laborious itself uses, and never in a transaction shared with a provisioning +write. + +Three classes of status write, and only the first one is strict: + +- **A, the claim** — `WHERE id AND run_type='IMPORT' AND (PENDING OR my own workflow id)`. This is + the ownership decision, and a claim matching nothing fails the import explicitly. +- **B, progress** — `WHERE id`, nothing else. A hint. Its failure never fails the import. +- **C, terminal** — `WHERE id`, nothing else, in the workflow's `finally`, after cleanup. The + authority: it rewrites status, code, reason and step from one classification of the outcome. +""" + +from temporalio import activity, workflow + +with workflow.unsafe.imports_passed_through(): + import json + import shutil + from datetime import timedelta + from enum import StrEnum + from pathlib import Path + from typing import Any, Final + + from minio.commonconfig import ENABLED, Filter + from minio.lifecycleconfig import Expiration, LifecycleConfig, Rule + from sientia_do.notifications.handlers import CoreNotificationHandler as NotificationHandler + from sientia_do.observability.logger import Logger + from sientia_do.observability.metrics_controller import MetricsController + from sientia_do.observability.sientia_monitoring import SientiaMonitoring + from sientia_do.repository.minio_repository import MinioRepository + from sientia_do.repository.mongodb_repository import MongoDBRepository + from sientia_do.temporal.constants import now + from sqlalchemy import create_engine, text + from sqlalchemy.engine import URL + from sqlalchemy.exc import DataError, IntegrityError, ProgrammingError + from temporalio.common import RetryPolicy + + from laborious import metrics + from laborious.activities.model_import_errors import ( + ImportErrorCode, + ImportGateRejectedError, + ImportLogRowNotClaimableError, + ImportStatusRejectedError, + classify_import_failure, + report_import_failure, + ) + from laborious.utils.bundle import format as fmt + from laborious.utils.bundle.reader import BundleGateError, BundleLimits, BundleReader + from laborious.utils.bundle.steps import ImportStep + from laborious.utils.import_password import ( + EnvelopeError, + decrypt_password_envelope, + load_envelope_key, + ) + from laborious.utils.repository.model_repository import MLFlowRepository + +# The table is a constant of the code, never an input: there is exactly one import log on this +# platform, and letting a caller name it would be a SQL-injection surface bought for nothing. The +# statements below write the name out literally rather than interpolating this constant — an +# interpolation is a shape a reader (and bandit) has to reason about, and there is nothing to +# parameterise. The constant is what the tests assert every statement against. +IMPORT_LOG_TABLE: Final[str] = 'public.experiment_run' + +RUN_NAME_MAX_LENGTH: Final[int] = 50 +RUN_NAME_PREFIX: Final[str] = 'import-' + +_MISSING_MINIO = 'Minio repository not initialized' +_MISSING_MLFLOW = 'MLflow repository not initialized' +_MISSING_MONGO = 'MongoDB repository not initialized' + +# Where a digest may sit inside the BFF-owned `request_data`. Checked in this order; absence is not +# a failure, only a disagreement is. +REQUEST_DIGEST_KEYS: Final[tuple[str, ...]] = ( + 'expected_digest', + 'digest', + 'sha256', + 'file_digest', +) + + +class ImportState(StrEnum): + """The workflow's internal vocabulary, mapped onto the shared status column by D17.""" + + RUNNING = 'RUNNING' + COMPLETED = 'COMPLETED' + ERROR = 'ERROR' + + +class ExperimentRunStatus(StrEnum): + """The values this workflow writes into `experiment_run.status`. + + Two are **reused** from the training flow because they describe a stage, not a flow — the row's + flow is told by `run_type`, not by the status. Two are **new in V12**, because + `TRAINING_SUCCESS` / `TRAINING_ERROR` would lie about where the row came from. Nobody should + "fix" the reuse into an `IMPORT_RUNNING` twin: it would carry no information `run_type` does not + already carry, and it would widen the pairing CHECK and the frontend's switch for nothing. + """ + + ORCHESTRATOR_WAITING_PROC = 'ORCHESTRATOR_WAITING_PROC' # reused + ORCHESTRATOR_VALIDATION_ERROR = 'ORCHESTRATOR_VALIDATION_ERROR' # reused + IMPORT_SUCCESS = 'IMPORT_SUCCESS' # new in V12 + IMPORT_ERROR = 'IMPORT_ERROR' # new in V12 + + +# A failure at these steps is the user's to act on — re-export, re-type the password, re-submit — +# which is the one thing the import screen can branch on without reading the error code. +USER_ACTIONABLE_STEPS: Final[frozenset[ImportStep]] = frozenset( + { + ImportStep.RECEIVED, + ImportStep.DOWNLOAD, + ImportStep.DECRYPTION, + ImportStep.ARCHIVE_INSPECTION, + ImportStep.EXTRACTION, + ImportStep.STRUCTURE_VALIDATION, + ImportStep.CONTENT_POLICY, + } +) + +# Only the status writes retry, and only because they are idempotent: `UPDATE ... SET +# WHERE id` leaves the row the same however many times it runs. A provisioning call has no such +# shape, which is why it keeps `maximum_attempts=1`. Defined here rather than in `sientia_do`, which +# other repositories share. +IMPORT_STATUS_RETRY_POLICY: Final[RetryPolicy] = RetryPolicy( + maximum_attempts=10, + initial_interval=timedelta(seconds=1), + backoff_coefficient=2.0, + maximum_interval=timedelta(seconds=30), + non_retryable_error_types=['ImportLogRowNotClaimableError', 'ImportStatusRejectedError'], +) + +# Progress writes are hints nobody reads — the terminal write rewrites them — so a 151-second pause +# before each of a dozen stage updates would stall a working import for twenty minutes because of a +# database the import itself does not need. +IMPORT_STATUS_HINT_RETRY_POLICY: Final[RetryPolicy] = RetryPolicy( + maximum_attempts=3, + initial_interval=timedelta(seconds=1), + backoff_coefficient=2.0, + maximum_interval=timedelta(seconds=2), + non_retryable_error_types=['ImportLogRowNotClaimableError', 'ImportStatusRejectedError'], +) + +_CLAIM_SQL = text( + """ + UPDATE public.experiment_run + SET status = :status, + updated_at = :updated_at, + orchestrator_response_data = CAST(:detail AS JSONB) + WHERE id = :import_run_id + AND run_type = 'IMPORT' + AND (status = 'PENDING' + OR (status = 'ORCHESTRATOR_WAITING_PROC' + AND orchestrator_response_data -> 'import' ->> 'workflow_id' = :workflow_id)) + """ +) + +_READ_ROW_SQL = text( + """ + SELECT id, + run_type, + status, + bucket_name, + file_name, + request_data, + orchestrator_response_data -> 'import' ->> 'workflow_id' AS claimed_by + FROM public.experiment_run + WHERE id = :import_run_id + """ +) + +# Class B and C key on the id alone: ownership was decided once, by the claim. Re-checking it on +# every later write buys nothing and costs the thing that matters — a predicate miss turning a +# pipeline that already imported the model into a reported failure. +_PROGRESS_SQL = text( + """ + UPDATE public.experiment_run + SET status = :status, + updated_at = :updated_at, + orchestrator_response_data = CAST(:detail AS JSONB) + WHERE id = :import_run_id + """ +) + +_NAMES_SQL = text( + """ + UPDATE public.experiment_run + SET experiment_name = :experiment_name, + run_name = :run_name, + updated_at = :updated_at, + orchestrator_response_data = CAST(:detail AS JSONB) + WHERE id = :import_run_id + """ +) + +_TERMINAL_SQL = text( + """ + UPDATE public.experiment_run + SET status = :status, + error_message = :error_message, + updated_at = :updated_at, + orchestrator_response_data = CAST(:detail AS JSONB) + WHERE id = :import_run_id + """ +) + + +def map_state_to_status( + state: ImportState, step: ImportStep | str | None = None +) -> ExperimentRunStatus: + """Map the workflow's internal state onto the shared status column (design D17). + + `RUNNING` is the orchestrator holding the row. `COMPLETED` is `IMPORT_SUCCESS`. An `ERROR` is + split in two by *who can act on it*: the user, for everything up to the content policy, or only + support, for everything after it — the same split the table already draws between + `ORCHESTRATOR_VALIDATION_ERROR` and `TRAINING_ERROR`. + """ + if state is ImportState.RUNNING: + return ExperimentRunStatus.ORCHESTRATOR_WAITING_PROC + if state is ImportState.COMPLETED: + return ExperimentRunStatus.IMPORT_SUCCESS + if step is not None and ImportStep(step) in USER_ACTIONABLE_STEPS: + return ExperimentRunStatus.ORCHESTRATOR_VALIDATION_ERROR + return ExperimentRunStatus.IMPORT_ERROR + + +def derive_run_name(model_name: str, model_version: str) -> str: + """`import-{model_name}-v{model_version}`, truncated inside the model-name segment. + + Deterministic, because the recorded name and the created run's name must not diverge: the run is + created with exactly this value, passed in as an argument rather than recomputed. The `import-` + prefix guarantees the column's length-3 floor; the truncation is what keeps it under 50. + """ + suffix = f'-v{model_version}' + room = RUN_NAME_MAX_LENGTH - len(RUN_NAME_PREFIX) - len(suffix) + if room < 1: + # A version string long enough to leave no room for a name: keep the prefix and as much of + # the version as fits, so the value is still a legible, deterministic 50 characters. + return f'{RUN_NAME_PREFIX}{suffix.lstrip("-")}'[:RUN_NAME_MAX_LENGTH] + return f'{RUN_NAME_PREFIX}{model_name[:room]}{suffix}' + + +def build_import_detail( + *, + workflow_id: str, + step: ImportStep | str | None = None, + code: ImportErrorCode | str | None = None, + gate: int | None = None, + source: dict[str, Any] | None = None, + cleanup_failed: bool = False, +) -> str: + """Build `orchestrator_response_data` as JSON text. + + The whole `import` object is rewritten on every write, which is what keeps the workflow + identifier the claim wrote from ever being dropped. There is no column for any of this (design + D21): the code the frontend switches on and the identifier support quotes live at fixed keys in + the JSON column the workflow already owns. + """ + detail: dict[str, Any] = {'workflow_id': workflow_id, 'gate': gate} + if step is not None: + detail['step'] = str(step) + if code is not None: + detail['code'] = str(code) + if source: + detail['source'] = source + if cleanup_failed: + detail['cleanup_failed'] = True + return json.dumps({'import': detail}) + + +class ModelImport(SientiaMonitoring): + """Activities turning a `.sientia` object into a registered model, or into a durable ERROR.""" + + def __init__( + self, + status_config: dict[str, Any], + import_config: dict[str, Any], + minio_repository: MinioRepository | None = None, + mlflow_repository: MLFlowRepository | None = None, + mongo_repository: MongoDBRepository | None = None, + logger: Logger | None = None, + notification_handler: NotificationHandler | None = None, + metrics_controller: MetricsController | None = None, + ): + """ + Args: + status_config: `build_import_status_config()` — the BFF database holding the import log. + This is a **second** Postgres connection, independent of the one Laborious already + holds, which is what makes the transactional independence of design D7 literal. + import_config: `build_import_config()` — bucket, prefix, ceilings, work directory, + envelope key and the models collection name. + minio_repository: the shared MinIO repository, for the uploaded bundle. + mlflow_repository: the shared MLflow repository — the same instance the rest of the + worker uses, because setting the tracking URI is a process-wide side effect. + mongo_repository: MongoDB, where the model listing lives. + logger: logger instance. + notification_handler: the notification path the rest of the repository uses. + metrics_controller: metrics controller. + """ + SientiaMonitoring.__init__(self, logger, notification_handler, metrics_controller) + self.import_config = import_config + self.status_config = status_config + self.import_minio_repository = minio_repository + self.import_mlflow_repository = mlflow_repository + self.mongo_repository = mongo_repository + self.status_engine = create_engine( + URL.create( + 'postgresql', + username=status_config['user'], + password=status_config['password'], + host=status_config['host'], + port=status_config['port'], + database=status_config['dbname'], + ), + pool_size=2, + max_overflow=3, + pool_pre_ping=True, + ) + + def close(self) -> None: + """Dispose the status engine. The import holds no other connection.""" + engine = getattr(self, 'status_engine', None) + if engine is not None: + engine.dispose() + + # ------------------------------------------------------------------ helpers + + @staticmethod + def _timestamp() -> Any: + """`updated_at` is `TIMESTAMP WITHOUT TIME ZONE`, so the value handed over is naive.""" + moment = now() + return moment.replace(tzinfo=None) if moment.tzinfo else moment + + @staticmethod + def _digest_from_request_data(request_data: Any) -> str | None: + """Find the uploader's digest inside the BFF-owned request payload, if it recorded one.""" + if not isinstance(request_data, dict): + return None + for key in REQUEST_DIGEST_KEYS: + value = request_data.get(key) + if isinstance(value, str) and value: + return value.lower() + return None + + def _execute_status_write(self, statement: Any, parameters: dict[str, Any]) -> int: + """Run one status statement in its own transaction and return the rows it affected. + + Raises: + ImportStatusRejectedError: the database accepted the connection and refused the + statement — a CHECK violation, an undefined column, a value too long. Non-retryable, + so a missing `V12` fails on the first attempt instead of after the full envelope. + Exception: anything else (connection refused, reset, timeout, a pod being replaced) is + left to propagate, because repeating it is exactly what fixes it. + """ + try: + with self.status_engine.begin() as connection: + return connection.execute(statement, parameters).rowcount + except (IntegrityError, ProgrammingError, DataError) as error: + raise ImportStatusRejectedError( + 'the import log database refused the status statement' + ) from error + + def _read_row(self, import_run_id: Any) -> dict[str, Any] | None: + """Read the record. A read, not a write: nothing of somebody else's is overwritten.""" + with self.status_engine.connect() as connection: + row = connection.execute(_READ_ROW_SQL, {'import_run_id': import_run_id}).mappings() + found = row.first() + return dict(found) if found is not None else None + + @staticmethod + def _describe_unclaimable(row: dict[str, Any] | None, workflow_id: str) -> str: + """Name the cause of a zero-row claim: missing row, training row, or someone else's.""" + if row is None: + return 'no such import log row' + if row.get('run_type') != 'IMPORT': + return f'row is a {row.get("run_type")} run, not an import' + claimed_by = row.get('claimed_by') + if claimed_by and claimed_by != workflow_id: + return f'row is already claimed by workflow {claimed_by}' + return f'row is in status {row.get("status")} and cannot be claimed' + + # -------------------------------------------------------------- class A: claim + + @activity.defn(name='claim_import_status') + async def claim_import_status(self, input_data: dict[str, Any]) -> dict[str, Any]: + """Claim the import log row the frontend inserted. The workflow's first write. + + One conditional `UPDATE`, whose second disjunct makes a re-execution of the same workflow + idempotent instead of a conflict — a lost response on attempt 1 must not become a false + "not mine" on attempt 2. Zero rows affected covers the id typo, the duplicated start and the + wrong `run_type` in one condition, with no read-then-write race, and it is what stops a + duplicated workflow start from provisioning twice. + + Args (input_data): + metadata (dict): workflow metadata for logging and metrics. + import_run_id (int): the record's primary key, from the workflow input. + workflow_id (str): this workflow's identifier, recorded in the JSON detail. + source (dict, optional): the bundle's declared origin, when already known. + + Returns: + dict: `claimed`, plus the row values the input guards are checked against — + `bucket_name`, `file_name` and `request_digest`. + + Raises: + ImportLogRowNotClaimableError: the row is not this workflow's. Non-retryable, reported + through the channel, and **written to no row** — the loudness has to go somewhere + that is not somebody else's audit record. + """ + metadata = input_data.get('metadata', {}) + import_run_id = input_data['import_run_id'] + workflow_id = input_data['workflow_id'] + + affected = self._execute_status_write( + _CLAIM_SQL, + { + 'status': ExperimentRunStatus.ORCHESTRATOR_WAITING_PROC.value, + 'updated_at': self._timestamp(), + 'detail': build_import_detail( + workflow_id=workflow_id, + step=ImportStep.RECEIVED, + source=input_data.get('source'), + ), + 'import_run_id': import_run_id, + 'workflow_id': workflow_id, + }, + ) + + row = self._read_row(import_run_id) + if affected == 0: + observed = self._describe_unclaimable(row, workflow_id) + error = ImportLogRowNotClaimableError( + f'import log row {import_run_id} could not be claimed: {observed}', + import_run_id=import_run_id, + observed=observed, + ) + await report_import_failure( + self, + None, + error, + import_run_id=import_run_id, + workflow_id=workflow_id, + metadata=metadata, + code=ImportErrorCode.LOG_ROW_NOT_CLAIMABLE, + marker='not_claimable', + ) + raise error + + await self.emit_metric( + metric_object=metrics.MODEL_IMPORT_STARTED_COUNT, + tags=self.get_core_labels(metadata, operation_type=ImportStep.RECEIVED.value), + ) + self.info(f'import log row {import_run_id} claimed by {workflow_id}', metadata) + row = row or {} + return { + 'claimed': True, + 'bucket_name': row.get('bucket_name'), + 'file_name': row.get('file_name'), + 'request_digest': self._digest_from_request_data(row.get('request_data')), + } + + # ----------------------------------------------------------- class B: progress + + @activity.defn(name='record_import_status') + async def record_import_status(self, input_data: dict[str, Any]) -> dict[str, Any]: + """Record the step the workflow is about to attempt. A hint, keyed on the id alone. + + Its failure is **not** an import failure: the workflow reports it through the channel and + carries on, because losing a hint costs a record pointing one step behind and nothing else — + the terminal write rewrites status, code, reason and step from one classification. + + Args (input_data): + metadata (dict): workflow metadata. + import_run_id (int): the record's primary key. + workflow_id (str): recorded in the detail, so it is never dropped. + step (str): the step about to be attempted. + source (dict, optional): the bundle's declared origin. + """ + affected = self._execute_status_write( + _PROGRESS_SQL, + { + 'status': ExperimentRunStatus.ORCHESTRATOR_WAITING_PROC.value, + 'updated_at': self._timestamp(), + 'detail': build_import_detail( + workflow_id=input_data['workflow_id'], + step=input_data['step'], + source=input_data.get('source'), + ), + 'import_run_id': input_data['import_run_id'], + }, + ) + return {'recorded': affected > 0, 'rows': affected} + + @activity.defn(name='record_import_names') + async def record_import_names(self, input_data: dict[str, Any]) -> dict[str, Any]: + """Write the experiment and run names, before anything exists in MLflow. + + Both columns exist so a person watching the import list sees *which* model is being + imported. The earliest moment both values are known is when the bundle is open, so that is + when they are written — before the experiment exists, before the run exists. + + Args (input_data): + metadata (dict): workflow metadata. + import_run_id (int): the record's primary key. + workflow_id (str): recorded in the detail. + experiment_name (str): the bundle's own experiment name, verbatim. + run_name (str): the derived run name, which the run is then created with. + source (dict, optional): the bundle's declared origin. + """ + affected = self._execute_status_write( + _NAMES_SQL, + { + 'experiment_name': input_data['experiment_name'], + 'run_name': input_data['run_name'], + 'updated_at': self._timestamp(), + 'detail': build_import_detail( + workflow_id=input_data['workflow_id'], + step=input_data.get('step', ImportStep.EXPERIMENT_CREATION), + source=input_data.get('source'), + ), + 'import_run_id': input_data['import_run_id'], + }, + ) + return {'recorded': affected > 0, 'rows': affected} + + # ----------------------------------------------------------- class C: terminal + + @activity.defn(name='record_import_terminal_status') + async def record_import_terminal_status(self, input_data: dict[str, Any]) -> dict[str, Any]: + """The single authoritative write, run from the workflow's `finally` after cleanup. + + Every failure is classified **once**, here, from the step that failed. On success the reason + column is written as `NULL` — never `''`, which its length-3 CHECK would refuse — and the + detail carries no code. A cleanup failure is noted in the detail without changing a + successful status: the user's model was imported, and reporting ERROR would be false. + + Args (input_data): + metadata (dict): workflow metadata. + import_run_id (int): the record's primary key. + workflow_id (str): recorded in the detail. + succeeded (bool): whether every step succeeded. + step (str, optional): the failing step, or the last step reached on success. + gate (int, optional): the gate that rejected the bundle, when one did. + source (dict, optional): the bundle's declared origin. + cleanup_failed (bool, optional): whether the temporary-file cleanup failed. + + Returns: + dict: the verdict that was written — status, code and reason — so a caller that has to + report a failure to record it still holds the verdict it could not write. + """ + metadata = input_data.get('metadata', {}) + succeeded = bool(input_data.get('succeeded')) + step = input_data.get('step') + cleanup_failed = bool(input_data.get('cleanup_failed')) + + if succeeded: + status = map_state_to_status(ImportState.COMPLETED) + code: ImportErrorCode | None = None + reason: str | None = None + else: + failure = classify_import_failure(ImportStep(step) if step else None) + status = map_state_to_status(ImportState.ERROR, step) + code, reason = failure.code, failure.reason + + verdict = { + 'status': status.value, + 'code': code.value if code else None, + 'reason': reason, + 'step': str(step) if step else None, + } + + self._execute_status_write( + _TERMINAL_SQL, + { + 'status': status.value, + 'error_message': reason, + 'updated_at': self._timestamp(), + 'detail': build_import_detail( + workflow_id=input_data['workflow_id'], + step=step, + code=code, + gate=input_data.get('gate'), + source=input_data.get('source'), + cleanup_failed=cleanup_failed, + ), + 'import_run_id': input_data['import_run_id'], + }, + ) + + if succeeded: + await self.emit_metric( + metric_object=metrics.MODEL_IMPORT_COMPLETED_COUNT, + tags=self.get_core_labels(metadata, operation_type=status.value), + ) + self.info(f'import {input_data["import_run_id"]} recorded as {status.value}', metadata) + return verdict + + @activity.defn(name='report_import_status_write_failure') + async def report_import_status_write_failure( + self, input_data: dict[str, Any] + ) -> dict[str, Any]: + """Report a status write that could not be made — the one failure with no row to land on. + + Called by the workflow when a progress or terminal write exhausted its attempts or was + refused. It carries the verdict it could not write, so the outcome survives in text even + when it cannot survive in the row, and it never turns a completed import into a failed one. + + Args (input_data): + metadata (dict): workflow metadata. + import_run_id (int): the record's primary key. + workflow_id (str): the identifier that correlates this line with the record. + detail (str): the underlying failure, as text — it crossed an activity boundary, so the + exception object itself is gone by now. + verdict (str, optional): the verdict the terminal write could not record. + """ + await report_import_failure( + self, + None, + None, + import_run_id=input_data['import_run_id'], + workflow_id=input_data['workflow_id'], + metadata=input_data.get('metadata', {}), + code=ImportErrorCode.STATUS_NOT_RECORDED, + marker='status_write', + verdict=input_data.get('verdict'), + detail_text=input_data.get('detail'), + ) + return {'reported': True} + + # ------------------------------------------------------------- bundle helpers + + def _bundle_reader(self) -> BundleReader: + """A reader carrying the configured ceilings.""" + return BundleReader( + BundleLimits( + max_object_bytes=self.import_config['max_object_bytes'], + max_entries=self.import_config['max_entries'], + max_uncompressed_bytes=self.import_config['max_uncompressed_bytes'], + max_compression_ratio=self.import_config['max_compression_ratio'], + object_prefix=self.import_config['prefix'], + ) + ) + + def _work_dir(self, workflow_id: str) -> Path: + """This import's own scratch directory. One per workflow, never shared.""" + return Path(self.import_config['work_dir']) / workflow_id + + def _effective_bucket(self, requested: str | None) -> str: + return requested or self.import_config['bucket'] + + # --------------------------------------------------------- download and open + + @activity.defn(name='download_import_bundle') + async def download_import_bundle(self, input_data: dict[str, Any]) -> dict[str, Any]: + """Gate 1, then the download, then the mandatory digest comparison. + + The object's size and key shape are checked from `stat_object`, before its body is fetched. + The SHA-256 of what was downloaded is then compared against `expected_digest` — the value the + uploader computed over the bytes it uploaded — with **no path that skips the comparison**, + because a missing value was already rejected at `received`. A mismatch fails here, before the + header is parsed: it means the object behind that key is not the file the user chose. + + Args (input_data): + metadata (dict): workflow metadata. + workflow_id (str): names this import's scratch directory. + bucket (str, optional): the effective bucket; the configured one when absent. + object_key (str): the uploaded object's key. + expected_digest (str): the uploader's SHA-256, lowercase hex. + + Returns: + dict: `path`, `size` and `digest` of the downloaded file. + + Raises: + ImportGateRejectedError: gate 1 rejected the object, or the digests disagree. + """ + if self.import_minio_repository is None: + raise ValueError(_MISSING_MINIO) + + metadata = input_data.get('metadata', {}) + bucket = self._effective_bucket(input_data.get('bucket')) + object_key = input_data['object_key'] + expected_digest = input_data['expected_digest'] + reader = self._bundle_reader() + + client = self.import_minio_repository.minio_client + try: + stat = client.stat_object(bucket, object_key) + except Exception as error: + raise ImportGateRejectedError( + 'the uploaded object could not be read', step=ImportStep.DOWNLOAD, gate=1 + ) from error + + try: + reader.check_object_limits(size=stat.size or 0, key=object_key) + except BundleGateError as error: + raise ImportGateRejectedError(str(error), step=error.step, gate=error.gate) from error + + work_dir = self._work_dir(input_data['workflow_id']) + work_dir.mkdir(parents=True, exist_ok=True) + target = work_dir / 'bundle.sientia' + try: + client.fget_object(bucket, object_key, str(target)) + except Exception as error: + raise ImportGateRejectedError( + 'the uploaded object could not be downloaded', step=ImportStep.DOWNLOAD, gate=1 + ) from error + + digest = fmt.file_digest(target) + if digest != str(expected_digest).lower(): + target.unlink(missing_ok=True) + raise ImportGateRejectedError( + 'the downloaded object does not match the digest the uploader computed', + step=ImportStep.DOWNLOAD, + gate=1, + ) + + self.info(f'import bundle downloaded from {bucket} ({stat.size} bytes)', metadata) + return {'path': str(target), 'size': stat.size, 'digest': digest} + + @activity.defn(name='open_import_bundle') + async def open_import_bundle(self, input_data: dict[str, Any]) -> dict[str, Any]: + """Open the envelope, then the bundle: gates 2 to 7, in the reader's own order. + + The password envelope is decrypted **here**, inside the activity that uses the password, so + no plaintext password is ever an activity input, an activity result or a workflow variable — + and therefore never lands in the Temporal event history. The recovered password is a local + value and nothing else. + + Args (input_data): + metadata (dict): workflow metadata. + workflow_id (str): names this import's scratch directory. + path (str): the downloaded bundle. + password_envelope (str): base64 `nonce || ciphertext || tag`, AES-256-GCM. + + Returns: + dict: `extracted_dir`, `metadata`, `parameters`, `digest` — nothing secret. + + Raises: + ImportGateRejectedError: with the step and the gate that rejected the bundle. + """ + metadata = input_data.get('metadata', {}) + work_dir = self._work_dir(input_data['workflow_id']) + extract_root = work_dir / 'extract' + + try: + key = load_envelope_key(self.import_config.get('password_key')) + password = decrypt_password_envelope(input_data['password_envelope'], key) + except EnvelopeError as error: + # A distinct failure from a wrong *bundle* password, and never confused with it: the + # envelope is the platform's own transport, and its message says so. Same step, because + # the step is where the import stopped, not who is at fault. + raise ImportGateRejectedError( + 'the import password envelope could not be opened', + step=ImportStep.DECRYPTION, + gate=3, + cause='envelope', + ) from error + + reader = self._bundle_reader() + try: + opened = reader.open(input_data['path'], password, extract_root, work_dir) + except BundleGateError as error: + raise ImportGateRejectedError(str(error), step=error.step, gate=error.gate) from error + finally: + del password + + await self.emit_metric( + metric_object=metrics.MODEL_IMPORT_KDF_DURATION, + tags=self.get_core_labels(metadata, operation_type=ImportStep.DECRYPTION.value), + method='observe', + value=opened.kdf_seconds, + ) + self.info(f'import bundle opened into {opened.extracted_dir}', metadata) + return { + 'extracted_dir': str(opened.extracted_dir), + 'metadata': opened.metadata, + 'parameters': opened.parameters, + 'digest': opened.digest, + } + + # ------------------------------------------------------------ MLflow provisioning + + @activity.defn(name='create_import_experiment') + async def create_import_experiment(self, input_data: dict[str, Any]) -> dict[str, Any]: + """Get or create the experiment named by the bundle, keeping the origin's name. + + Args (input_data): + metadata (dict): workflow metadata. + experiment_name (str): the bundle's `metadata.experiment_name`. + + Returns: + dict: `experiment_id` and `experiment_name`. + """ + if self.import_mlflow_repository is None: + raise ValueError(_MISSING_MLFLOW) + + experiment_name = input_data['experiment_name'] + experiment = self.import_mlflow_repository.get_experiment( + experiment_name, create_if_not_exists=True + ) + self.info( + f'import experiment ready: {experiment_name} ({experiment.experiment_id})', + input_data.get('metadata', {}), + ) + return {'experiment_id': experiment.experiment_id, 'experiment_name': experiment_name} + + @activity.defn(name='upload_import_artifacts') + async def upload_import_artifacts(self, input_data: dict[str, Any]) -> dict[str, Any]: + """Create a new run with the recorded name and log the bundle's tree and parameters. + + Always a new run — the importer never appends to an existing one. The run is created with + exactly the `run_name` already written on the record, passed in rather than recomputed, so + the two cannot diverge. The artifact tree is logged verbatim, keeping `prediction_model` and + `data_model` at the run's artifact root, which is what the legacy loader resolves. The + bundle's `parameters` are logged unchanged, and the origin's own identity is logged + alongside them under `source.*` — records of where the model came from, not links. + + Args (input_data): + metadata (dict): workflow metadata. + experiment_id (str): the experiment to create the run in. + run_name (str): the name already on the record. + extracted_dir (str): the opened bundle's directory. + parameters (dict): the bundle's `parameters` block, verbatim. + source (dict): the bundle's `metadata` block, logged as `source.*`. + + Returns: + dict: `run_id` and `run_name`. + """ + if self.import_mlflow_repository is None: + raise ValueError(_MISSING_MLFLOW) + + client = self.import_mlflow_repository.client + run = client.create_run( + experiment_id=input_data['experiment_id'], run_name=input_data['run_name'] + ) + run_id = run.info.run_id + + artifacts_dir = Path(input_data['extracted_dir']) / 'artifacts' + client.log_artifacts(run_id, str(artifacts_dir)) + + for name, value in (input_data.get('parameters') or {}).items(): + client.log_param(run_id, name, value) + for name, value in (input_data.get('source') or {}).items(): + client.log_param(run_id, f'source.{name}', value) + + client.set_terminated(run_id, 'FINISHED') + self.info(f'import artifacts uploaded to run {run_id}', input_data.get('metadata', {})) + return {'run_id': run_id, 'run_name': input_data['run_name']} + + @activity.defn(name='register_import_model_version') + async def register_import_model_version(self, input_data: dict[str, Any]) -> dict[str, Any]: + """Register a version whose source the legacy loader can parse. No stage change. + + The source is `runs://prediction_model`, because `get_model_run_id` extracts the run + id as `source.split('/')[2]` — a `models:/m-*` source, what the new stack emits, breaks it. + The stage is left untouched: a `COMPLETED` import is a registered model, not a servable one, + and promoting it to `Production` is a manual operator step outside this workflow. + + Args (input_data): + metadata (dict): workflow metadata. + model_name (str): the bundle's `metadata.model_name`, verbatim. + run_id (str): the run this import created. + + Returns: + dict: `model_name`, `version` and `source`. + """ + if self.import_mlflow_repository is None: + raise ValueError(_MISSING_MLFLOW) + + client = self.import_mlflow_repository.client + model_name = input_data['model_name'] + run_id = input_data['run_id'] + + try: + client.get_registered_model(model_name) + except Exception: + # First import of this name: the registered model is created on demand. A second import + # lands as a new version of the same registered model, never a differently named one. + client.create_registered_model(model_name) + + source = f'runs:/{run_id}/prediction_model' + version = client.create_model_version(name=model_name, source=source, run_id=run_id) + self.info( + f'import registered {model_name} version {version.version}', + input_data.get('metadata', {}), + ) + return {'model_name': model_name, 'version': str(version.version), 'source': source} + + # --------------------------------------------------- the MongoDB model document + + @activity.defn(name='write_import_model_document') + async def write_import_model_document(self, input_data: dict[str, Any]) -> dict[str, Any]: + """Write the document that *is* the model listing, inert, last. + + This is what the runtime reads to know how to load a model — `model_config.transform_flavor`, + `model_config.predict_flavor` and `model_config.target` — so without it an imported model is + invisible to every workflow. `id` continues the collection's own numbering: the largest + existing id **as an integer** plus one, never a count and never a lexicographic sort, which + would answer `"9" > "30"`. A name already in the collection is left exactly as it is: + `active`, `target` and the flavors of a model already on the platform are operator-owned. + + Args (input_data): + metadata (dict): workflow metadata. + model_name (str): the bundle's `metadata.model_name`, verbatim. + target (str): the bundle's `parameters.target_variable`. + + Returns: + dict: `written`, `id`, `skipped` and how many unusable ids were ignored. + """ + if self.mongo_repository is None: + raise ValueError(_MISSING_MONGO) + + metadata = input_data.get('metadata', {}) + collection = self.import_config['models_collection'] + model_name = input_data['model_name'] + + existing = await self.mongo_repository.find_one(collection, {'name': model_name}, metadata) + if existing is not None: + self.info( + f'model document for {model_name} already exists; leaving it untouched', metadata + ) + return {'written': False, 'skipped': True, 'id': existing.get('id')} + + next_id, ignored = await self._next_model_document_id(collection, metadata) + document = { + 'id': next_id, + 'name': model_name, + 'active': False, + 'model_config': { + 'transform_flavor': 'sklearn', + 'predict_flavor': 'joblib', + 'retention_minutes': 60, + 'target': input_data['target'], + }, + } + await self.mongo_repository.insert(collection, document, metadata) + self.info(f'model document written for {model_name} with id {next_id}', metadata) + return {'written': True, 'skipped': False, 'id': next_id, 'ignored_ids': ignored} + + async def _next_model_document_id( + self, collection: str, metadata: dict[str, Any] + ) -> tuple[str, int]: + """`max(int(id)) + 1` as a decimal string, tolerating ids that are not numbers.""" + if self.mongo_repository is None: + raise ValueError(_MISSING_MONGO) + raw_ids = await self.mongo_repository.distinct(collection, 'id', {}, metadata) + numeric: list[int] = [] + ignored = 0 + for value in raw_ids: + try: + numeric.append(int(str(value))) + except (TypeError, ValueError): + # A document whose id is not a decimal number cannot take part in the numbering. + # Ignored rather than fatal, and counted so the log says how many there were. + ignored += 1 + if ignored: + self.info(f'ignored {ignored} non-numeric model document ids', metadata) + return str(max(numeric, default=0) + 1), ignored + + # ----------------------------------------------------------- cleanup and retention + + @activity.defn(name='cleanup_import_files') + async def cleanup_import_files(self, input_data: dict[str, Any]) -> dict[str, Any]: + """Remove this import's scratch directory. Never the uploaded object. + + Runs on every path, success or failure, and **never raises**: a cleanup failure is reported + through the channel and noted in the record's detail, but the user's model was imported and + reporting an error would be false. + + Args (input_data): + metadata (dict): workflow metadata. + workflow_id (str): names the directory to remove. + import_run_id (int): for correlation when the removal fails. + + Returns: + dict: `cleaned` and, when it failed, the reason as text. + """ + metadata = input_data.get('metadata', {}) + work_dir = self._work_dir(input_data['workflow_id']) + try: + shutil.rmtree(work_dir, ignore_errors=False) + except FileNotFoundError: + # Nothing was downloaded, or a gate removed its own partial tree. Not a failure. + return {'cleaned': True, 'existed': False} + except OSError as error: + await report_import_failure( + self, + ImportStep.CLEANUP, + error, + import_run_id=input_data.get('import_run_id'), + workflow_id=input_data['workflow_id'], + metadata=metadata, + code=ImportErrorCode.CLEANUP_INCOMPLETE, + marker=ImportStep.CLEANUP.value, + ) + return {'cleaned': False, 'existed': True, 'reason': type(error).__name__} + return {'cleaned': True, 'existed': True} + + @activity.defn(name='ensure_import_bundle_retention') + async def ensure_import_bundle_retention(self, input_data: dict[str, Any]) -> dict[str, Any]: + """Ensure the 7-day expiry rule on the import prefix, idempotently. + + Applied by the importer rather than assumed from a Helm value, so the acceptance criterion + ("lifecycle active") is verifiable from the code that depends on it. The uploaded object is + never deleted by the import itself — it is left to this rule, as the record of an attempt. + + Args (input_data): + metadata (dict): workflow metadata. + bucket (str, optional): the effective bucket; the configured one when absent. + + Returns: + dict: whether the rule was already there and what it expires. + """ + if self.import_minio_repository is None: + raise ValueError(_MISSING_MINIO) + + metadata = input_data.get('metadata', {}) + bucket = self._effective_bucket(input_data.get('bucket')) + prefix = self.import_config['prefix'] + days = self.import_config['retention_days'] + rule_id = 'sientia-import-bundle-expiry' + client = self.import_minio_repository.minio_client + + try: + existing = client.get_bucket_lifecycle(bucket) + except Exception: + # A bucket with no lifecycle configuration at all answers with an error on some + # deployments and with `None` on others; both mean "there is no rule yet". + existing = None + + rules = [rule for rule in (existing.rules if existing else []) if rule.rule_id != rule_id] + rules.append( + Rule( + ENABLED, + rule_filter=Filter(prefix=prefix), + rule_id=rule_id, + expiration=Expiration(days=days), + ) + ) + client.set_bucket_lifecycle(bucket, LifecycleConfig(rules)) + self.info(f'import retention rule ensured on {bucket} ({days} days)', metadata) + return {'bucket': bucket, 'prefix': prefix, 'days': days, 'rules': len(rules)} diff --git a/laborious/activities/model_import_errors.py b/laborious/activities/model_import_errors.py new file mode 100644 index 0000000..657f47f --- /dev/null +++ b/laborious/activities/model_import_errors.py @@ -0,0 +1,348 @@ +"""The import's failure vocabulary: a closed catalog, typed errors and one reporting channel. + +Three things live here and nowhere else: + +- **the catalog** — one stable `error_code` and one plain-language `error_reason` per pipeline step. + The reason is written for a person who does not know this system exists: what happened, what state + the model is in, what to do next. It never carries a path, a table name, an object key, a Python + exception class or a traceback, and it is never built from `str(exception)`. +- **the typed errors** — the two failures that must not be retried, because repeating them cannot + change the answer: a record this workflow does not own, and a statement the database refuses. +- **the channel** — `report_import_failure`, the single funnel every failure passes through. It is + the only place in the import path that logs an error or sends a notification, which a test + asserts, so no failure is reported twice or half-reported. +""" + +from temporalio import workflow + +with workflow.unsafe.imports_passed_through(): + import traceback + from collections.abc import Mapping + from dataclasses import dataclass + from enum import StrEnum + from types import MappingProxyType + from typing import Any, Final, Protocol + + from temporalio.exceptions import ApplicationError + + from laborious import metrics + from laborious.utils.bundle.steps import ImportStep + + +class ImportErrorCode(StrEnum): + """The machine-readable half of a failure: the frontend's i18n key and support's triage key. + + Written under `import.code` in the record's JSON detail — not in a column of its own, so the + import adds nothing to a table it shares with the training flow. + """ + + REQUEST_INVALID = 'IMPORT_REQUEST_INVALID' + FILE_UNREADABLE = 'IMPORT_FILE_UNREADABLE' + BUNDLE_CANNOT_BE_OPENED = 'IMPORT_BUNDLE_CANNOT_BE_OPENED' + BUNDLE_REJECTED = 'IMPORT_BUNDLE_REJECTED' + BUNDLE_UNPACK_FAILED = 'IMPORT_BUNDLE_UNPACK_FAILED' + BUNDLE_INCOMPLETE = 'IMPORT_BUNDLE_INCOMPLETE' + BUNDLE_UNEXPECTED_CONTENT = 'IMPORT_BUNDLE_UNEXPECTED_CONTENT' + STORAGE_PREPARATION_FAILED = 'IMPORT_STORAGE_PREPARATION_FAILED' + MODEL_FILES_NOT_STORED = 'IMPORT_MODEL_FILES_NOT_STORED' + MODEL_NOT_REGISTERED = 'IMPORT_MODEL_NOT_REGISTERED' + MODEL_SETTINGS_NOT_SAVED = 'IMPORT_MODEL_SETTINGS_NOT_SAVED' + UNEXPECTED_ERROR = 'IMPORT_UNEXPECTED_ERROR' + # Codes that never reach a record's `error_message`, and are therefore absent from the catalog: + # one names the failure to own a row, one the failure to write one, one the temporary files left + # on the worker after an import that otherwise succeeded. All three live on the channel — log, + # metric, notification, Temporal error — and the third is additionally flagged in the record's + # detail as `cleanup_failed`, without a code and without changing the status. + LOG_ROW_NOT_CLAIMABLE = 'IMPORT_LOG_ROW_NOT_CLAIMABLE' + STATUS_NOT_RECORDED = 'IMPORT_STATUS_NOT_RECORDED' + CLEANUP_INCOMPLETE = 'IMPORT_CLEANUP_INCOMPLETE' + + +@dataclass(frozen=True) +class ImportFailure: + """One classification of one failure: the step, its code and the sentence it stores.""" + + step: ImportStep | None + code: ImportErrorCode + reason: str + + +_CONTACT_SUPPORT = "Contact support with this import's identifier." +_RE_EXPORT = 'Export the model again from the origin platform.' + +IMPORT_FAILURE_CATALOG: Final[Mapping[ImportStep, tuple[ImportErrorCode, str]]] = MappingProxyType( + { + ImportStep.RECEIVED: ( + ImportErrorCode.REQUEST_INVALID, + 'The import could not be started because the request was incomplete or ' + 'contradictory. Nothing was created. Submit the import again.', + ), + ImportStep.DOWNLOAD: ( + ImportErrorCode.FILE_UNREADABLE, + 'The uploaded file could not be read, or it is not the file that was sent. ' + 'Upload it again.', + ), + ImportStep.DECRYPTION: ( + ImportErrorCode.BUNDLE_CANNOT_BE_OPENED, + 'This file could not be opened: the password is wrong, or the file is damaged or ' + 'was altered. Confirm the password with whoever exported the model, then try ' + 'again.', + ), + ImportStep.ARCHIVE_INSPECTION: ( + ImportErrorCode.BUNDLE_REJECTED, + "The file's contents did not pass the platform's safety checks, so it was not " + f'opened. {_RE_EXPORT}', + ), + ImportStep.EXTRACTION: ( + ImportErrorCode.BUNDLE_UNPACK_FAILED, + f'The file could not be unpacked safely and nothing from it was kept. {_RE_EXPORT}', + ), + ImportStep.STRUCTURE_VALIDATION: ( + ImportErrorCode.BUNDLE_INCOMPLETE, + 'This file is not a complete model export — part of what the platform needs is ' + f'missing from it. {_RE_EXPORT}', + ), + ImportStep.CONTENT_POLICY: ( + ImportErrorCode.BUNDLE_UNEXPECTED_CONTENT, + 'The file contains something a model export should not contain, so it was ' + f'rejected. {_RE_EXPORT}', + ), + ImportStep.EXPERIMENT_CREATION: ( + ImportErrorCode.STORAGE_PREPARATION_FAILED, + 'The platform could not prepare a place to keep this model. The import stopped ' + f'and no model was created. {_CONTACT_SUPPORT}', + ), + ImportStep.ARTIFACT_UPLOAD: ( + ImportErrorCode.MODEL_FILES_NOT_STORED, + "The model's files could not be stored on this platform. The import stopped and " + f'the model is not available. {_CONTACT_SUPPORT}', + ), + ImportStep.REGISTRATION: ( + ImportErrorCode.MODEL_NOT_REGISTERED, + "The model's files were stored, but the model itself could not be registered, so " + f'it cannot be used. {_CONTACT_SUPPORT}', + ), + ImportStep.MODEL_DOCUMENT: ( + ImportErrorCode.MODEL_SETTINGS_NOT_SAVED, + 'The model was registered, but the settings that tell the platform how to run it ' + f'could not be saved. {_CONTACT_SUPPORT}', + ), + } +) + +UNCLASSIFIED_FAILURE: Final[tuple[ImportErrorCode, str]] = ( + ImportErrorCode.UNEXPECTED_ERROR, + f'The import stopped for an unexpected reason and no model was created. {_CONTACT_SUPPORT}', +) + +# `cleanup` deletes temporary files on the worker. It is never the reported failure: when every +# other step succeeded and only cleanup failed, the import is a success and the leak is a log line. +# So it deliberately has no user-facing sentence, and the catalog-completeness test knows it. +STEPS_WITHOUT_A_USER_FACING_REASON: Final[frozenset[ImportStep]] = frozenset({ImportStep.CLEANUP}) + + +def classify_import_failure( + step: ImportStep | None, exception: BaseException | None = None +) -> ImportFailure: + """Turn a failure into the one code and the one sentence that will be recorded. + + The exception's text is **ignored entirely** — it is what carries paths, table names and type + names, and it belongs in the log. What decides the outcome is the step. + + Args: + step: the step that was being attempted, or `None` when none had been entered. + exception: the failure, when there is one to hand. It is accepted so callers read as + "classify this failure" and never absent because the classification needs it: the + catalog is keyed by step, and an exception cannot cross an activity boundary with its + type intact anyway — the terminal write classifies from the step alone. + + Returns: + ImportFailure: step, code and the reason to store. + """ + del exception # the catalog is keyed by step; the exception's text never reaches the record + if step is None or step not in IMPORT_FAILURE_CATALOG: + code, reason = UNCLASSIFIED_FAILURE + return ImportFailure(step=step, code=code, reason=reason) + code, reason = IMPORT_FAILURE_CATALOG[step] + return ImportFailure(step=step, code=code, reason=reason) + + +class ImportLogRowNotClaimableError(ApplicationError): + """The record named by `import_run_id` is not this workflow's to write. + + Raised when the claim affects zero rows: no such row, a `TRAIN` row, or a row already running + under another workflow. Non-retryable on purpose — a second attempt would read the same rows and + reach the same conclusion — and loud everywhere except the row itself, which belongs to someone + else. + """ + + def __init__(self, message: str, *, import_run_id: Any = None, observed: str = '') -> None: + super().__init__( + message, + type='ImportLogRowNotClaimableError', + non_retryable=True, + ) + self.import_run_id = import_run_id + self.observed = observed + + +class ImportStatusRejectedError(ApplicationError): + """The database accepted the connection and refused the statement. + + A `CHECK` violation, an undefined column, a value too long for its column. `V12` not being + applied yet lands here, at the terminal write, and it must fail on the first attempt instead of + spending the whole retry envelope to say so. + """ + + def __init__(self, message: str) -> None: + super().__init__(message, type='ImportStatusRejectedError', non_retryable=True) + + +class ImportInputError(ApplicationError): + """The request is incomplete or contradictory. Fails at `received`, on the claimed row.""" + + def __init__(self, message: str) -> None: + super().__init__( + message, + {'step': ImportStep.RECEIVED.value, 'gate': None}, + type='ImportInputError', + non_retryable=True, + ) + + +class ImportGateRejectedError(ApplicationError): + """A gate rejected the bundle, and the error says which one. + + Gates 2 to 7 all run inside one activity, so the step the workflow was *attempting* is not + precise enough to record. An exception loses its Python type crossing an activity boundary but + keeps its `details`, so the step and the gate travel there and the terminal write can name them. + """ + + def __init__( + self, + message: str, + *, + step: ImportStep, + gate: int | None = None, + cause: str | None = None, + ) -> None: + super().__init__( + message, + {'step': step.value, 'gate': gate, 'cause': cause}, + type='ImportGateRejectedError', + non_retryable=True, + ) + self.step = step + self.gate = gate + + +def failure_location(exception: BaseException | None) -> tuple[str | None, int | None]: + """Read the step and gate an activity's failure carries, if it carried any. + + Walks the `__cause__` chain because Temporal wraps an activity failure in an `ActivityError`. + Returns `(None, None)` when the failure did not say where it happened, and the caller then falls + back to the step it was attempting. + """ + seen = 0 + candidate: BaseException | None = exception + while candidate is not None and seen < 10: + details = getattr(candidate, 'details', None) + if details and isinstance(details[0], dict) and 'step' in details[0]: + location = details[0] + gate = location.get('gate') + return location.get('step'), int(gate) if isinstance(gate, int) else None + candidate = candidate.__cause__ + seen += 1 + return None, None + + +class FailureSinks(Protocol): + """What the channel needs from its caller: the repository's existing observability surface.""" + + def error(self, message: str, metadata: dict | None = None) -> None: ... + + def get_core_labels(self, metadata: dict | None = None, operation_type: str = '-') -> dict: ... + + async def emit_metric( + self, + metric_object: Any, + tags: dict[str, Any], + method: str = 'inc', + value: float | None = None, + ) -> None: ... + + async def send_notification_async( + self, + metadata: dict, + notification_id: str, + message: str, + block: str, + level: str = 'INFO', + attachment_content: str | None = None, + ) -> None: ... + + +async def report_import_failure( + sinks: FailureSinks, + step: ImportStep | None, + exception: BaseException | None, + *, + import_run_id: Any, + workflow_id: str, + metadata: dict[str, Any] | None = None, + code: ImportErrorCode | None = None, + marker: str | None = None, + verdict: str | None = None, + detail_text: str | None = None, +) -> ImportFailure: + """Report one failure through every sink, exactly once. + + Sinks, in order: a structured log carrying the ids, the step, the code **and** the exception + with its traceback (the only sink that gets the exception text); the import error metric, with + the code in `operation_type`; a notification through the existing handler. The exception itself + reaches Temporal by being raised, and the record is the fifth, optional sink — the terminal + write, when the row can be written at all. + + Args: + sinks: the activity providing logger, metrics and notifications. + step: the pipeline step, or `None` for a failure that is not one (see `marker`). + exception: the failure. + import_run_id: the record's id, for correlation. + workflow_id: the identifier a user quotes to support. + metadata: workflow metadata for labels and the notification. + code: an explicit code, for the two failures that are not pipeline steps. + marker: `not_claimable` or `status_write` — what a step name would have been. + verdict: for a failed terminal write, the verdict it could not record, so the outcome + survives in text even when it cannot survive in the row. + detail_text: the failure as text, for the callers whose exception object is already gone — + anything that crossed an activity boundary. Used in place of a traceback. + + Returns: + ImportFailure: the classification that was reported. + """ + failure = classify_import_failure(step, exception) + if code is not None: + failure = ImportFailure(step=step, code=code, reason=failure.reason) + + where = marker or (step.value if step else 'unknown') + if exception is not None: + detail = ''.join(traceback.format_exception(exception)).strip() + else: + detail = detail_text or 'no exception object available' + verdict_line = f' verdict_not_recorded={verdict}' if verdict else '' + sinks.error( + f'model import failed: step={where} code={failure.code.value} ' + f'import_run_id={import_run_id} workflow_id={workflow_id}{verdict_line}\n{detail}', + metadata or {}, + ) + + labels = sinks.get_core_labels(metadata or {}, operation_type=failure.code.value) + await sinks.emit_metric(metric_object=metrics.MODEL_IMPORT_ERROR_COUNT, tags=labels) + await sinks.send_notification_async( + metadata=metadata or {}, + notification_id=failure.code.value, + message=failure.reason, + block=where, + level='ERROR', + ) + return failure diff --git a/laborious/metrics.py b/laborious/metrics.py index 74a9189..3f4de2c 100644 --- a/laborious/metrics.py +++ b/laborious/metrics.py @@ -183,6 +183,45 @@ MODEL_ANALYZE_ERROR_COUNT = Counter( ) +# ================== Model import metrics ================== +# +# The failing step and the error code travel in `operation_type`, the one label the shared +# `CORE_LABELS` leaves free. Nothing else about a failure is a label: an error reason is a sentence +# and an exception is a log line, neither of which belongs in a metric's cardinality. + +MODEL_IMPORT_STARTED_COUNT = Counter( + 'laborious_model_import_started_count', + 'Number of model imports that claimed their import log row', + CORE_LABELS, +) + +MODEL_IMPORT_COMPLETED_COUNT = Counter( + 'laborious_model_import_completed_count', + 'Number of model imports that reached a registered version and a model document', + CORE_LABELS, +) + +MODEL_IMPORT_ERROR_COUNT = Counter( + 'laborious_model_import_error_count', + 'Number of failed model imports, with the failing step or error code in operation_type', + CORE_LABELS, +) + +MODEL_IMPORT_KDF_DURATION = Histogram( + 'laborious_model_import_kdf_duration_seconds', + 'Time spent deriving the bundle key with Argon2id', + CORE_LABELS, + buckets=[0.5, 1.0, 2.0, 3.0, 5.0, 8.0, 13.0, 21.0], +) + +MODEL_IMPORT_STEP_DURATION = Histogram( + 'laborious_model_import_step_duration_seconds', + 'Duration of each model import step, named in operation_type', + CORE_LABELS, + buckets=[0.1, 0.5, 1.0, 5.0, 10.0, 30.0, 60.0, 300.0], +) + + # ================== PI Web API metrics ================== PI_WEB_API_LABELS = [*CORE_LABELS, 'tag_name'] diff --git a/laborious/utils/bundle/__init__.py b/laborious/utils/bundle/__init__.py new file mode 100644 index 0000000..77e7945 --- /dev/null +++ b/laborious/utils/bundle/__init__.py @@ -0,0 +1,27 @@ +"""Reading `.sientia` bundles: the wire format, the seven gates and the step vocabulary. + +There is no writer here. The producer is the Streamlit app in `sientia-projects-templates` +(`app/src/operations/model_export/`), and a second production writer for a format with one producer +would be a fork waiting to happen. What stands in for it in the tests is +`tests/helpers/bundle_factory.py` plus the golden fixtures under `tests/fixtures/bundle/`. +""" + +from laborious.utils.bundle.format import BundleFormatError, BundleHeader +from laborious.utils.bundle.reader import ( + BundleGateError, + BundleLimits, + BundleReader, + OpenedBundle, +) +from laborious.utils.bundle.steps import GATE_TO_STEP, ImportStep + +__all__ = [ + 'GATE_TO_STEP', + 'BundleFormatError', + 'BundleGateError', + 'BundleHeader', + 'BundleLimits', + 'BundleReader', + 'ImportStep', + 'OpenedBundle', +] diff --git a/laborious/utils/bundle/format.py b/laborious/utils/bundle/format.py new file mode 100644 index 0000000..f07d2f8 --- /dev/null +++ b/laborious/utils/bundle/format.py @@ -0,0 +1,216 @@ +"""The `.sientia` wire format, as the producer writes it. + +Every constant here is copied from the producer that emits the bundles this reader opens — the +Streamlit app in `sientia-projects-templates`, modules +`app/src/operations/model_export/crypto.py` and `app/src/operations/model_export/bundle.py`. The +producer file each value came from is named next to it. The golden fixtures under +`tests/fixtures/bundle/` are the anti-drift device: if the producer's layout moves, the tests here +fail against the committed bytes instead of failing in production. + +This module deliberately imports nothing from Laborious, Temporal, MLflow or libsodium: it is pure +`struct` and `hashlib` over bytes, so it stays liftable into a shared package if the format ever +gets one (the cancelled QTZPOC-15). `reader.py` is where the crypto lives. +""" + +from __future__ import annotations + +import hashlib +import struct +from dataclasses import dataclass +from pathlib import Path +from typing import Final + +# --- Producer: app/src/operations/model_export/crypto.py --- +MAGIC: Final[bytes] = b'SIENTIA1' +FORMAT_VERSION: Final[int] = 1 +AEAD_ID_XCHACHA20POLY1305_SECRETSTREAM: Final[int] = 1 +KDF_ID_ARGON2ID: Final[int] = 1 +SALT_SIZE: Final[int] = 16 +DIGEST_SIZE: Final[int] = 32 +RESERVED_SIGNATURE_SIZE: Final[int] = 64 +# `KDF_MEMLIMIT_BYTES` / `KDF_OPSLIMIT` on the producer: what it writes is also the ceiling the +# reader refuses to exceed, so header bytes cannot buy an arbitrary allocation on the worker. +MAX_KDF_MEMLIMIT: Final[int] = 256 * 1024 * 1024 # 268435456 +MAX_KDF_OPSLIMIT: Final[int] = 3 +# `KDF_PARALLELISM_RESERVED`: libsodium's `crypto_pwhash` has no lane parameter, so the field is +# reserved and must be exactly 1. +PARALLELISM: Final[int] = 1 +CHUNK_SIZE: Final[int] = 64 * 1024 # 65536, fixed by the format +KEY_SIZE: Final[int] = 32 # crypto_secretstream_xchacha20poly1305_KEYBYTES + +# magic(8s) version(B) aead_id(B) kdf_id(B) salt(16s) memlimit(Q) opslimit(Q) +# parallelism(B) digest(32s) reserved_signature(64s) — producer's `_HEADER_STRUCT`. +HEADER_STRUCT: Final[struct.Struct] = struct.Struct('>8sBBB16sQQB32s64s') +HEADER_SIZE: Final[int] = HEADER_STRUCT.size # 140 + +# --- libsodium constants the framing depends on --- +# Not producer constants, but constants of the primitive the producer uses. Kept as literals so +# this module stays import-free; `tests/laborious/utils/bundle/test_format.py` asserts they equal +# the values `nacl.bindings` reports, which is what makes the literals safe. +STREAM_HEADER_SIZE: Final[int] = 24 # crypto_secretstream_xchacha20poly1305_HEADERBYTES +ABYTES: Final[int] = 17 # crypto_secretstream_xchacha20poly1305_ABYTES + +# The pre-upload check prefix of `08-security-and-encryption.md` § 5.3: enough bytes to hold the +# header, the stream header and one full chunk. Derived from constants, never read from the file. +PREFIX_SIZE: Final[int] = HEADER_SIZE + STREAM_HEADER_SIZE + CHUNK_SIZE + ABYTES # 65717 + +# The digest covers the chunk stream only. The producer computes `sha256(ciphertext)` over the +# joined chunks and *then* writes `header + stream_header + ciphertext`, so on disk the covered +# range starts after both headers. Verified against a bundle produced by the producer's own code: +# `sha256(file[164:])` matches the header field and `sha256(file[140:])` does not. +DIGEST_OFFSET: Final[int] = HEADER_SIZE + STREAM_HEADER_SIZE # 164 + +MIN_FILE_SIZE: Final[int] = HEADER_SIZE + STREAM_HEADER_SIZE + +SUPPORTED_FORMAT_VERSIONS: Final[frozenset[int]] = frozenset({FORMAT_VERSION}) +SUPPORTED_AEAD_IDS: Final[frozenset[int]] = frozenset({AEAD_ID_XCHACHA20POLY1305_SECRETSTREAM}) +SUPPORTED_KDF_IDS: Final[frozenset[int]] = frozenset({KDF_ID_ARGON2ID}) + +_HASH_BLOCK_SIZE: Final[int] = 1024 * 1024 + + +class BundleFormatError(Exception): + """The bytes are not a `.sientia` bundle this reader implements. + + Raised for every header-level rejection: wrong magic, unknown version, unknown primitive ids, + cost parameters above the ceiling, a reserved field carrying a value, a file too short to hold + a header, or a chunk stream whose digest does not match the header's. `reader.py` turns this + into a gate-2 rejection; nothing here knows about gates or import steps. + """ + + +@dataclass(frozen=True) +class BundleHeader: + """The 140-byte plaintext header, parsed and validated.""" + + format_version: int + aead_id: int + kdf_id: int + salt: bytes + kdf_memlimit_bytes: int + kdf_opslimit: int + kdf_parallelism: int + ciphertext_digest: bytes + reserved_signature: bytes + + +def parse_header(raw: bytes) -> BundleHeader: + """Parse and validate the plaintext header. + + Everything the reader trusts about the file's shape is decided here, before any offset past + the header is used and before a key is derived. + + Args: + raw: at least `HEADER_SIZE` bytes read from the start of the object. + + Returns: + BundleHeader: the validated header. + + Raises: + BundleFormatError: on any invalid or unsupported field. + """ + if len(raw) < HEADER_SIZE: + raise BundleFormatError( + f'file is shorter than the {HEADER_SIZE}-byte header ({len(raw)} bytes available)' + ) + + ( + magic, + format_version, + aead_id, + kdf_id, + salt, + kdf_memlimit_bytes, + kdf_opslimit, + kdf_parallelism, + ciphertext_digest, + reserved_signature, + ) = HEADER_STRUCT.unpack(raw[:HEADER_SIZE]) + + if magic != MAGIC: + raise BundleFormatError('file does not start with the expected bundle marker') + if format_version not in SUPPORTED_FORMAT_VERSIONS: + raise BundleFormatError(f'unsupported bundle format version: {format_version}') + if aead_id not in SUPPORTED_AEAD_IDS: + raise BundleFormatError(f'unsupported encryption identifier: {aead_id}') + if kdf_id not in SUPPORTED_KDF_IDS: + raise BundleFormatError(f'unsupported key derivation identifier: {kdf_id}') + if kdf_memlimit_bytes > MAX_KDF_MEMLIMIT: + raise BundleFormatError( + f'declared key derivation memory {kdf_memlimit_bytes} exceeds the ' + f'{MAX_KDF_MEMLIMIT} ceiling' + ) + if kdf_opslimit > MAX_KDF_OPSLIMIT: + raise BundleFormatError( + f'declared key derivation passes {kdf_opslimit} exceeds the {MAX_KDF_OPSLIMIT} ceiling' + ) + if kdf_memlimit_bytes <= 0 or kdf_opslimit <= 0: + raise BundleFormatError('key derivation parameters must be positive') + if kdf_parallelism != PARALLELISM: + raise BundleFormatError( + f'reserved parallelism field must be {PARALLELISM}, found {kdf_parallelism}' + ) + if reserved_signature != bytes(RESERVED_SIGNATURE_SIZE): + raise BundleFormatError('reserved signature field must be empty in this format version') + + return BundleHeader( + format_version=format_version, + aead_id=aead_id, + kdf_id=kdf_id, + salt=salt, + kdf_memlimit_bytes=kdf_memlimit_bytes, + kdf_opslimit=kdf_opslimit, + kdf_parallelism=kdf_parallelism, + ciphertext_digest=ciphertext_digest, + reserved_signature=reserved_signature, + ) + + +def read_header(path: Path | str) -> BundleHeader: + """Read and validate the header of the file at `path`. + + Raises: + BundleFormatError: when the file cannot hold a header plus a stream header, or when the + header itself is invalid. + """ + file_path = Path(path) + size = file_path.stat().st_size + if size < MIN_FILE_SIZE: + raise BundleFormatError( + f'file is {size} bytes, smaller than the {MIN_FILE_SIZE} bytes a bundle needs for its ' + 'header and encrypted stream header' + ) + with file_path.open('rb') as handle: + return parse_header(handle.read(HEADER_SIZE)) + + +def compute_ciphertext_digest(path: Path | str) -> bytes: + """SHA-256 of the chunk stream — the bytes from `DIGEST_OFFSET` to the end of the file.""" + digest = hashlib.sha256() + with Path(path).open('rb') as handle: + handle.seek(DIGEST_OFFSET) + while block := handle.read(_HASH_BLOCK_SIZE): + digest.update(block) + return digest.digest() + + +def verify_ciphertext_digest(path: Path | str, header: BundleHeader) -> None: + """Compare the file's chunk-stream digest against the header's. + + Raises: + BundleFormatError: when they differ — a truncated, overwritten or corrupted transfer. This + runs before any key derivation, so a damaged file never costs an Argon2id pass. + """ + if compute_ciphertext_digest(path) != header.ciphertext_digest: + raise BundleFormatError( + 'the encrypted content does not match the digest recorded in the bundle header' + ) + + +def file_digest(path: Path | str) -> str: + """Hex SHA-256 of the whole file, for the `expected_digest` comparison of gate 1.""" + digest = hashlib.sha256() + with Path(path).open('rb') as handle: + while block := handle.read(_HASH_BLOCK_SIZE): + digest.update(block) + return digest.hexdigest() diff --git a/laborious/utils/bundle/reader.py b/laborious/utils/bundle/reader.py new file mode 100644 index 0000000..61fcd90 --- /dev/null +++ b/laborious/utils/bundle/reader.py @@ -0,0 +1,473 @@ +"""Reading a `.sientia` bundle: the seven gates, in order, in one function. + +`BundleReader.open()` is the only entry point. Gate ordering is a property of that function rather +than of the caller, so it cannot be reordered by accident and can be unit-tested without Temporal: + +1. object-level limits (`check_object_limits`, called by the download activity before the body is + fetched — the only gate that does not need the file) +2. header validation, including the chunk-stream digest, before any key derivation +3. AEAD decryption — the integrity check; a wrong password and a tampered file fail identically +4. archive inspection over the central directory, writing nothing to disk +5. extraction into a fresh, isolated directory +6. structure and schema of the extracted tree +7. content policy over the file set + +Nothing here logs, notifies or emits a metric: rejections are raised as `BundleGateError` and the +activity layer owns the one failure channel. +""" + +from __future__ import annotations + +import json +import os +import shutil +import stat +import time +import unicodedata +import zipfile +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +import nacl.bindings as sodium +import nacl.exceptions + +from laborious.utils.bundle import format as fmt +from laborious.utils.bundle.steps import GATE_TO_STEP, ImportStep + +# Gate 6 — the layout `bundle.build_zip` writes and `model_repository.py` reads back. +METADATA_FILE_NAME = 'metadata.json' +ARTIFACTS_DIR_NAME = 'artifacts' +REQUIRED_TOP_LEVEL_KEYS = frozenset({'parameters', 'metadata'}) +REQUIRED_METADATA_FIELDS = ( + 'model_name', + 'experiment_name', + 'run_id', + 'model_version', + 'model_project', + 'export_timestamp', +) +REQUIRED_PARAMETERS = ('target_variable',) +REQUIRED_MODEL_DIRS = ('prediction_model', 'data_model') +REQUIRED_MODEL_FILES = ('MLmodel', 'model.pkl') + +# The import record's `experiment_name` column is `VARCHAR(50)` with a length-3 floor, and the name +# is written onto the record before provisioning starts — so the column's limits are enforced here, +# not three activities later as a constraint violation. +EXPERIMENT_NAME_MIN_LENGTH = 3 +EXPERIMENT_NAME_MAX_LENGTH = 50 + +# Gate 7 — an MLflow 2.x artifact tree for this platform: the model directories' known files, the +# model card, the CSVs the training template logs and the bundle's own metadata. +ALLOWED_FILE_NAMES = frozenset( + { + 'MLmodel', + 'metadata.json', + 'model.pkl', + 'conda.yaml', + 'python_env.yaml', + 'requirements.txt', + 'model_card.json', + 'model_card.svg', + } +) +ALLOWED_FILE_SUFFIXES = frozenset({'.csv', '.json', '.yaml', '.yml', '.txt', '.pkl', '.svg', '.md'}) + +_DECRYPT_FAILURE_MESSAGE = ( + 'could not open this bundle: wrong password, or the file is damaged or was altered' +) + + +class BundleGateError(Exception): + """A gate rejected the bundle. + + Carries the gate number and the import step that gate maps onto, so the workflow never has to + guess which step to record. + """ + + def __init__(self, message: str, *, gate: int, step: ImportStep | None = None) -> None: + super().__init__(message) + self.gate = gate + self.step = step if step is not None else GATE_TO_STEP[gate] + + +@dataclass(frozen=True) +class BundleLimits: + """Ceilings enforced by gates 1 and 4. Values come from `build_import_config()`.""" + + max_object_bytes: int = 1024 * 1024 * 1024 + max_entries: int = 5000 + max_uncompressed_bytes: int = 4 * 1024 * 1024 * 1024 + max_compression_ratio: float = 200.0 + object_prefix: str = 'imported_models/' + object_suffix: str = '.sientia' + + +@dataclass(frozen=True) +class OpenedBundle: + """What a bundle yields once every gate has passed. Nothing secret is carried.""" + + extracted_dir: Path + metadata: dict[str, Any] = field(default_factory=dict) + parameters: dict[str, Any] = field(default_factory=dict) + digest: str = '' + header: fmt.BundleHeader | None = None + # How long Argon2id took, so the worker's most expensive step is measurable without the + # activity layer having to reach inside the reader. + kdf_seconds: float = 0.0 + + +class BundleReader: + """Opens `.sientia` bundles produced by the exporter in `sientia-projects-templates`.""" + + def __init__(self, limits: BundleLimits | None = None) -> None: + self.limits = limits or BundleLimits() + + # ------------------------------------------------------------------ gate 1 + + def check_object_limits(self, *, size: int, key: str) -> None: + """Gate 1, object level: reject before the object's body is fetched. + + Args: + size: the object's size as reported by `stat_object`. + key: the object key. + + Raises: + BundleGateError: oversized object, key outside the import prefix, or wrong suffix. + """ + if size > self.limits.max_object_bytes: + raise BundleGateError( + f'uploaded object is {size} bytes, above the ' + f'{self.limits.max_object_bytes} byte limit', + gate=1, + ) + if size <= 0: + raise BundleGateError('uploaded object is empty', gate=1) + if not key.startswith(self.limits.object_prefix): + raise BundleGateError('uploaded object is not under the import prefix', gate=1) + if not key.endswith(self.limits.object_suffix): + raise BundleGateError( + f'uploaded object does not end in {self.limits.object_suffix}', gate=1 + ) + + # --------------------------------------------------------------------- open + + def open( + self, + encrypted_path: Path | str, + password: str, + extract_root: Path | str, + work_dir: Path | str | None = None, + ) -> OpenedBundle: + """Run gates 2 to 7 in order and return the opened bundle. + + Args: + encrypted_path: the downloaded `.sientia` file. + password: the plaintext bundle password. Never stored, never logged, never returned. + extract_root: the directory to extract into. It MUST NOT exist yet (gate 5). + work_dir: where the decrypted zip is written. Defaults to `extract_root`'s parent. + + Returns: + OpenedBundle: extracted tree, `metadata`, `parameters`, digest and header. + + Raises: + BundleGateError: with the gate that rejected the bundle and its import step. + """ + encrypted = Path(encrypted_path) + root = Path(extract_root) + staging = Path(work_dir) if work_dir is not None else root.parent + + header = self._gate_2_header(encrypted) + zip_path, kdf_seconds = self._gate_3_decrypt(encrypted, password, header, staging) + try: + self._gate_4_inspect(zip_path) + self._gate_5_extract(zip_path, root) + metadata, parameters = self._gate_6_structure(root) + self._gate_7_content_policy(root) + except BaseException: + # A rejection after decryption leaves nothing extracted behind (gate 5's contract) and + # never leaves a decrypted zip on the worker for the next import to trip over. + shutil.rmtree(root, ignore_errors=True) + zip_path.unlink(missing_ok=True) + raise + zip_path.unlink(missing_ok=True) + + return OpenedBundle( + extracted_dir=root, + metadata=metadata, + parameters=parameters, + digest=header.ciphertext_digest.hex(), + header=header, + kdf_seconds=kdf_seconds, + ) + + # ------------------------------------------------------------------ gate 2 + + def _gate_2_header(self, encrypted: Path) -> fmt.BundleHeader: + """Parse the header and check the chunk-stream digest, before deriving anything.""" + try: + header = fmt.read_header(encrypted) + fmt.verify_ciphertext_digest(encrypted, header) + except fmt.BundleFormatError as error: + raise BundleGateError(str(error), gate=2) from error + except OSError as error: + raise BundleGateError('downloaded bundle could not be read', gate=2) from error + return header + + # ------------------------------------------------------------------ gate 3 + + def _gate_3_decrypt( + self, encrypted: Path, password: str, header: fmt.BundleHeader, staging: Path + ) -> tuple[Path, float]: + """Derive the key with the header's parameters and decrypt the stream to a file. + + The AEAD *is* the integrity check: a wrong password and a tampered byte fail here with the + same message, which never claims to distinguish them. + """ + staging.mkdir(parents=True, exist_ok=True) + zip_path = staging / 'bundle.zip' + try: + started = time.monotonic() + key = self._derive_key(password, header) + kdf_seconds = time.monotonic() - started + self._pull_stream(encrypted, key, zip_path) + except BundleGateError: + zip_path.unlink(missing_ok=True) + raise + except (nacl.exceptions.CryptoError, ValueError, RuntimeError) as error: + zip_path.unlink(missing_ok=True) + raise BundleGateError(_DECRYPT_FAILURE_MESSAGE, gate=3) from error + except OSError as error: + zip_path.unlink(missing_ok=True) + raise BundleGateError('decrypted bundle could not be written', gate=3) from error + return zip_path, kdf_seconds + + @staticmethod + def _derive_key(password: str, header: fmt.BundleHeader) -> bytes: + """Argon2id over the NFC-normalised password, with the header's own cost parameters. + + The password is normalised and **not** trimmed, matching the producer's `_derive_key`: the + same password typed on another OS must derive the same key, while a trailing space is part + of the secret. + """ + normalized = unicodedata.normalize('NFC', password) + return sodium.crypto_pwhash_alg( + fmt.KEY_SIZE, + normalized.encode('utf-8'), + header.salt, + header.kdf_opslimit, + header.kdf_memlimit_bytes, + sodium.crypto_pwhash_ALG_ARGON2ID13, + ) + + @staticmethod + def _pull_stream(encrypted: Path, key: bytes, zip_path: Path) -> None: + """Decrypt the chunk stream into `zip_path`, requiring the FINAL tag.""" + chunk_size = fmt.CHUNK_SIZE + fmt.ABYTES + state = sodium.crypto_secretstream_xchacha20poly1305_state() + with encrypted.open('rb') as source, zip_path.open('wb') as target: + source.seek(fmt.HEADER_SIZE) + stream_header = source.read(fmt.STREAM_HEADER_SIZE) + sodium.crypto_secretstream_xchacha20poly1305_init_pull(state, stream_header, key) + last_tag: int | None = None + while chunk := source.read(chunk_size): + plaintext, last_tag = sodium.crypto_secretstream_xchacha20poly1305_pull( + state, chunk + ) + target.write(plaintext) + if last_tag != sodium.crypto_secretstream_xchacha20poly1305_TAG_FINAL: + # A stream that stops without its FINAL tag was cut short. The digest already catches + # truncation of a stored file; this catches a stream that was framed to look complete. + raise BundleGateError(_DECRYPT_FAILURE_MESSAGE, gate=3) + + # ------------------------------------------------------------------ gate 4 + + def _gate_4_inspect(self, zip_path: Path) -> None: + """Inspect the central directory only. Nothing is written to disk by this gate.""" + try: + with zipfile.ZipFile(zip_path) as archive: + entries = archive.infolist() + except zipfile.BadZipFile as error: + raise BundleGateError('bundle content is not a readable archive', gate=4) from error + + if len(entries) > self.limits.max_entries: + raise BundleGateError( + f'archive declares {len(entries)} entries, above the ' + f'{self.limits.max_entries} limit', + gate=4, + ) + if not entries: + raise BundleGateError('archive is empty', gate=4) + + uncompressed = sum(entry.file_size for entry in entries) + compressed = sum(entry.compress_size for entry in entries) + if uncompressed > self.limits.max_uncompressed_bytes: + raise BundleGateError( + f'archive declares {uncompressed} uncompressed bytes, above the ' + f'{self.limits.max_uncompressed_bytes} limit', + gate=4, + ) + ratio = uncompressed / max(compressed, 1) + if ratio > self.limits.max_compression_ratio: + raise BundleGateError( + f'archive compression ratio {ratio:.1f} is above the ' + f'{self.limits.max_compression_ratio} limit', + gate=4, + ) + + for entry in entries: + self._check_entry_name(entry.filename) + self._check_entry_mode(entry) + + @staticmethod + def _check_entry_name(name: str) -> None: + """Reject absolute names and traversal, from the declared name alone.""" + normalised = name.replace('\\', '/') + if normalised.startswith('/') or (len(normalised) > 1 and normalised[1] == ':'): + raise BundleGateError('archive declares an absolute entry path', gate=4) + if any(part == '..' for part in normalised.split('/')): + raise BundleGateError('archive declares an entry escaping its own tree', gate=4) + + @staticmethod + def _check_entry_mode(entry: zipfile.ZipInfo) -> None: + """Reject symlinks and anything that is neither a regular file nor a directory.""" + mode = entry.external_attr >> 16 + if stat.S_IFMT(mode) == 0: + # No file-type bits stored. This is the normal case for two kinds of entry: a zip + # written by a tool that records no Unix mode at all (`mode == 0`), and + # `ZipFile.writestr`, which stores permissions only — the producer's `metadata.json` + # arrives as `0o600 << 16`. Nothing to check: the member is extracted as a plain file + # with the worker's own permissions either way. + return + if stat.S_ISLNK(mode): + raise BundleGateError('archive declares a symbolic link', gate=4) + if not (stat.S_ISREG(mode) or stat.S_ISDIR(mode)): + raise BundleGateError('archive declares an entry that is not a regular file', gate=4) + + # ------------------------------------------------------------------ gate 5 + + def _gate_5_extract(self, zip_path: Path, root: Path) -> None: + """Extract into a fresh directory, containing every member by resolved path. + + A pre-existing extraction root is a failure rather than something to clear: it means + another import, or a previous attempt, owns that path. + """ + if root.exists(): + raise BundleGateError('extraction directory already exists', gate=5) + root.mkdir(parents=True) + resolved_root = root.resolve() + try: + with zipfile.ZipFile(zip_path) as archive: + for entry in archive.infolist(): + self._extract_member(archive, entry, root, resolved_root) + except BundleGateError: + shutil.rmtree(root, ignore_errors=True) + raise + except (OSError, zipfile.BadZipFile) as error: + shutil.rmtree(root, ignore_errors=True) + raise BundleGateError('bundle content could not be unpacked', gate=5) from error + + @staticmethod + def _extract_member( + archive: zipfile.ZipFile, entry: zipfile.ZipInfo, root: Path, resolved_root: Path + ) -> None: + """Write one member, re-checking containment on the resolved path.""" + name = entry.filename.replace('\\', '/') + target = root / name + resolved = Path(os.path.realpath(target)) + if resolved != resolved_root and resolved_root not in resolved.parents: + raise BundleGateError('archive member resolves outside the extraction root', gate=5) + if entry.is_dir(): + resolved.mkdir(parents=True, exist_ok=True) + return + if resolved.exists(): + raise BundleGateError('archive member would overwrite an existing file', gate=5) + resolved.parent.mkdir(parents=True, exist_ok=True) + # Written as a plain file: the archive's stored permissions are never restored, so no + # member can arrive executable. + with archive.open(entry) as source, resolved.open('wb') as sink: + shutil.copyfileobj(source, sink) + + # ------------------------------------------------------------------ gate 6 + + def _gate_6_structure(self, root: Path) -> tuple[dict[str, Any], dict[str, Any]]: + """Validate the extracted layout and the metadata schema.""" + metadata_path = root / METADATA_FILE_NAME + artifacts_dir = root / ARTIFACTS_DIR_NAME + if not metadata_path.is_file(): + raise BundleGateError('bundle has no metadata document at its root', gate=6) + if not artifacts_dir.is_dir(): + raise BundleGateError('bundle has no artifacts directory', gate=6) + + document = self._load_metadata_document(metadata_path) + metadata = document['metadata'] + parameters = document['parameters'] + + self._check_metadata_fields(metadata) + self._check_parameters(parameters) + self._check_model_directories(artifacts_dir) + return metadata, parameters + + @staticmethod + def _load_metadata_document(metadata_path: Path) -> dict[str, Any]: + """Read `metadata.json` and check its two top-level keys.""" + try: + document = json.loads(metadata_path.read_text(encoding='utf-8')) + except (json.JSONDecodeError, UnicodeDecodeError, OSError) as error: + raise BundleGateError('bundle metadata is not readable JSON', gate=6) from error + if not isinstance(document, dict) or set(document) != REQUIRED_TOP_LEVEL_KEYS: + raise BundleGateError( + 'bundle metadata does not carry exactly the parameters and metadata blocks', gate=6 + ) + if not isinstance(document['metadata'], dict) or not isinstance( + document['parameters'], dict + ): + raise BundleGateError('bundle metadata blocks are not objects', gate=6) + return document + + @staticmethod + def _check_metadata_fields(metadata: dict[str, Any]) -> None: + """Every origin field the import records must be present and usable.""" + for field_name in REQUIRED_METADATA_FIELDS: + value = metadata.get(field_name) + if not isinstance(value, str) or not value.strip(): + raise BundleGateError(f'bundle metadata is missing the {field_name} field', gate=6) + experiment_name = metadata['experiment_name'] + if not (EXPERIMENT_NAME_MIN_LENGTH <= len(experiment_name) <= EXPERIMENT_NAME_MAX_LENGTH): + raise BundleGateError( + "bundle experiment name does not fit the platform's limits", gate=6 + ) + + @staticmethod + def _check_parameters(parameters: dict[str, Any]) -> None: + """`target_variable` is what `model_config.target` is built from; nothing else supplies it.""" + for name in REQUIRED_PARAMETERS: + value = parameters.get(name) + if value is None or not str(value).strip(): + raise BundleGateError(f'bundle parameters are missing {name}', gate=6) + + @staticmethod + def _check_model_directories(artifacts_dir: Path) -> None: + """`prediction_model` and `data_model` must each be a loadable MLflow model directory.""" + for directory in REQUIRED_MODEL_DIRS: + model_dir = artifacts_dir / directory + if not model_dir.is_dir(): + raise BundleGateError(f'bundle has no {directory} artifacts', gate=6) + for required in REQUIRED_MODEL_FILES: + if not (model_dir / required).is_file(): + raise BundleGateError(f'bundle {directory} artifacts are incomplete', gate=6) + + # ------------------------------------------------------------------ gate 7 + + def _gate_7_content_policy(self, root: Path) -> None: + """Only the file kinds an MLflow artifact tree calls for may be present.""" + for path in sorted(root.rglob('*')): + if path.is_dir(): + continue + if not path.is_file(): + raise BundleGateError('bundle contains an entry that is not a file', gate=7) + if path.name in ALLOWED_FILE_NAMES: + continue + if path.suffix.lower() in ALLOWED_FILE_SUFFIXES: + continue + raise BundleGateError('bundle contains a file a model export should not carry', gate=7) diff --git a/laborious/utils/bundle/steps.py b/laborious/utils/bundle/steps.py new file mode 100644 index 0000000..35c2506 --- /dev/null +++ b/laborious/utils/bundle/steps.py @@ -0,0 +1,55 @@ +"""The import pipeline's step vocabulary, and the gate-to-step mapping. + +`ImportStep` is the join between three things: the machine-readable step written into the import +record's JSON detail, the key of the error catalog (one code and one sentence per step) and the +value the frontend switches on. It lives in the bundle package rather than in the activities module +because the reader raises with it — `laborious/activities/model_import.py` imports it from here so +there is exactly one definition. + +It holds **pipeline** steps only. The two failures that are not steps of the pipeline — failing to +claim the record and failing to write it — are reported by the failure channel under their own +markers (`not_claimable`, `status_write`), deliberately outside this enum so the +catalog-completeness test keeps meaning "every pipeline step has a sentence". +""" + +from __future__ import annotations + +from enum import StrEnum +from typing import Final + + +class ImportStep(StrEnum): + """Where an import stopped. Values are what gets written to the record.""" + + RECEIVED = 'received' + DOWNLOAD = 'download' + DECRYPTION = 'decryption' + ARCHIVE_INSPECTION = 'archive_inspection' + EXTRACTION = 'extraction' + STRUCTURE_VALIDATION = 'structure_validation' + CONTENT_POLICY = 'content_policy' + EXPERIMENT_CREATION = 'experiment_creation' + ARTIFACT_UPLOAD = 'artifact_upload' + REGISTRATION = 'registration' + MODEL_DOCUMENT = 'model_document' + CLEANUP = 'cleanup' + + +# The seven gates map onto the first seven steps, with gates 1 and 2 sharing one: both say "these +# are not the bytes that were sent" — an object over the size ceiling, a key with the wrong shape, a +# digest that disagrees with what the uploader computed, a wrong magic, an unsupported version, a +# chunk stream that does not match the header's digest. The catalog's `download` sentence ("The +# uploaded file could not be read, or it is not the file that was sent. Upload it again.") is true +# for all of them, and none of them may borrow the `decryption` sentence, which implicates the +# password. +GATE_TO_STEP: Final[dict[int, ImportStep]] = { + 1: ImportStep.DOWNLOAD, + 2: ImportStep.DOWNLOAD, + 3: ImportStep.DECRYPTION, + 4: ImportStep.ARCHIVE_INSPECTION, + 5: ImportStep.EXTRACTION, + 6: ImportStep.STRUCTURE_VALIDATION, + 7: ImportStep.CONTENT_POLICY, +} + +GATE_COUNT: Final[int] = 7 diff --git a/laborious/utils/connectors_config.py b/laborious/utils/connectors_config.py index ed3477a..aa2a019 100644 --- a/laborious/utils/connectors_config.py +++ b/laborious/utils/connectors_config.py @@ -1,5 +1,6 @@ import json -from os import getenv +import tempfile +from os import getenv, path from typing import Any @@ -90,3 +91,72 @@ def build_minio_config() -> dict[str, Any]: 'retention_hours': int(getenv('MINIO_RETENTION_HOURS', '24')), 'secure': getenv('MINIO_SECURE', 'false') == 'true', } + + +def build_import_config() -> dict[str, Any]: + """ + Build the `.sientia` model-import configuration from environment variables. + + The password key is read as the raw base64 string and **not** validated here: the worker must + start whether or not `IMPORT_MODEL_ENABLED` is set, and a missing or malformed key is an import + failure at the decryption step (`laborious.utils.import_password.load_envelope_key`), not a + boot failure. + + Environment Variables: + IMPORT_PASSWORD_KEY: base64 of the 32-byte AES-256-GCM key that wraps bundle passwords + IMPORT_BUNDLE_BUCKET: bucket holding the uploaded bundles (default: sientia) + IMPORT_BUNDLE_PREFIX: key prefix the uploads must sit under (default: imported_models/) + IMPORT_WORK_DIR: worker scratch directory (default: /sientia-import) + IMPORT_MAX_OBJECT_BYTES: gate 1 object size ceiling (default: 1 GiB) + IMPORT_MAX_ARCHIVE_ENTRIES: gate 4 entry count ceiling (default: 5000) + IMPORT_MAX_UNCOMPRESSED_BYTES: gate 4 uncompressed size ceiling (default: 4 GiB) + IMPORT_MAX_COMPRESSION_RATIO: gate 4 compression ratio ceiling (default: 200) + IMPORT_MODELS_COLLECTION: MongoDB collection holding the model listing (default: models) + IMPORT_BUNDLE_RETENTION_DAYS: lifecycle expiry for the uploaded bundle (default: 7) + + Returns: + dict: import configuration dictionary + """ + prefix = getenv('IMPORT_BUNDLE_PREFIX', 'imported_models/') + return { + 'password_key': getenv('IMPORT_PASSWORD_KEY'), + 'bucket': getenv('IMPORT_BUNDLE_BUCKET', 'sientia'), + 'prefix': prefix if prefix.endswith('/') else f'{prefix}/', + 'work_dir': getenv('IMPORT_WORK_DIR', path.join(tempfile.gettempdir(), 'sientia-import')), + 'max_object_bytes': int(getenv('IMPORT_MAX_OBJECT_BYTES', str(1024 * 1024 * 1024))), + 'max_entries': int(getenv('IMPORT_MAX_ARCHIVE_ENTRIES', '5000')), + 'max_uncompressed_bytes': int( + getenv('IMPORT_MAX_UNCOMPRESSED_BYTES', str(4 * 1024 * 1024 * 1024)) + ), + 'max_compression_ratio': float(getenv('IMPORT_MAX_COMPRESSION_RATIO', '200')), + 'models_collection': getenv('IMPORT_MODELS_COLLECTION', 'models'), + 'retention_days': int(getenv('IMPORT_BUNDLE_RETENTION_DAYS', '7')), + } + + +def build_import_status_config() -> dict[str, Any]: + """ + Build the connection to the import log database — a **second** Postgres connection. + + The import log is `public.experiment_run` in the database owned by the Spring Boot BFF + (`sientia-core-mlops-bff`), not the `sientia` database Laborious itself uses. The table and + schema are constants of the code, never inputs; only the connection is configuration. + + Environment Variables: + IMPORT_STATUS_DB_HOST: host (default: localhost) + IMPORT_STATUS_DB_PORT: port (default: 5432) + IMPORT_STATUS_DB_NAME: database (default: sientia-core-mlops-bff) + IMPORT_STATUS_DB_USER: user (default: mlops_bff_user) + IMPORT_STATUS_DB_PASSWORD: password (default: empty) + + Returns: + dict: import status database configuration dictionary + """ + return { + 'host': getenv('IMPORT_STATUS_DB_HOST', 'localhost'), + 'port': int(getenv('IMPORT_STATUS_DB_PORT', '5432')), + 'dbname': getenv('IMPORT_STATUS_DB_NAME', 'sientia-core-mlops-bff'), + 'user': getenv('IMPORT_STATUS_DB_USER', 'mlops_bff_user'), + 'password': getenv('IMPORT_STATUS_DB_PASSWORD', ''), + 'schema': 'public', + } diff --git a/laborious/utils/import_password.py b/laborious/utils/import_password.py new file mode 100644 index 0000000..5b61e8b --- /dev/null +++ b/laborious/utils/import_password.py @@ -0,0 +1,98 @@ +"""The bundle password envelope: AES-256-GCM, opened inside the activity that uses the password. + +The workflow only ever carries `password_envelope`, so no plaintext password reaches the Temporal +event history. This module is imported by the bundle-opening activity and by nothing else — in +particular not by `laborious/workflows/import_model.py`, which a test asserts. + +Envelope layout, chosen because WebCrypto gives the Angular frontend AES-GCM natively: + + base64( nonce(12 bytes) || ciphertext || GCM tag(16 bytes) ) + +The key is a 32-byte value held in a Kubernetes Secret and exposed as `IMPORT_PASSWORD_KEY`, +base64-encoded. Neither the key, the envelope nor the recovered password is ever logged, notified, +used as a metric label or written to the import record. +""" + +from __future__ import annotations + +import base64 +import binascii +from typing import Final + +from cryptography.exceptions import InvalidTag +from cryptography.hazmat.primitives.ciphers.aead import AESGCM + +NONCE_SIZE: Final[int] = 12 +TAG_SIZE: Final[int] = 16 +KEY_SIZE: Final[int] = 32 +MIN_ENVELOPE_SIZE: Final[int] = NONCE_SIZE + TAG_SIZE + + +class EnvelopeError(Exception): + """The envelope or its key is unusable. + + Deliberately one type for every cause — missing key, wrong key, malformed base64, altered + bytes — because the failure is reported at the decryption step either way. What it must never + be confused with is a wrong *bundle* password: that one comes from the reader's gate 3, and the + two carry different sentences. + """ + + +def load_envelope_key(raw_key: str | None) -> bytes: + """Decode the configured envelope key. + + Args: + raw_key: the value of `IMPORT_PASSWORD_KEY` — base64 of exactly 32 bytes. + + Returns: + bytes: the 32-byte key. + + Raises: + EnvelopeError: when the value is absent, not base64, or not 32 bytes long. There is no + fallback interpretation: an unset key never means "the envelope is plaintext". + """ + if not raw_key: + raise EnvelopeError('the import password key is not configured') + try: + key = base64.b64decode(raw_key, validate=True) + except (binascii.Error, ValueError) as error: + raise EnvelopeError('the import password key is not valid base64') from error + if len(key) != KEY_SIZE: + raise EnvelopeError(f'the import password key must be {KEY_SIZE} bytes') + return key + + +def decrypt_password_envelope(envelope: str, key: bytes) -> str: + """Recover the bundle password from its envelope. + + Args: + envelope: base64 of `nonce || ciphertext || tag`. + key: the 32-byte key from `load_envelope_key`. + + Returns: + str: the plaintext password, exactly as it was typed — not trimmed, not case-folded. NFC + normalisation happens at key derivation, where the producer does it. + + Raises: + EnvelopeError: malformed envelope, wrong key or altered bytes (AES-GCM authentication). + """ + if not envelope: + raise EnvelopeError('the import request carries no password envelope') + try: + payload = base64.b64decode(envelope, validate=True) + except (binascii.Error, ValueError) as error: + raise EnvelopeError('the password envelope is not valid base64') from error + if len(payload) < MIN_ENVELOPE_SIZE: + raise EnvelopeError('the password envelope is too short to hold a nonce and a tag') + + nonce, sealed = payload[:NONCE_SIZE], payload[NONCE_SIZE:] + try: + plaintext = AESGCM(key).decrypt(nonce, sealed, None) + except InvalidTag as error: + raise EnvelopeError('the password envelope could not be authenticated') from error + except ValueError as error: + raise EnvelopeError('the password envelope could not be opened') from error + try: + return plaintext.decode('utf-8') + except UnicodeDecodeError as error: + raise EnvelopeError('the recovered password is not valid UTF-8') from error diff --git a/laborious/worker/worker.py b/laborious/worker/worker.py index 7a420ba..9f76141 100644 --- a/laborious/worker/worker.py +++ b/laborious/worker/worker.py @@ -10,6 +10,8 @@ The worker supports multiple runtime-scoped task queues (via ``sientia_do.tempor - minimal_retrain-{runtime}-queue: Model retraining workflows - drift-{runtime}-queue: Drift detection workflows - simple_metrics-{runtime}-queue: Simple metrics workflows +- import_model-{runtime}-queue: `.sientia` model import — registered **only** when + ``IMPORT_MODEL_ENABLED`` is truthy, so the containment boundary stays a deployment decision ``RUNTIME`` must be set; it is passed to every ``prepare_worker`` call. Schedulers must use the same queue names (breaking change vs legacy ``drift-queue`` / ``simple_metrics-queue``). @@ -30,6 +32,9 @@ Environment Variables: - HTTP_METRICS_PORT: Prometheus metrics server port (default: 9090) - HTTP_SDK_METRICS_PORT: Temporal SDK metrics port (default: 9091) - PROJECT_NAME: Project name for notifications (default: laborious) +- IMPORT_MODEL_ENABLED: register the fifth (import) worker; unset by default +- IMPORT_PASSWORD_KEY / IMPORT_BUNDLE_* / IMPORT_STATUS_DB_*: import configuration, see + ``laborious.utils.connectors_config`` """ from temporalio import client, workflow @@ -53,11 +58,14 @@ with workflow.unsafe.imports_passed_through(): from laborious import metrics from laborious.activities.activities import Activities from laborious.utils.connectors_config import ( + build_import_config, + build_import_status_config, build_minio_config, build_mlflow_config, build_opc_config, ) from laborious.workflows.drift import Drift + from laborious.workflows.import_model import ImportModel from laborious.workflows.minimal_retrain import MinimalRetrain from laborious.workflows.predictions_batch import PredictionsBatch from laborious.workflows.simple_metrics import SimpleMetrics @@ -137,6 +145,9 @@ async def main(): pi_web_api_config=build_api_config(), logger=logger, notification_handler=notification_handler, + mongo_config=mongo_config, + import_config=build_import_config(), + import_status_config=build_import_status_config(), ) logger.custom_info('Initializing OPC...', metadata_runtime) @@ -235,6 +246,42 @@ async def main(): ), ] + # The fifth worker is opt-in: containment (QTZPOC-19) wants the importer in its own runtime with + # its own credentials, and a flag lets that be a deployment decision instead of forcing every + # Laborious runtime to poll the import queue today. Unset, the four workers above are unaffected + # and no import queue is polled at all. + if os.getenv('IMPORT_MODEL_ENABLED', '').strip().lower() in ('1', 'true', 'yes', 'on'): + logger.custom_info('Import model worker enabled', metadata_runtime) + workers.append( + prepare_worker( + temporal_client=temporal_client, + main_workflow=ImportModel, + other_workflows=[], + activities=[ + # Import log row (the BFF database, update-only) + activities.claim_import_status, + activities.record_import_status, + activities.record_import_names, + activities.record_import_terminal_status, + activities.report_import_status_write_failure, + # Bundle + activities.download_import_bundle, + activities.open_import_bundle, + # MLflow provisioning + activities.create_import_experiment, + activities.upload_import_artifacts, + activities.register_import_model_version, + # The model listing + activities.write_import_model_document, + # Cleanup and retention + activities.cleanup_import_files, + activities.ensure_import_bundle_retention, + ], + logger=logger, + runtime=runtime, + ) + ) + handlers = [] for w in workers: handlers.append(w.run()) diff --git a/laborious/workflows/import_model.py b/laborious/workflows/import_model.py new file mode 100644 index 0000000..39cbbf6 --- /dev/null +++ b/laborious/workflows/import_model.py @@ -0,0 +1,416 @@ +"""The `import_model` workflow: a `.sientia` object becomes a registered model, or a durable ERROR. + +Shape of this workflow, and the reasons it has that shape: + +- **The claim comes first.** The record already exists — the frontend inserted it through the BFF — + so the workflow's first act is to take ownership of it. Everything after that failure is recorded + *on* the record; a failed claim is recorded nowhere, because the workflow owns no row. +- **Fail-fast, and nothing else.** No rollback, no compensation, no resume, no reconciliation, no + `continue_as_new`. A failed import stays failed; trying again is a new import with a new upload. +- **Only the status writes retry**, and only because `UPDATE ... SET WHERE id` is + idempotent. Every provisioning call keeps `maximum_attempts=1`, because a repeated registry call or + Mongo insert can write twice. +- **One `finally`, two actions**: cleanup, then the single terminal write that classifies the + outcome once. No `except` block writes a verdict of its own. +- **The workflow holds ids, never contents.** The decrypted bundle never crosses an activity + boundary, and the password reaches this module only as an envelope it cannot open. +""" + +from datetime import timedelta + +from temporalio import workflow + +with workflow.unsafe.imports_passed_through(): + from typing import Any + + from sientia_do.temporal.policies import retry_policy + + from laborious.activities.activities import Activities + from laborious.activities.model_import import ( + IMPORT_STATUS_HINT_RETRY_POLICY, + IMPORT_STATUS_RETRY_POLICY, + derive_run_name, + ) + from laborious.activities.model_import_errors import ImportInputError, failure_location + from laborious.utils.bundle.steps import ImportStep + +# `start_to_close_timeout` is per attempt. The terminal write additionally gets a +# `schedule_to_close_timeout` larger than its retry envelope (10 attempts, ≈151 s of backoff, ≈251 s +# if every attempt also burns its timeout) — set it smaller and Temporal cuts the retries short, +# which is the one configuration mistake that would silently undo design D20. +STATUS_WRITE_TIMEOUT = timedelta(seconds=10) +STATUS_WRITE_ENVELOPE = timedelta(seconds=300) +DOWNLOAD_TIMEOUT = timedelta(seconds=600) +OPEN_TIMEOUT = timedelta(seconds=900) +EXPERIMENT_TIMEOUT = timedelta(seconds=60) +ARTIFACT_UPLOAD_TIMEOUT = timedelta(seconds=900) +REGISTRATION_TIMEOUT = timedelta(seconds=120) +DOCUMENT_TIMEOUT = timedelta(seconds=60) +CLEANUP_TIMEOUT = timedelta(seconds=120) +RETENTION_TIMEOUT = timedelta(seconds=60) + +# Fields no import may carry. The project is a frontend concern: the model listing is the MongoDB +# `models` document, no migrated database holds a project or model table, and nothing in this +# repository reads a project-to-model link (design D22). An input naming one is rejected rather than +# accepted and ignored, so no caller believes a project link was recorded. The same goes for a +# relational target: there is none, and for a plaintext password, which does not belong in an event +# history whatever else is true. +REJECTED_INPUT_FIELDS = ( + 'project_id', + 'project_name', + 'provisioning_target', + 'schema', + 'table_name', + 'status_table', + 'password', +) + +DIGEST_LENGTH = 64 +_HEX_DIGITS = frozenset('0123456789abcdef') + + +@workflow.defn(name='import_model') +class ImportModel: + """Turn an encrypted `.sientia` object in MinIO into a registered model, or into an ERROR.""" + + @workflow.run + async def run(self, input_data: dict[str, Any]) -> dict[str, Any]: + """Execute the import. + + Args: + input_data: the import request. + Required keys: + - import_run_id (int): primary key of the import log row the frontend inserted + - object_key (str): the uploaded object's key + - expected_digest (str): the SHA-256 the uploader computed for those bytes + - password_envelope (str): the encrypted bundle password, never plaintext + Optional keys: + - bucket (str): defaults to the configured import bucket + + Returns: + dict: `import_run_id`, `run_id`, `model_name`, `version` and the model document's id. + + Raises: + ImportLogRowNotClaimableError: the record is not this workflow's — the run ends FAILED + with that error type and nothing is written to any row. + Exception: whatever failed. The record carries the failing step, its code and its + sentence; this exception carries the technical truth. + """ + workflow_id = workflow.info().workflow_id + import_run_id = self._require_import_run_id(input_data.get('import_run_id')) + metadata: dict[str, Any] = { + 'model_name': '-', + 'model_id': str(import_run_id), + 'workflow_name': 'import_model', + 'schedule_name': '-', + } + base = { + 'metadata': metadata, + 'import_run_id': import_run_id, + 'workflow_id': workflow_id, + } + + # Class A. Outside the try/finally on purpose: a claim that matches nothing leaves this + # workflow owning no row, so there is nothing to clean up and nothing to record. + claim = await workflow.execute_activity_method( + Activities.claim_import_status, + base, + retry_policy=IMPORT_STATUS_RETRY_POLICY, + start_to_close_timeout=STATUS_WRITE_TIMEOUT, + schedule_to_close_timeout=STATUS_WRITE_ENVELOPE, + ) + + current_step = ImportStep.RECEIVED + failed_step: str | None = None + gate: int | None = None + succeeded = False + source: dict[str, Any] = {} + result: dict[str, Any] = {'import_run_id': import_run_id} + + try: + self._validate_input(input_data, claim) + + current_step = ImportStep.DOWNLOAD + await self._hint(base, current_step) + await self._ensure_retention(base, input_data) + download = await workflow.execute_activity_method( + Activities.download_import_bundle, + { + **base, + 'bucket': input_data.get('bucket'), + 'object_key': input_data['object_key'], + 'expected_digest': input_data['expected_digest'], + }, + retry_policy=retry_policy, + start_to_close_timeout=DOWNLOAD_TIMEOUT, + ) + + current_step = ImportStep.DECRYPTION + await self._hint(base, current_step) + opened = await workflow.execute_activity_method( + Activities.open_import_bundle, + { + **base, + 'path': download['path'], + 'password_envelope': input_data['password_envelope'], + }, + retry_policy=retry_policy, + start_to_close_timeout=OPEN_TIMEOUT, + ) + source = opened['metadata'] + parameters = opened['parameters'] + model_name = source['model_name'] + metadata['model_name'] = model_name + run_name = derive_run_name(model_name, source['model_version']) + + # The names go on the record before anything exists in MLflow, so a person watching the + # import list sees *which* model is being imported while it is still running. + await self._record_names(base, source, run_name) + + current_step = ImportStep.EXPERIMENT_CREATION + await self._hint(base, current_step, source) + experiment = await workflow.execute_activity_method( + Activities.create_import_experiment, + {**base, 'experiment_name': source['experiment_name']}, + retry_policy=retry_policy, + start_to_close_timeout=EXPERIMENT_TIMEOUT, + ) + + current_step = ImportStep.ARTIFACT_UPLOAD + await self._hint(base, current_step, source) + run = await workflow.execute_activity_method( + Activities.upload_import_artifacts, + { + **base, + 'experiment_id': experiment['experiment_id'], + 'run_name': run_name, + 'extracted_dir': opened['extracted_dir'], + 'parameters': parameters, + 'source': source, + }, + retry_policy=retry_policy, + start_to_close_timeout=ARTIFACT_UPLOAD_TIMEOUT, + ) + + current_step = ImportStep.REGISTRATION + await self._hint(base, current_step, source) + version = await workflow.execute_activity_method( + Activities.register_import_model_version, + {**base, 'model_name': model_name, 'run_id': run['run_id']}, + retry_policy=retry_policy, + start_to_close_timeout=REGISTRATION_TIMEOUT, + ) + + current_step = ImportStep.MODEL_DOCUMENT + await self._hint(base, current_step, source) + document = await workflow.execute_activity_method( + Activities.write_import_model_document, + { + **base, + 'model_name': model_name, + 'target': parameters['target_variable'], + }, + retry_policy=retry_policy, + start_to_close_timeout=DOCUMENT_TIMEOUT, + ) + + succeeded = True + result = { + 'import_run_id': import_run_id, + 'run_id': run['run_id'], + 'model_name': model_name, + 'version': version['version'], + 'document_id': document.get('id'), + } + except BaseException as error: # NOSONAR - classified once below, then re-raised untouched + located_step, located_gate = failure_location(error) + failed_step = located_step or current_step.value + gate = located_gate + raise + finally: + cleanup = await self._cleanup(base) + await self._record_terminal( + base, + succeeded=succeeded, + step=failed_step or current_step.value, + gate=gate, + source=source, + cleanup_failed=not cleanup.get('cleaned', True), + ) + + return result + + # ------------------------------------------------------------------ validation + + @staticmethod + def _require_import_run_id(value: Any) -> int: + """The one check that runs before the claim, because the claim needs its result. + + A missing or unusable id means there is no record to claim and none to write: the run ends + FAILED with this error type, and the failure is visible in the workflow's terminal state, its + error type, the error metric and a notification — everywhere except a row, which is the + point. + """ + if isinstance(value, bool) or not isinstance(value, int) or value <= 0: + raise ImportInputError('import_run_id must be a positive integer') + return value + + @staticmethod + def _validate_input(input_data: dict[str, Any], claim: dict[str, Any]) -> None: + """Validate the rest of the request, and check it against the record it just claimed. + + This runs **after** the claim so its failure lands on the record rather than only in the + logs: by now the row is this workflow's, so a bad request is reported to the person who made + it. Everything here fails at step `received`. + """ + unknown = [name for name in REJECTED_INPUT_FIELDS if name in input_data] + if unknown: + raise ImportInputError( + f'the import request carries fields this workflow does not accept: ' + f'{", ".join(sorted(unknown))}' + ) + + for required in ('object_key', 'expected_digest', 'password_envelope'): + if not input_data.get(required): + raise ImportInputError(f'the import request is missing {required}') + + digest = str(input_data['expected_digest']).lower() + if len(digest) != DIGEST_LENGTH or not set(digest) <= _HEX_DIGITS: + raise ImportInputError('expected_digest must be a 64-character hexadecimal SHA-256') + + object_key = input_data['object_key'] + file_name = claim.get('file_name') + if file_name and file_name != object_key: + raise ImportInputError('the record names a different object than the request') + + request_digest = claim.get('request_digest') + if request_digest and request_digest != digest: + raise ImportInputError('the record names a different digest than the request') + + # --------------------------------------------------------------- status writes + + async def _hint( + self, + base: dict[str, Any], + step: ImportStep, + source: dict[str, Any] | None = None, + ) -> None: + """Record the step about to be attempted. A hint: its failure never fails the import.""" + try: + await workflow.execute_activity_method( + Activities.record_import_status, + {**base, 'step': step.value, 'source': source or None}, + retry_policy=IMPORT_STATUS_HINT_RETRY_POLICY, + start_to_close_timeout=STATUS_WRITE_TIMEOUT, + ) + except BaseException as error: # NOSONAR - a lost hint must not fail a working import + await self._report_status_write_failure(base, error) + + async def _record_names( + self, base: dict[str, Any], source: dict[str, Any], run_name: str + ) -> None: + """Write the experiment and run names. Also a hint: the run is created either way.""" + try: + await workflow.execute_activity_method( + Activities.record_import_names, + { + **base, + 'experiment_name': source['experiment_name'], + 'run_name': run_name, + 'source': source, + 'step': ImportStep.EXPERIMENT_CREATION.value, + }, + retry_policy=IMPORT_STATUS_HINT_RETRY_POLICY, + start_to_close_timeout=STATUS_WRITE_TIMEOUT, + ) + except BaseException as error: # NOSONAR - the names are for a human reading the list + await self._report_status_write_failure(base, error) + + async def _record_terminal( + self, + base: dict[str, Any], + *, + succeeded: bool, + step: str | None, + gate: int | None, + source: dict[str, Any], + cleanup_failed: bool, + ) -> None: + """The single authoritative write, and the last thing this workflow does. + + Its own failure is reported through the channel with the verdict it could not write, and it + never turns a completed import into a failed one — nor masks the cause of a real failure. + """ + try: + await workflow.execute_activity_method( + Activities.record_import_terminal_status, + { + **base, + 'succeeded': succeeded, + 'step': step, + 'gate': gate, + 'source': source or None, + 'cleanup_failed': cleanup_failed, + }, + retry_policy=IMPORT_STATUS_RETRY_POLICY, + start_to_close_timeout=STATUS_WRITE_TIMEOUT, + schedule_to_close_timeout=STATUS_WRITE_ENVELOPE, + ) + except BaseException as error: # NOSONAR - the outcome stands even when it cannot be written + await self._report_status_write_failure( + base, + error, + verdict=f'succeeded={succeeded} step={step} gate={gate}', + ) + + async def _report_status_write_failure( + self, + base: dict[str, Any], + error: BaseException, + verdict: str | None = None, + ) -> None: + """Route a status write that could not be made through the one failure channel.""" + try: + await workflow.execute_activity_method( + Activities.report_import_status_write_failure, + {**base, 'detail': str(error), 'verdict': verdict}, + retry_policy=retry_policy, + start_to_close_timeout=STATUS_WRITE_TIMEOUT, + ) + except BaseException: # NOSONAR - reporting the report is where this stops + pass + + # ----------------------------------------------------------- side-effect steps + + @staticmethod + async def _ensure_retention(base: dict[str, Any], input_data: dict[str, Any]) -> None: + """Ensure the bundle bucket's expiry rule, without making it a reason to fail an import. + + The rule is applied by the importer rather than assumed from a Helm value, so the acceptance + criterion is verifiable from the code that depends on it. But a bucket policy the importer + cannot set is an operational problem, not a bad import: failing a valid model import because + a lifecycle API refused would be the wrong trade, so this is reported and dropped like a + lost status hint. + """ + try: + await workflow.execute_activity_method( + Activities.ensure_import_bundle_retention, + {**base, 'bucket': input_data.get('bucket')}, + retry_policy=retry_policy, + start_to_close_timeout=RETENTION_TIMEOUT, + ) + except BaseException: # NOSONAR - retention is not a precondition of a correct import + pass + + @staticmethod + async def _cleanup(base: dict[str, Any]) -> dict[str, Any]: + """Remove this import's temporary files. Runs on every path, before the terminal write.""" + try: + return await workflow.execute_activity_method( + Activities.cleanup_import_files, + base, + retry_policy=retry_policy, + start_to_close_timeout=CLEANUP_TIMEOUT, + ) + except BaseException: # NOSONAR - cleanup never replaces the failure that brought us here + return {'cleaned': False} diff --git a/requirements-local.txt b/requirements-local.txt index 852328a..8d060e3 100644 --- a/requirements-local.txt +++ b/requirements-local.txt @@ -4,11 +4,17 @@ sqlalchemy asyncua==1.0.6 redis git+ssh://git@github.com/Aignosi/sientia-dataops-library.git@1.12.1 -git+ssh://git@github.com/Aignosi/sientia-model-library.git@0.10.0 +/home/grezewave/Documents/projects/sientia/sientia-mlops-library prometheus-client -botocore -boto3 -s3fs +# fsspec/s3fs/gcsfs pinned as a set: nannyml (via the mlops library) pulls both s3fs and +# gcsfs, and each pins fsspec exactly, so a free `s3fs` makes the resolver fail against +# evidently's fsspec>=2024.2.0. Versions match the library-distribution-controller freeze. +fsspec==2026.7.0 +s3fs==2026.7.0 +gcsfs==2026.8.0 +# aiobotocore 3.9.0 (pulled by s3fs) requires botocore<1.43.57,>=1.43.3. +botocore==1.43.56 +boto3==1.43.56 pyarrow kaleido hyperopt diff --git a/requirements.txt b/requirements.txt index 4b2a2a8..5b70c81 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,11 +4,17 @@ sqlalchemy asyncua==1.0.6 redis sientia_do>=1.12.1 -sientia>0.40.0 +sientia>=0.41.0 prometheus-client -botocore -boto3 -s3fs +# fsspec/s3fs/gcsfs pinned as a set: nannyml (via sientia) pulls both s3fs and gcsfs, and +# each pins fsspec exactly, so a free `s3fs` makes the resolver fail against evidently's +# fsspec>=2024.2.0. Versions match the library-distribution-controller freeze. +fsspec==2026.7.0 +s3fs==2026.7.0 +gcsfs==2026.8.0 +# aiobotocore 3.9.0 (pulled by s3fs) requires botocore<1.43.57,>=1.43.3. +botocore==1.43.56 +boto3==1.43.56 pyarrow kaleido hyperopt @@ -18,3 +24,10 @@ scipy<1.14.0 scikit-learn==1.4.2 pycaret==3.3.2 joblib +# .sientia bundle import (Argon2id + XChaCha20-Poly1305 secretstream): same pin as the +# producer in sientia-projects-templates, so both sides open the same bytes. +pynacl==1.5.0 +# AES-256-GCM password envelope; already present transitively, declared explicitly. +cryptography +# MongoDB models collection (the model listing); transitive via sientia_do, declared explicitly. +pymongo diff --git a/tests/fixtures/bundle/README.md b/tests/fixtures/bundle/README.md new file mode 100644 index 0000000..7a5cad8 --- /dev/null +++ b/tests/fixtures/bundle/README.md @@ -0,0 +1,86 @@ +# Golden `.sientia` fixtures + +Binary conformance vectors for `laborious/utils/bundle/`. They are **bytes the producer's own code +emitted**, not bytes this repository knows how to write: there is no production writer here (change +`import-model-workflow`, design D1). If the producer's layout moves, the reader's tests fail against +these files instead of failing in production. + +| File | What it is | +|---|---| +| `golden_model_v3.sientia` | `format_version` 1 bundle, 110767 bytes, plaintext zip 110569 bytes — **two chunks**, so `MESSAGE`/`FINAL` framing is exercised by the golden fixture itself | +| `golden_model_v3.metadata.json` | the `metadata.json` that went into that bundle, in plaintext, so a test can assert the recovered value without decrypting first | +| `regenerate.py` | the script that produced them, importing the producer's `bundle.py`/`crypto.py` by path | + +## Password + +``` +Import1Model!Fixture +``` + +Recorded deliberately: the fixture protects nothing. It satisfies the producer's export-side +strength rule (≥ 12 characters, upper, lower, digit, symbol), which is checked by +`crypto.validate_password_strength` at generation time. + +## Provenance + +- Producer repository: `sientia-projects-templates` +- Producer commit: `8d3c1fd117c697e8b352c34f11c732809a4a129b` + (`8d3c1fd QTZPOC-7: adapt to lint rules picked up from merging main`) +- Producer modules used: `app/src/operations/model_export/bundle.py`, + `app/src/operations/model_export/crypto.py` — loaded by path, so every constant, the `struct` + layout, the zip layout, the KDF parameters and the chunk framing come from the producer and none of + them is re-implemented here +- Producer pin honoured: `pynacl==1.5.0` (this repository pins the same version) + +One deviation from task 0.3, recorded rather than hidden: the bytes were generated with the +producer's **code** under *this* repository's venv, not inside the producer's own venv, because that +venv has neither `pynacl` nor `mlflow` installed. The dependency that matters for the bytes is +`pynacl`, pinned identically on both sides, and `mlflow` is not reached — `regenerate.py` builds the +artifact tree itself instead of calling `client.download_artifacts`. + +## Contents of the bundle + +``` +metadata.json (written first, no indent, by bundle.build_zip) +artifacts/prediction_model/MLmodel +artifacts/prediction_model/model.pkl (90 KiB of incompressible filler) +artifacts/prediction_model/conda.yaml +artifacts/prediction_model/python_env.yaml +artifacts/prediction_model/requirements.txt +artifacts/data_model/MLmodel +artifacts/data_model/model.pkl (8 KiB) +artifacts/data_model/conda.yaml +artifacts/data_model/python_env.yaml +artifacts/data_model/requirements.txt +artifacts/model_card/model_card.json +artifacts/train_data.csv +artifacts/test_data.csv +artifacts/raw_data.csv +``` + +No directory entries are stored — the producer only adds paths for which `is_file()` is true. + +`parameters` carries `target_variable: "SE"`, which gate 6 requires and which becomes +`model_config.target` in the MongoDB model document. + +## Digest boundary — the reason this fixture exists + +The header's `ciphertext_digest` is `sha256(file[164:])`: the producer computes +`hashlib.sha256(ciphertext)` over the joined chunks and only then writes +`header + stream_header + ciphertext`, so the 24-byte `secretstream` header is **not** covered. +Verified against this fixture and against a freshly produced bundle. Earlier prose (this change's own +first draft, and `aig-docs/03-sientia-bundle-format.md`) said "everything after the plaintext +header", i.e. from offset 140 — that is wrong, and `tests/laborious/utils/bundle/test_format.py` +asserts both directions so it cannot come back. + +## Regenerating + +```bash +source ./venv/bin/activate +PRODUCER_REPO=../sientia-projects-templates python tests/fixtures/bundle/regenerate.py +``` + +Two fields change on every run and are expected to: the 16-byte salt (`os.urandom`) and +`metadata.metadata.export_timestamp` (`datetime.now(UTC)`). Everything else is deterministic — +the filler bytes come from a seeded PRNG. After regenerating, update the producer commit above and +the byte counts in the table. diff --git a/tests/fixtures/bundle/golden_model_v3.metadata.json b/tests/fixtures/bundle/golden_model_v3.metadata.json new file mode 100644 index 0000000..cf30161 --- /dev/null +++ b/tests/fixtures/bundle/golden_model_v3.metadata.json @@ -0,0 +1,24 @@ +{ + "parameters": { + "model_name": "fixture_model", + "target_variable": "SE", + "input_variables": "['feature_a', 'feature_b']", + "models_params": "{'alpha': 0.1}", + "nan_treatment": "drop", + "scaler_name": "StandardScaler", + "train_size": "0.8", + "test_size": "0.2", + "include_ar": "False", + "retrain": "False", + "start_date": "2026-01-01 00:00:00", + "end_date": "2026-08-01 00:00:00" + }, + "metadata": { + "model_name": "fixture_model", + "experiment_name": "fixture_experiment", + "run_id": "8f14e45fceea167a5a36dedd4bea2543", + "model_version": "3", + "model_project": "fixture_project", + "export_timestamp": "2026-08-25T12:08:59.533441+00:00" + } +} diff --git a/tests/fixtures/bundle/golden_model_v3.sientia b/tests/fixtures/bundle/golden_model_v3.sientia new file mode 100644 index 0000000000000000000000000000000000000000..2766539be3d8f4d288e390baedae5615b7d62bfa GIT binary patch literal 110767 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

rI5z+Ty_ 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 12 chars, upper, lower, digit, symbol). +FIXTURE_PASSWORD = 'Import1Model!Fixture' + +# Deterministic filler so a regeneration differs only in the salt and the export timestamp. +FILLER_SEED = 20260825 + + +def _load_producer_module(repo: Path, name: str) -> types.ModuleType: + """Import one producer module by path, without importing the Streamlit app.""" + path = repo / 'app' / 'src' / 'operations' / 'model_export' / f'{name}.py' + if not path.is_file(): + raise SystemExit(f'producer module not found: {path}') + spec = importlib.util.spec_from_file_location(f'producer_{name}', path) + if spec is None or spec.loader is None: + raise SystemExit(f'cannot load producer module: {path}') + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def _producer_commit(repo: Path) -> str: + result = subprocess.run( # noqa: S603 - fixed argv, no shell + ['git', '-C', str(repo), 'rev-parse', 'HEAD'], # noqa: S607 - git from PATH is intended + capture_output=True, + text=True, + check=False, + ) + return result.stdout.strip() or 'unknown' + + +def _write_model_dir(root: Path, flavor: str, pickle_bytes: bytes) -> None: + """Write the files an MLflow 2.x model directory carries for this platform.""" + root.mkdir(parents=True, exist_ok=True) + (root / 'MLmodel').write_text( + f'artifact_path: {flavor}\n' + 'flavors:\n' + ' python_function:\n' + ' loader_module: mlflow.sklearn\n' + ' model_path: model.pkl\n' + ' sklearn:\n' + ' pickled_model: model.pkl\n' + ' serialization_format: cloudpickle\n' + ' sklearn_version: 1.4.2\n' + 'mlflow_version: 2.10.1\n' + 'model_uuid: 0f9c1c1f9c1f4c0f9c1c1f9c1f4c0f9c\n' + 'utc_time_created: "2026-08-25 12:00:00.000000"\n', + encoding='utf-8', + ) + (root / 'model.pkl').write_bytes(pickle_bytes) + (root / 'conda.yaml').write_text( + 'channels:\n- conda-forge\ndependencies:\n- python=3.11\n- pip\n- pip:\n' + ' - mlflow==2.10.1\n - scikit-learn==1.4.2\nname: mlflow-env\n', + encoding='utf-8', + ) + (root / 'python_env.yaml').write_text( + 'python: 3.11.15\nbuild_dependencies:\n- pip==24.0\ndependencies:\n- -r requirements.txt\n', + encoding='utf-8', + ) + (root / 'requirements.txt').write_text( + 'mlflow==2.10.1\nscikit-learn==1.4.2\ncloudpickle==3.0.0\n', encoding='utf-8' + ) + + +def _write_csv(path: Path, rows: int, rng: random.Random) -> None: + lines = ['datetime,feature_a,feature_b,SE'] + for i in range(rows): + lines.append( + f'2026-08-{(i % 27) + 1:02d}T00:00:00,' + f'{rng.uniform(0, 100):.6f},{rng.uniform(0, 100):.6f},{rng.uniform(0, 100):.6f}' + ) + path.write_text('\n'.join(lines) + '\n', encoding='utf-8') + + +def _build_artifact_tree(root: Path) -> None: + rng = random.Random(FILLER_SEED) # noqa: S311 - test filler, not key material + # Incompressible filler keeps the zip above the 64 KiB chunk size, so the golden fixture + # exercises multi-chunk `MESSAGE`/`FINAL` framing with real producer bytes. + big_pickle = bytes(rng.getrandbits(8) for _ in range(90 * 1024)) + small_pickle = bytes(rng.getrandbits(8) for _ in range(8 * 1024)) + _write_model_dir(root / 'prediction_model', 'prediction_model', big_pickle) + _write_model_dir(root / 'data_model', 'data_model', small_pickle) + (root / 'model_card').mkdir(parents=True, exist_ok=True) + (root / 'model_card' / 'model_card.json').write_text( + json.dumps( + { + 'model_name': 'fixture_model', + 'target': 'SE', + 'created_by': 'fixture', + 'creation_method': 'time_series_template', + } + ), + encoding='utf-8', + ) + _write_csv(root / 'train_data.csv', 120, rng) + _write_csv(root / 'test_data.csv', 40, rng) + _write_csv(root / 'raw_data.csv', 200, rng) + + +RUN_PARAMETERS = { + 'model_name': 'fixture_model', + 'target_variable': 'SE', + 'input_variables': "['feature_a', 'feature_b']", + 'models_params': "{'alpha': 0.1}", + 'nan_treatment': 'drop', + 'scaler_name': 'StandardScaler', + 'train_size': '0.8', + 'test_size': '0.2', + 'include_ar': 'False', + 'retrain': 'False', + 'start_date': '2026-01-01 00:00:00', + 'end_date': '2026-08-01 00:00:00', +} + +METADATA_FIELDS = { + 'model_name': 'fixture_model', + 'experiment_name': 'fixture_experiment', + 'run_id': '8f14e45fceea167a5a36dedd4bea2543', + 'model_version': '3', + 'model_project': 'fixture_project', +} + + +def main() -> int: + repo = Path(os.getenv('PRODUCER_REPO', str(DEFAULT_PRODUCER_REPO))).resolve() + bundle = _load_producer_module(repo, 'bundle') + crypto = _load_producer_module(repo, 'crypto') + + crypto.validate_password_strength(FIXTURE_PASSWORD) + + staging = Path(tempfile.mkdtemp(prefix='sientia_fixture_')) + try: + _build_artifact_tree(staging) + metadata = bundle.build_metadata_json(run_parameters=RUN_PARAMETERS, **METADATA_FIELDS) + zip_bytes = bundle.build_zip(staging, metadata) + sientia_bytes = crypto.encrypt_archive(zip_bytes, FIXTURE_PASSWORD) + finally: + shutil.rmtree(staging, ignore_errors=True) + + target = FIXTURE_DIR / 'golden_model_v3.sientia' + target.write_bytes(sientia_bytes) + (FIXTURE_DIR / 'golden_model_v3.metadata.json').write_text( + json.dumps(metadata, indent=2) + '\n', encoding='utf-8' + ) + print(f'wrote {target} ({len(sientia_bytes)} bytes, zip {len(zip_bytes)} bytes)') + print(f'producer commit: {_producer_commit(repo)}') + print(f'password: {FIXTURE_PASSWORD}') + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/tests/helpers/__init__.py b/tests/helpers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/helpers/bundle_factory.py b/tests/helpers/bundle_factory.py new file mode 100644 index 0000000..db54be5 --- /dev/null +++ b/tests/helpers/bundle_factory.py @@ -0,0 +1,272 @@ +"""Test-only `.sientia` writer, mirrored field for field from the producer. + +There is no production writer in this repository (change `import-model-workflow`, design D1): the +producer is the Streamlit app in `sientia-projects-templates`. This factory exists for one reason — +building the **hostile** variants the gate tests need, which the producer cannot emit. Its output is +pinned against the golden fixture by `test_format.py::test_factory_header_matches_fixture_layout`, +so a drift in either direction shows up as a failing test. + +Mirrored from: +- `app/src/operations/model_export/crypto.py` — header layout, KDF, `secretstream` framing +- `app/src/operations/model_export/bundle.py` — zip layout and the two-top-level-key JSON + +Deliberate deviation: the default KDF cost is a fraction of the producer's (8 MiB / 1 pass instead +of 256 MiB / 3), because the reader honours whatever the header declares and a test suite that +derives thirty 256 MiB Argon2id keys is a test suite nobody runs. The golden fixture is what covers +the production cost parameters. +""" + +from __future__ import annotations + +import base64 +import hashlib +import io +import json +import os +import struct +import unicodedata +import zipfile +from dataclasses import dataclass, field +from typing import Any + +import nacl.bindings as sodium +from cryptography.hazmat.primitives.ciphers.aead import AESGCM + +# --- mirrored from the producer's crypto.py --- +MAGIC = b'SIENTIA1' +FORMAT_VERSION = 1 +AEAD_ID = 1 +KDF_ID = 1 +SALT_SIZE = 16 +RESERVED_SIGNATURE_SIZE = 64 +CHUNK_SIZE = 64 * 1024 +HEADER_STRUCT = struct.Struct('>8sBBB16sQQB32s64s') +HEADER_SIZE = HEADER_STRUCT.size +PRODUCER_KDF_MEMLIMIT_BYTES = 256 * 1024 * 1024 +PRODUCER_KDF_OPSLIMIT = 3 + +# Cheap-but-valid cost parameters for the suite; see the module docstring. +TEST_KDF_MEMLIMIT_BYTES = 8 * 1024 * 1024 +TEST_KDF_OPSLIMIT = 1 + +DEFAULT_PASSWORD = 'Factory1Model!Test' +DEFAULT_ENVELOPE_KEY = bytes(range(32)) + +MODEL_DIRS = ('prediction_model', 'data_model') +MODEL_FILES = ('MLmodel', 'model.pkl', 'conda.yaml', 'python_env.yaml', 'requirements.txt') +CSV_FILES = ('train_data.csv', 'test_data.csv', 'raw_data.csv') + +DEFAULT_METADATA = { + 'model_name': 'factory_model', + 'experiment_name': 'factory_experiment', + 'run_id': 'aa11bb22cc33dd44ee55ff6677889900', + 'model_version': '2', + 'model_project': 'factory_project', + 'export_timestamp': '2026-08-25T12:00:00+00:00', +} + +DEFAULT_PARAMETERS = { + 'model_name': 'factory_model', + 'target_variable': 'SE', + 'input_variables': "['feature_a', 'feature_b']", + 'retrain': 'False', +} + + +def default_tree() -> dict[str, bytes]: + """The artifact tree a well-formed bundle carries, as archive-name -> bytes.""" + files: dict[str, bytes] = {} + for directory in MODEL_DIRS: + for name in MODEL_FILES: + files[f'artifacts/{directory}/{name}'] = f'{directory}/{name}\n'.encode() + files['artifacts/model_card/model_card.json'] = json.dumps({'target': 'SE'}).encode() + for name in CSV_FILES: + files[f'artifacts/{name}'] = b'datetime,feature_a,SE\n2026-08-25T00:00:00,1.0,2.0\n' + return files + + +def build_metadata_document( + *, + metadata: dict[str, Any] | None = None, + parameters: dict[str, Any] | None = None, + drop_metadata_field: str | None = None, + drop_top_level_key: str | None = None, + drop_parameter: str | None = None, +) -> dict[str, Any]: + """Build `metadata.json`, with hooks for the malformed variants gate 6 must reject.""" + meta = {**DEFAULT_METADATA, **(metadata or {})} + params = {**DEFAULT_PARAMETERS, **(parameters or {})} + if drop_metadata_field: + meta.pop(drop_metadata_field, None) + if drop_parameter: + params.pop(drop_parameter, None) + document: dict[str, Any] = {'parameters': params, 'metadata': meta} + if drop_top_level_key: + document.pop(drop_top_level_key, None) + return document + + +@dataclass +class ZipSpec: + """How to lay out the zip inside the bundle.""" + + files: dict[str, bytes] = field(default_factory=default_tree) + document: dict[str, Any] | None = None + include_metadata: bool = True + symlinks: dict[str, str] = field(default_factory=dict) + absolute_entry: str | None = None + traversal_entry: str | None = None + bomb_bytes: int = 0 + extra_files: dict[str, bytes] = field(default_factory=dict) + + +def build_zip(spec: ZipSpec | None = None) -> bytes: + """Zip `artifacts/` plus `metadata.json`, mirroring the producer's `build_zip`. + + `metadata.json` is written first with `json.dumps` and no indent, entries use `/` separators, + and no directory entries are stored — all three matter to the reader. + """ + spec = spec or ZipSpec() + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, 'w', zipfile.ZIP_DEFLATED) as archive: + if spec.include_metadata: + document = spec.document if spec.document is not None else build_metadata_document() + archive.writestr('metadata.json', json.dumps(document)) + for name, content in {**spec.files, **spec.extra_files}.items(): + archive.writestr(_regular_file_info(name), content) + for name, target in spec.symlinks.items(): + info = zipfile.ZipInfo(name) + info.create_system = 3 + info.external_attr = (0o120777 << 16) | 0o200000 + archive.writestr(info, target) + if spec.absolute_entry: + archive.writestr(_regular_file_info(spec.absolute_entry), b'absolute\n') + if spec.traversal_entry: + archive.writestr(_regular_file_info(spec.traversal_entry), b'traversal\n') + if spec.bomb_bytes: + archive.writestr(_regular_file_info('artifacts/bomb.csv'), b'\0' * spec.bomb_bytes) + return buffer.getvalue() + + +def _regular_file_info(name: str) -> zipfile.ZipInfo: + """A zip entry carrying a Unix regular-file mode, as `ZipFile.write` would store it.""" + info = zipfile.ZipInfo(name) + info.create_system = 3 + info.compress_type = zipfile.ZIP_DEFLATED + info.external_attr = 0o100644 << 16 + return info + + +def derive_key(password: str, salt: bytes, opslimit: int, memlimit: int) -> bytes: + """Argon2id over the NFC-normalised password — the producer's `_derive_key`.""" + normalized = unicodedata.normalize('NFC', password) + return sodium.crypto_pwhash_alg( + sodium.crypto_secretstream_xchacha20poly1305_KEYBYTES, + normalized.encode('utf-8'), + salt, + opslimit, + memlimit, + sodium.crypto_pwhash_ALG_ARGON2ID13, + ) + + +def encrypt_archive( # noqa: PLR0913 - every knob here exists to build one hostile variant + plaintext: bytes, + password: str = DEFAULT_PASSWORD, + *, + format_version: int = FORMAT_VERSION, + aead_id: int = AEAD_ID, + kdf_id: int = KDF_ID, + memlimit: int = TEST_KDF_MEMLIMIT_BYTES, + opslimit: int = TEST_KDF_OPSLIMIT, + declared_memlimit: int | None = None, + declared_opslimit: int | None = None, + parallelism: int = 1, + reserved_signature: bytes | None = None, + magic: bytes = MAGIC, + digest_override: bytes | None = None, + salt: bytes | None = None, + drop_final_tag: bool = False, +) -> bytes: + """Produce a full `.sientia` byte stream, mirroring the producer's `encrypt_archive`. + + The `declared_*` arguments write cost parameters into the header that differ from the ones + actually used, which is how a "512 MiB header" is built without allocating 512 MiB. + `drop_final_tag` pushes every chunk as `MESSAGE`, so the stream is framed as if more were + coming — the truncation the digest cannot see. + """ + salt = salt if salt is not None else os.urandom(SALT_SIZE) + key = derive_key(password, salt, opslimit, memlimit) + + state = sodium.crypto_secretstream_xchacha20poly1305_state() + stream_header = sodium.crypto_secretstream_xchacha20poly1305_init_push(state, key) + + chunks = [] + offset = 0 + total = len(plaintext) + while offset < total: + chunk = plaintext[offset : offset + CHUNK_SIZE] + offset += len(chunk) + is_last = offset >= total and not drop_final_tag + tag = ( + sodium.crypto_secretstream_xchacha20poly1305_TAG_FINAL + if is_last + else sodium.crypto_secretstream_xchacha20poly1305_TAG_MESSAGE + ) + chunks.append(sodium.crypto_secretstream_xchacha20poly1305_push(state, chunk, None, tag)) + ciphertext = b''.join(chunks) + + digest = digest_override if digest_override is not None else hashlib.sha256(ciphertext).digest() + header = HEADER_STRUCT.pack( + magic, + format_version, + aead_id, + kdf_id, + salt, + declared_memlimit if declared_memlimit is not None else memlimit, + declared_opslimit if declared_opslimit is not None else opslimit, + parallelism, + digest, + reserved_signature if reserved_signature is not None else bytes(RESERVED_SIGNATURE_SIZE), + ) + return header + stream_header + ciphertext + + +def make_bundle( + zip_spec: ZipSpec | None = None, + password: str = DEFAULT_PASSWORD, + **header_kwargs: Any, +) -> bytes: + """The common case: a well-formed tree, encrypted with the test cost parameters.""" + return encrypt_archive(build_zip(zip_spec), password, **header_kwargs) + + +def tamper_ciphertext(bundle: bytes, offset_from_stream: int = 8) -> bytes: + """Flip one byte inside the chunk stream **and** repair the header digest. + + Repairing the digest is what makes this a gate-3 test rather than a gate-2 one: the file is + self-consistent and only the AEAD can tell it was altered. + """ + position = HEADER_SIZE + 24 + offset_from_stream + altered = bytearray(bundle) + altered[position] ^= 0xFF + repaired = hashlib.sha256(bytes(altered[HEADER_SIZE + 24 :])).digest() + altered[44:76] = repaired + return bytes(altered) + + +def truncate_bundle(bundle: bytes, drop_bytes: int = 32) -> bytes: + """Cut the tail off a bundle, leaving the header's digest describing bytes that are gone.""" + return bundle[:-drop_bytes] + + +def build_envelope(password: str = DEFAULT_PASSWORD, key: bytes = DEFAULT_ENVELOPE_KEY) -> str: + """`base64(nonce || ciphertext || tag)` under AES-256-GCM, as the frontend will send it.""" + nonce = os.urandom(12) + sealed = AESGCM(key).encrypt(nonce, password.encode('utf-8'), None) + return base64.b64encode(nonce + sealed).decode('ascii') + + +def envelope_key_b64(key: bytes = DEFAULT_ENVELOPE_KEY) -> str: + """The key as `IMPORT_PASSWORD_KEY` carries it.""" + return base64.b64encode(key).decode('ascii') diff --git a/tests/laborious/utils/bundle/__init__.py b/tests/laborious/utils/bundle/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/laborious/utils/bundle/conftest.py b/tests/laborious/utils/bundle/conftest.py new file mode 100644 index 0000000..ffc41f6 --- /dev/null +++ b/tests/laborious/utils/bundle/conftest.py @@ -0,0 +1,34 @@ +"""Shared fixtures for the `.sientia` bundle tests.""" + +from pathlib import Path + +import pytest + +FIXTURE_DIR = Path(__file__).resolve().parents[3] / 'fixtures' / 'bundle' +GOLDEN_BUNDLE = FIXTURE_DIR / 'golden_model_v3.sientia' +GOLDEN_METADATA = FIXTURE_DIR / 'golden_model_v3.metadata.json' +# Recorded in tests/fixtures/bundle/README.md; the fixture protects nothing. +GOLDEN_PASSWORD = 'Import1Model!Fixture' + + +@pytest.fixture +def golden_bundle() -> Path: + """The `.sientia` file the real producer emitted.""" + assert GOLDEN_BUNDLE.is_file(), ( + f'golden fixture missing: {GOLDEN_BUNDLE} — regenerate it with ' + 'tests/fixtures/bundle/regenerate.py' + ) + return GOLDEN_BUNDLE + + +@pytest.fixture +def golden_password() -> str: + return GOLDEN_PASSWORD + + +@pytest.fixture +def work_dir(tmp_path: Path) -> Path: + """A per-test stand-in for `IMPORT_WORK_DIR/`.""" + root = tmp_path / 'workflow-id' + root.mkdir() + return root diff --git a/tests/laborious/utils/bundle/test_format.py b/tests/laborious/utils/bundle/test_format.py new file mode 100644 index 0000000..e8c0f6b --- /dev/null +++ b/tests/laborious/utils/bundle/test_format.py @@ -0,0 +1,227 @@ +"""The wire format: header parsing, ceilings, digest coverage and the fixture contract.""" + +import ast +import hashlib +from pathlib import Path + +import nacl.bindings as sodium +import pytest + +from laborious.utils.bundle import format as fmt +from tests.helpers import bundle_factory as factory + + +def _write(tmp_path: Path, payload: bytes, name: str = 'bundle.sientia') -> Path: + target = tmp_path / name + target.write_bytes(payload) + return target + + +# --------------------------------------------------------------- constants + + +def test_header_size_is_140(): + assert fmt.HEADER_SIZE == 140 + assert fmt.HEADER_STRUCT.format == '>8sBBB16sQQB32s64s' + + +def test_libsodium_literals_match_the_binding(): + """The literals in `format.py` keep the module import-free; these assertions keep them true.""" + assert fmt.STREAM_HEADER_SIZE == sodium.crypto_secretstream_xchacha20poly1305_HEADERBYTES + assert fmt.ABYTES == sodium.crypto_secretstream_xchacha20poly1305_ABYTES + assert fmt.KEY_SIZE == sodium.crypto_secretstream_xchacha20poly1305_KEYBYTES + + +def test_producer_constants_match(): + assert fmt.MAGIC == b'SIENTIA1' + assert fmt.CHUNK_SIZE == 65536 + assert fmt.MAX_KDF_MEMLIMIT == 268435456 + assert fmt.MAX_KDF_OPSLIMIT == 3 + assert fmt.SALT_SIZE == 16 + assert fmt.DIGEST_SIZE == 32 + assert fmt.RESERVED_SIGNATURE_SIZE == 64 + + +def test_prefix_size_is_65717(): + """The pre-upload prefix is computed from constants, never read from a file.""" + assert fmt.PREFIX_SIZE == 140 + 24 + (65536 + 17) + assert fmt.PREFIX_SIZE == 65717 + + +def test_digest_offset_skips_both_headers(): + assert fmt.DIGEST_OFFSET == 164 + + +# ------------------------------------------------------------------- parse + + +def test_parses_a_producer_emitted_header(golden_bundle): + header = fmt.read_header(golden_bundle) + + assert header.format_version == 1 + assert header.aead_id == fmt.AEAD_ID_XCHACHA20POLY1305_SECRETSTREAM + assert header.kdf_id == fmt.KDF_ID_ARGON2ID + assert len(header.salt) == 16 + assert header.kdf_memlimit_bytes == 268435456 + assert header.kdf_opslimit == 3 + assert header.kdf_parallelism == 1 + assert len(header.ciphertext_digest) == 32 + assert header.reserved_signature == bytes(64) + + +def test_wrong_magic_rejected(tmp_path): + path = _write(tmp_path, factory.make_bundle(magic=b'NOTSIENT')) + + with pytest.raises(fmt.BundleFormatError, match='expected bundle marker'): + fmt.read_header(path) + + +def test_unknown_format_version_rejected(tmp_path): + path = _write(tmp_path, factory.make_bundle(format_version=2)) + + with pytest.raises(fmt.BundleFormatError, match='format version'): + fmt.read_header(path) + + +def test_unknown_aead_id_rejected(tmp_path): + path = _write(tmp_path, factory.make_bundle(aead_id=7)) + + with pytest.raises(fmt.BundleFormatError, match='encryption identifier'): + fmt.read_header(path) + + +def test_unknown_kdf_id_rejected(tmp_path): + path = _write(tmp_path, factory.make_bundle(kdf_id=9)) + + with pytest.raises(fmt.BundleFormatError, match='key derivation identifier'): + fmt.read_header(path) + + +def test_memlimit_above_the_ceiling_rejected(tmp_path): + path = _write(tmp_path, factory.make_bundle(declared_memlimit=512 * 1024 * 1024)) + + with pytest.raises(fmt.BundleFormatError, match='memory'): + fmt.read_header(path) + + +def test_opslimit_above_the_ceiling_rejected(tmp_path): + path = _write(tmp_path, factory.make_bundle(declared_opslimit=4)) + + with pytest.raises(fmt.BundleFormatError, match='passes'): + fmt.read_header(path) + + +def test_zero_cost_parameters_rejected(tmp_path): + path = _write(tmp_path, factory.make_bundle(declared_opslimit=0)) + + with pytest.raises(fmt.BundleFormatError, match='positive'): + fmt.read_header(path) + + +def test_reserved_parallelism_must_be_one(tmp_path): + path = _write(tmp_path, factory.make_bundle(parallelism=2)) + + with pytest.raises(fmt.BundleFormatError, match='parallelism'): + fmt.read_header(path) + + +def test_non_zero_reserved_signature_rejected(tmp_path): + path = _write(tmp_path, factory.make_bundle(reserved_signature=b'\x01' + bytes(63))) + + with pytest.raises(fmt.BundleFormatError, match='reserved signature'): + fmt.read_header(path) + + +def test_file_shorter_than_header_plus_stream_header_rejected(tmp_path): + path = _write(tmp_path, b'SIENTIA1' + bytes(100)) + + with pytest.raises(fmt.BundleFormatError, match='smaller than'): + fmt.read_header(path) + + +def test_parse_header_needs_the_whole_header(): + with pytest.raises(fmt.BundleFormatError, match='shorter than'): + fmt.parse_header(b'SIENTIA1') + + +# ------------------------------------------------------------------ digest + + +def test_digest_covers_the_chunk_stream_not_the_stream_header(golden_bundle): + """The producer hashes the chunks before prepending either header — offset 164, not 140.""" + header = fmt.read_header(golden_bundle) + payload = golden_bundle.read_bytes() + + assert hashlib.sha256(payload[164:]).digest() == header.ciphertext_digest + assert hashlib.sha256(payload[140:]).digest() != header.ciphertext_digest + assert fmt.compute_ciphertext_digest(golden_bundle) == header.ciphertext_digest + + +def test_verify_ciphertext_digest_accepts_the_fixture(golden_bundle): + fmt.verify_ciphertext_digest(golden_bundle, fmt.read_header(golden_bundle)) + + +def test_digest_mismatch_rejected(tmp_path): + path = _write(tmp_path, factory.make_bundle(digest_override=bytes(32))) + header = fmt.read_header(path) + + with pytest.raises(fmt.BundleFormatError, match='does not match the digest'): + fmt.verify_ciphertext_digest(path, header) + + +def test_truncated_file_fails_the_digest(tmp_path): + path = _write(tmp_path, factory.truncate_bundle(factory.make_bundle())) + header = fmt.read_header(path) + + with pytest.raises(fmt.BundleFormatError, match='does not match the digest'): + fmt.verify_ciphertext_digest(path, header) + + +def test_file_digest_is_the_whole_object(tmp_path): + payload = factory.make_bundle() + path = _write(tmp_path, payload) + + assert fmt.file_digest(path) == hashlib.sha256(payload).hexdigest() + + +# ------------------------------------------------------- factory conformance + + +def test_factory_header_matches_fixture_layout(golden_bundle, tmp_path): + """The test-only writer must be structurally identical to the producer's output.""" + produced = fmt.read_header(_write(tmp_path, factory.make_bundle())) + golden = fmt.read_header(golden_bundle) + + assert (produced.format_version, produced.aead_id, produced.kdf_id) == ( + golden.format_version, + golden.aead_id, + golden.kdf_id, + ) + assert produced.kdf_parallelism == golden.kdf_parallelism + assert produced.reserved_signature == golden.reserved_signature + assert len(produced.salt) == len(golden.salt) + assert len(produced.ciphertext_digest) == len(golden.ciphertext_digest) + assert factory.HEADER_STRUCT.format == fmt.HEADER_STRUCT.format + assert factory.PRODUCER_KDF_MEMLIMIT_BYTES == golden.kdf_memlimit_bytes + assert factory.PRODUCER_KDF_OPSLIMIT == golden.kdf_opslimit + + +# --------------------------------------------------------------- liftability + +FORBIDDEN_IMPORT_ROOTS = frozenset( + {'laborious', 'temporalio', 'mlflow', 'nacl', 'cryptography', 'sqlalchemy', 'pymongo'} +) + + +def test_format_module_imports_nothing_platform_specific(): + """`format.py` stays liftable into a shared package: stdlib only, no platform imports.""" + tree = ast.parse(Path(fmt.__file__).read_text(encoding='utf-8')) + roots = set() + for node in ast.walk(tree): + if isinstance(node, ast.Import): + roots.update(alias.name.split('.')[0] for alias in node.names) + elif isinstance(node, ast.ImportFrom) and node.module: + roots.add(node.module.split('.')[0]) + + assert roots.isdisjoint(FORBIDDEN_IMPORT_ROOTS), roots & FORBIDDEN_IMPORT_ROOTS + assert roots == {'__future__', 'hashlib', 'struct', 'dataclasses', 'pathlib', 'typing'} diff --git a/tests/laborious/utils/bundle/test_reader.py b/tests/laborious/utils/bundle/test_reader.py new file mode 100644 index 0000000..9430173 --- /dev/null +++ b/tests/laborious/utils/bundle/test_reader.py @@ -0,0 +1,460 @@ +"""The seven gates: what each one rejects, in what order, and what it leaves on disk.""" + +import json +import random +from pathlib import Path + +import nacl.bindings as sodium +import pytest + +from laborious.utils.bundle import format as fmt +from laborious.utils.bundle.reader import BundleGateError, BundleLimits, BundleReader +from laborious.utils.bundle.steps import ImportStep +from tests.helpers import bundle_factory as factory + +OBJECT_KEY = 'imported_models/factory_model_v2.sientia' + + +@pytest.fixture +def reader() -> BundleReader: + return BundleReader() + + +def _bundle_file(work_dir: Path, payload: bytes) -> Path: + target = work_dir / 'download.sientia' + target.write_bytes(payload) + return target + + +def _open(reader: BundleReader, work_dir: Path, payload: bytes, password: str | None = None): + return reader.open( + _bundle_file(work_dir, payload), + password if password is not None else factory.DEFAULT_PASSWORD, + work_dir / 'extract', + work_dir, + ) + + +def _extracted_files(root: Path) -> list[str]: + if not root.exists(): + return [] + return sorted(str(p.relative_to(root)) for p in root.rglob('*') if p.is_file()) + + +# ------------------------------------------------------------------- gate 1 + + +def test_gate_1_accepts_a_well_shaped_object(reader): + reader.check_object_limits(size=1024, key=OBJECT_KEY) + + +def test_gate_1_rejects_an_oversized_object(reader): + limited = BundleReader(BundleLimits(max_object_bytes=1000)) + + with pytest.raises(BundleGateError) as error: + limited.check_object_limits(size=1001, key=OBJECT_KEY) + + assert error.value.gate == 1 + assert error.value.step is ImportStep.DOWNLOAD + + +def test_gate_1_rejects_an_empty_object(reader): + with pytest.raises(BundleGateError, match='empty'): + reader.check_object_limits(size=0, key=OBJECT_KEY) + + +def test_gate_1_rejects_a_key_outside_the_import_prefix(reader): + with pytest.raises(BundleGateError, match='import prefix'): + reader.check_object_limits(size=10, key='other_place/model.sientia') + + +def test_gate_1_rejects_a_key_with_the_wrong_suffix(reader): + with pytest.raises(BundleGateError, match='.sientia'): + reader.check_object_limits(size=10, key='imported_models/model.zip') + + +# ------------------------------------------------------------- golden bundle + + +def test_opens_the_producer_fixture(golden_bundle, golden_password, work_dir, reader): + """The producer's own bytes, at the producer's own KDF cost.""" + opened = reader.open(golden_bundle, golden_password, work_dir / 'extract', work_dir) + + assert opened.metadata['model_name'] == 'fixture_model' + assert opened.metadata['experiment_name'] == 'fixture_experiment' + assert set(opened.metadata) == { + 'model_name', + 'experiment_name', + 'run_id', + 'model_version', + 'model_project', + 'export_timestamp', + } + assert opened.parameters['target_variable'] == 'SE' + assert opened.header is not None + assert opened.digest == opened.header.ciphertext_digest.hex() + assert _extracted_files(opened.extracted_dir) == [ + 'artifacts/data_model/MLmodel', + 'artifacts/data_model/conda.yaml', + 'artifacts/data_model/model.pkl', + 'artifacts/data_model/python_env.yaml', + 'artifacts/data_model/requirements.txt', + 'artifacts/model_card/model_card.json', + 'artifacts/prediction_model/MLmodel', + 'artifacts/prediction_model/conda.yaml', + 'artifacts/prediction_model/model.pkl', + 'artifacts/prediction_model/python_env.yaml', + 'artifacts/prediction_model/requirements.txt', + 'artifacts/raw_data.csv', + 'artifacts/test_data.csv', + 'artifacts/train_data.csv', + 'metadata.json', + ] + assert list(work_dir.glob('*.zip')) == [] + + +def test_fixture_metadata_matches_its_committed_plaintext_copy( + golden_bundle, golden_password, work_dir, reader +): + from tests.laborious.utils.bundle.conftest import GOLDEN_METADATA + + opened = reader.open(golden_bundle, golden_password, work_dir / 'extract', work_dir) + committed = json.loads(GOLDEN_METADATA.read_text(encoding='utf-8')) + + assert opened.metadata == committed['metadata'] + assert opened.parameters == committed['parameters'] + + +# ------------------------------------------------------------------- gate 2 + + +def test_gate_2_rejects_a_wrong_magic_before_deriving(work_dir, reader, monkeypatch): + payload = factory.make_bundle(magic=b'NOTSIENT') + monkeypatch.setattr( + sodium, 'crypto_pwhash_alg', _never_called('key derivation must not run at gate 2') + ) + + with pytest.raises(BundleGateError) as error: + _open(reader, work_dir, payload) + + assert error.value.gate == 2 + assert error.value.step is ImportStep.DOWNLOAD + + +def test_gate_2_rejects_kdf_parameters_above_the_ceiling_without_allocating( + work_dir, reader, monkeypatch +): + payload = factory.make_bundle(declared_memlimit=512 * 1024 * 1024) + monkeypatch.setattr( + sodium, 'crypto_pwhash_alg', _never_called('the ceiling must be enforced before deriving') + ) + + with pytest.raises(BundleGateError) as error: + _open(reader, work_dir, payload) + + assert error.value.gate == 2 + + +def test_gate_2_rejects_a_digest_mismatch(work_dir, reader): + with pytest.raises(BundleGateError) as error: + _open(reader, work_dir, factory.make_bundle(digest_override=bytes(32))) + + assert error.value.gate == 2 + + +def test_gate_2_rejects_a_truncated_transfer(work_dir, reader): + with pytest.raises(BundleGateError) as error: + _open(reader, work_dir, factory.truncate_bundle(factory.make_bundle())) + + assert error.value.gate == 2 + + +def _never_called(message: str): + def _raise(*_args, **_kwargs): + raise AssertionError(message) + + return _raise + + +# ------------------------------------------------------------------- gate 3 + +AMBIGUOUS_REASON = 'wrong password, or the file is damaged or was altered' + + +def test_gate_3_wrong_password(work_dir, reader): + with pytest.raises(BundleGateError) as error: + _open(reader, work_dir, factory.make_bundle(), password='Wrong1Password!Here') + + assert error.value.gate == 3 + assert error.value.step is ImportStep.DECRYPTION + assert AMBIGUOUS_REASON in str(error.value) + + +def test_gate_3_altered_ciphertext_reads_the_same_as_a_wrong_password(work_dir, reader): + tampered = factory.tamper_ciphertext(factory.make_bundle()) + + with pytest.raises(BundleGateError) as altered: + _open(reader, work_dir, tampered) + with pytest.raises(BundleGateError) as wrong_password: + _open(reader, work_dir, factory.make_bundle(), password='Wrong1Password!Here') + + assert str(altered.value) == str(wrong_password.value) + assert altered.value.gate == wrong_password.value.gate == 3 + + +def test_gate_3_stream_without_a_final_tag_is_rejected(work_dir, reader): + with pytest.raises(BundleGateError) as error: + _open(reader, work_dir, factory.make_bundle(drop_final_tag=True)) + + assert error.value.gate == 3 + assert AMBIGUOUS_REASON in str(error.value) + + +def test_gate_3_leaves_no_decrypted_zip_behind(work_dir, reader): + with pytest.raises(BundleGateError): + _open(reader, work_dir, factory.make_bundle(), password='Wrong1Password!Here') + + assert list(work_dir.glob('*.zip')) == [] + + +def test_gate_3_normalises_the_password_to_nfc(work_dir, reader): + composed = 'Café1Model!Test' + decomposed = 'Café1Model!Test' + payload = factory.make_bundle(password=composed) + + opened = _open(reader, work_dir, payload, password=decomposed) + + assert opened.metadata['model_name'] == 'factory_model' + + +def test_gate_3_does_not_trim_the_password(work_dir, reader): + payload = factory.make_bundle(password='Trailing1Space!Test ') + + with pytest.raises(BundleGateError): + _open(reader, work_dir, payload, password='Trailing1Space!Test') + + +def test_multi_chunk_plaintext_round_trips(work_dir, reader): + """More than 64 KiB of plaintext, so `MESSAGE` and `FINAL` framing are both exercised.""" + spec = factory.ZipSpec() + # Incompressible on purpose: a compressible filler would deflate below one chunk and the test + # would silently stop covering multi-chunk framing. + spec.files['artifacts/prediction_model/model.pkl'] = random.Random( # noqa: S311 - filler + 20260825 + ).randbytes(200 * 1024) + payload = factory.make_bundle(spec) + assert len(payload) > fmt.CHUNK_SIZE + fmt.ABYTES + fmt.DIGEST_OFFSET + + opened = _open(reader, work_dir, payload) + + extracted = opened.extracted_dir / 'artifacts' / 'prediction_model' / 'model.pkl' + assert extracted.read_bytes() == spec.files['artifacts/prediction_model/model.pkl'] + + +# ------------------------------------------------------------------- gate 4 + + +@pytest.mark.parametrize( + ('spec_kwargs', 'match'), + [ + ({'bomb_bytes': 40 * 1024 * 1024}, 'compression ratio'), + ({'traversal_entry': 'artifacts/../../etc/passwd'}, 'escaping'), + ({'absolute_entry': '/etc/passwd'}, 'absolute'), + ({'symlinks': {'artifacts/link': '/etc/passwd'}}, 'symbolic link'), + ], +) +def test_gate_4_rejects_hostile_central_directories(work_dir, reader, spec_kwargs, match): + payload = factory.make_bundle(factory.ZipSpec(**spec_kwargs)) + + with pytest.raises(BundleGateError, match=match) as error: + _open(reader, work_dir, payload) + + assert error.value.gate == 4 + assert error.value.step is ImportStep.ARCHIVE_INSPECTION + assert _extracted_files(work_dir / 'extract') == [] + assert not (work_dir / 'extract').exists() + + +def test_gate_4_rejects_too_many_entries(work_dir): + limited = BundleReader(BundleLimits(max_entries=3)) + + with pytest.raises(BundleGateError, match='entries') as error: + _open(limited, work_dir, factory.make_bundle()) + + assert error.value.gate == 4 + + +def test_gate_4_rejects_an_oversized_uncompressed_total(work_dir): + limited = BundleReader(BundleLimits(max_uncompressed_bytes=64)) + + with pytest.raises(BundleGateError, match='uncompressed') as error: + _open(limited, work_dir, factory.make_bundle()) + + assert error.value.gate == 4 + + +def test_gate_4_rejects_content_that_is_not_an_archive(work_dir, reader): + payload = factory.encrypt_archive(b'not a zip at all') + + with pytest.raises(BundleGateError, match='readable archive') as error: + _open(reader, work_dir, payload) + + assert error.value.gate == 4 + + +# ------------------------------------------------------------------- gate 5 + + +def test_gate_5_refuses_a_pre_existing_extraction_root(work_dir, reader): + (work_dir / 'extract').mkdir() + + with pytest.raises(BundleGateError, match='already exists') as error: + _open(reader, work_dir, factory.make_bundle()) + + assert error.value.gate == 5 + assert error.value.step is ImportStep.EXTRACTION + + +def test_gate_5_extraction_writes_plain_files(work_dir, reader): + opened = _open(reader, work_dir, factory.make_bundle()) + + for path in opened.extracted_dir.rglob('*'): + if path.is_file(): + assert path.stat().st_mode & 0o111 == 0, path + + +# ------------------------------------------------------------------- gate 6 + + +@pytest.mark.parametrize( + ('spec', 'match'), + [ + (factory.ZipSpec(include_metadata=False), 'metadata document'), + ( + factory.ZipSpec( + document=factory.build_metadata_document(drop_top_level_key='parameters') + ), + 'parameters and metadata blocks', + ), + ( + factory.ZipSpec( + document=factory.build_metadata_document(drop_metadata_field='experiment_name') + ), + 'experiment_name', + ), + ( + factory.ZipSpec( + document=factory.build_metadata_document(drop_parameter='target_variable') + ), + 'target_variable', + ), + ( + factory.ZipSpec( + document=factory.build_metadata_document(metadata={'experiment_name': 'x' * 51}) + ), + 'experiment name', + ), + ( + factory.ZipSpec( + document=factory.build_metadata_document(metadata={'experiment_name': 'ab'}) + ), + 'experiment name', + ), + ], +) +def test_gate_6_rejects_a_malformed_metadata_document(work_dir, reader, spec, match): + with pytest.raises(BundleGateError, match=match) as error: + _open(reader, work_dir, factory.make_bundle(spec)) + + assert error.value.gate == 6 + assert error.value.step is ImportStep.STRUCTURE_VALIDATION + assert not (work_dir / 'extract').exists() + + +def test_gate_6_rejects_unreadable_metadata_json(work_dir, reader): + spec = factory.ZipSpec(include_metadata=False) + spec.extra_files['metadata.json'] = b'{not json' + + with pytest.raises(BundleGateError, match='readable JSON') as error: + _open(reader, work_dir, factory.make_bundle(spec)) + + assert error.value.gate == 6 + + +def test_gate_6_rejects_a_missing_artifacts_directory(work_dir, reader): + spec = factory.ZipSpec(files={}) + + with pytest.raises(BundleGateError, match='artifacts directory') as error: + _open(reader, work_dir, factory.make_bundle(spec)) + + assert error.value.gate == 6 + + +@pytest.mark.parametrize( + ('dropped', 'match'), + [ + ('artifacts/data_model/MLmodel', 'data_model artifacts are incomplete'), + ('artifacts/prediction_model/model.pkl', 'prediction_model artifacts are incomplete'), + ], +) +def test_gate_6_rejects_an_incomplete_model_directory(work_dir, reader, dropped, match): + spec = factory.ZipSpec() + spec.files.pop(dropped) + + with pytest.raises(BundleGateError, match=match) as error: + _open(reader, work_dir, factory.make_bundle(spec)) + + assert error.value.gate == 6 + + +def test_gate_6_rejects_a_bundle_without_data_model(work_dir, reader): + spec = factory.ZipSpec() + for name in list(spec.files): + if name.startswith('artifacts/data_model/'): + spec.files.pop(name) + + with pytest.raises(BundleGateError, match='no data_model artifacts') as error: + _open(reader, work_dir, factory.make_bundle(spec)) + + assert error.value.gate == 6 + + +# ------------------------------------------------------------------- gate 7 + + +@pytest.mark.parametrize('name', ['artifacts/run.sh', 'artifacts/tool.bin', 'artifacts/mod.so']) +def test_gate_7_rejects_content_a_model_export_should_not_carry(work_dir, reader, name): + spec = factory.ZipSpec() + spec.extra_files[name] = b'#!/bin/sh\n' + + with pytest.raises(BundleGateError, match='should not carry') as error: + _open(reader, work_dir, factory.make_bundle(spec)) + + assert error.value.gate == 7 + assert error.value.step is ImportStep.CONTENT_POLICY + assert not (work_dir / 'extract').exists() + + +# ------------------------------------------------------------------ ordering + + +def test_the_earlier_gate_wins(work_dir, reader): + """A bundle that would fail gate 4 and gate 6 must report gate 4, having extracted nothing.""" + spec = factory.ZipSpec( + traversal_entry='artifacts/../escape.csv', + document=factory.build_metadata_document(drop_parameter='target_variable'), + ) + + with pytest.raises(BundleGateError) as error: + _open(reader, work_dir, factory.make_bundle(spec)) + + assert error.value.gate == 4 + assert _extracted_files(work_dir / 'extract') == [] + + +def test_header_is_checked_before_the_password(work_dir, reader): + """A bundle with a bad header and a wrong password reports the header, not the password.""" + with pytest.raises(BundleGateError) as error: + _open(reader, work_dir, factory.make_bundle(magic=b'NOTSIENT'), password='Nope1!Nope1!') + + assert error.value.gate == 2 diff --git a/tests/laborious/utils/test_connectors_config.py b/tests/laborious/utils/test_connectors_config.py index b94bb8d..8dd82f5 100644 --- a/tests/laborious/utils/test_connectors_config.py +++ b/tests/laborious/utils/test_connectors_config.py @@ -1,11 +1,42 @@ -from os import environ +import tempfile +from os import environ, path + +import pytest from laborious.utils.connectors_config import ( + build_import_config, + build_import_status_config, build_minio_config, build_mlflow_config, build_opc_config, ) +IMPORT_ENV_VARS = ( + 'IMPORT_PASSWORD_KEY', + 'IMPORT_BUNDLE_BUCKET', + 'IMPORT_BUNDLE_PREFIX', + 'IMPORT_WORK_DIR', + 'IMPORT_MAX_OBJECT_BYTES', + 'IMPORT_MAX_ARCHIVE_ENTRIES', + 'IMPORT_MAX_UNCOMPRESSED_BYTES', + 'IMPORT_MAX_COMPRESSION_RATIO', + 'IMPORT_MODELS_COLLECTION', + 'IMPORT_BUNDLE_RETENTION_DAYS', + 'IMPORT_STATUS_DB_HOST', + 'IMPORT_STATUS_DB_PORT', + 'IMPORT_STATUS_DB_NAME', + 'IMPORT_STATUS_DB_USER', + 'IMPORT_STATUS_DB_PASSWORD', +) + + +@pytest.fixture +def clean_import_env(monkeypatch): + """Import configuration is read from a known-empty environment.""" + for name in IMPORT_ENV_VARS: + monkeypatch.delenv(name, raising=False) + return monkeypatch + def test_build_mlflow_config_with_env_vars(): # Arrange @@ -120,3 +151,83 @@ def test_build_minio_config_with_defaults(): 'retention_hours': 24, 'secure': False, } + + +def test_build_import_config_with_defaults(clean_import_env): + assert build_import_config() == { + 'password_key': None, + 'bucket': 'sientia', + 'prefix': 'imported_models/', + 'work_dir': path.join(tempfile.gettempdir(), 'sientia-import'), + 'max_object_bytes': 1024 * 1024 * 1024, + 'max_entries': 5000, + 'max_uncompressed_bytes': 4 * 1024 * 1024 * 1024, + 'max_compression_ratio': 200.0, + 'models_collection': 'models', + 'retention_days': 7, + } + + +def test_build_import_config_reads_the_environment(clean_import_env): + clean_import_env.setenv('IMPORT_PASSWORD_KEY', 'a2V5') + clean_import_env.setenv('IMPORT_BUNDLE_BUCKET', 'other-bucket') + clean_import_env.setenv('IMPORT_BUNDLE_PREFIX', 'incoming/models') + clean_import_env.setenv('IMPORT_WORK_DIR', '/var/lib/import') + clean_import_env.setenv('IMPORT_MAX_OBJECT_BYTES', '2048') + clean_import_env.setenv('IMPORT_MAX_ARCHIVE_ENTRIES', '12') + clean_import_env.setenv('IMPORT_MAX_UNCOMPRESSED_BYTES', '4096') + clean_import_env.setenv('IMPORT_MAX_COMPRESSION_RATIO', '12.5') + clean_import_env.setenv('IMPORT_MODELS_COLLECTION', 'models_v2') + clean_import_env.setenv('IMPORT_BUNDLE_RETENTION_DAYS', '14') + + config = build_import_config() + + assert config['password_key'] == 'a2V5' + assert config['bucket'] == 'other-bucket' + # A prefix without its trailing slash would make `imported_models_backup/x` pass the gate-1 + # prefix check, so the separator is added rather than trusted. + assert config['prefix'] == 'incoming/models/' + assert config['work_dir'] == '/var/lib/import' + assert config['max_object_bytes'] == 2048 + assert config['max_entries'] == 12 + assert config['max_uncompressed_bytes'] == 4096 + assert config['max_compression_ratio'] == 12.5 + assert config['models_collection'] == 'models_v2' + assert config['retention_days'] == 14 + + +def test_build_import_config_does_not_validate_the_password_key(clean_import_env): + """A missing or malformed key must fail the import at decryption, never at worker start.""" + clean_import_env.setenv('IMPORT_PASSWORD_KEY', 'not base64 at all') + + assert build_import_config()['password_key'] == 'not base64 at all' + + +def test_build_import_status_config_with_defaults(clean_import_env): + assert build_import_status_config() == { + 'host': 'localhost', + 'port': 5432, + 'dbname': 'sientia-core-mlops-bff', + 'user': 'mlops_bff_user', + 'password': '', + 'schema': 'public', + } + + +def test_build_import_status_config_reads_the_environment(clean_import_env): + clean_import_env.setenv('IMPORT_STATUS_DB_HOST', 'bff-postgres') + clean_import_env.setenv('IMPORT_STATUS_DB_PORT', '6543') + clean_import_env.setenv('IMPORT_STATUS_DB_NAME', 'other-db') + clean_import_env.setenv('IMPORT_STATUS_DB_USER', 'other-user') + clean_import_env.setenv('IMPORT_STATUS_DB_PASSWORD', 'secret') + + config = build_import_status_config() + + assert config == { + 'host': 'bff-postgres', + 'port': 6543, + 'dbname': 'other-db', + 'user': 'other-user', + 'password': 'secret', + 'schema': 'public', + } diff --git a/tests/laborious/utils/test_import_password.py b/tests/laborious/utils/test_import_password.py new file mode 100644 index 0000000..5d01876 --- /dev/null +++ b/tests/laborious/utils/test_import_password.py @@ -0,0 +1,99 @@ +"""The password envelope: AES-256-GCM in, plaintext password out, nothing in between.""" + +import base64 + +import pytest + +from laborious.utils.import_password import ( + EnvelopeError, + decrypt_password_envelope, + load_envelope_key, +) +from tests.helpers import bundle_factory as factory + +KEY = factory.DEFAULT_ENVELOPE_KEY +OTHER_KEY = bytes(range(1, 33)) + + +def test_recovers_the_password_used_at_export(): + envelope = factory.build_envelope('Correct1Horse!Battery', KEY) + + assert decrypt_password_envelope(envelope, KEY) == 'Correct1Horse!Battery' + + +def test_wrong_key_fails_authentication(): + envelope = factory.build_envelope('Correct1Horse!Battery', KEY) + + with pytest.raises(EnvelopeError, match='authenticated'): + decrypt_password_envelope(envelope, OTHER_KEY) + + +def test_tampered_envelope_fails_authentication(): + payload = bytearray(base64.b64decode(factory.build_envelope('Correct1Horse!Battery', KEY))) + payload[-1] ^= 0xFF + + with pytest.raises(EnvelopeError, match='authenticated'): + decrypt_password_envelope(base64.b64encode(bytes(payload)).decode(), KEY) + + +def test_malformed_base64_rejected(): + with pytest.raises(EnvelopeError, match='base64'): + decrypt_password_envelope('this is not base64!!', KEY) + + +def test_envelope_too_short_rejected(): + with pytest.raises(EnvelopeError, match='too short'): + decrypt_password_envelope(base64.b64encode(bytes(20)).decode(), KEY) + + +def test_empty_envelope_rejected(): + with pytest.raises(EnvelopeError, match='no password envelope'): + decrypt_password_envelope('', KEY) + + +def test_password_is_not_trimmed(): + """A trailing space is part of the secret; the KDF must see it.""" + envelope = factory.build_envelope('Trailing1Space!Test ', KEY) + + assert decrypt_password_envelope(envelope, KEY) == 'Trailing1Space!Test ' + + +def test_password_keeps_its_unicode_form(): + """Normalisation happens at key derivation, not here — the envelope round-trips verbatim.""" + decomposed = 'Café1Model!Test' + envelope = factory.build_envelope(decomposed, KEY) + + assert decrypt_password_envelope(envelope, KEY) == decomposed + + +def test_loads_a_valid_key(): + assert load_envelope_key(factory.envelope_key_b64(KEY)) == KEY + + +@pytest.mark.parametrize( + ('raw_key', 'match'), + [ + (None, 'not configured'), + ('', 'not configured'), + ('not base64 at all', 'valid base64'), + (base64.b64encode(bytes(16)).decode(), '32 bytes'), + (base64.b64encode(bytes(64)).decode(), '32 bytes'), + ], +) +def test_rejects_an_unusable_key(raw_key, match): + """There is no fallback: an unusable key never means the envelope is plaintext.""" + with pytest.raises(EnvelopeError, match=match): + load_envelope_key(raw_key) + + +def test_no_secret_appears_in_the_error_text(): + password = 'Correct1Horse!Battery' + envelope = factory.build_envelope(password, KEY) + + with pytest.raises(EnvelopeError) as error: + decrypt_password_envelope(envelope, OTHER_KEY) + + message = str(error.value) + assert password not in message + assert envelope not in message + assert factory.envelope_key_b64(OTHER_KEY) not in message