From 2d3abb9f3df12454be90d6b5b78bbd9dc76c9639 Mon Sep 17 00:00:00 2001 From: "a.tolmachev" Date: Wed, 25 Mar 2026 21:31:54 +0300 Subject: [PATCH] feat: harden demo flows and observability --- README.md | 1 + TASKS.md | 13 ++-- apps/admin-api/src/app.rs | 113 +++++++++++++++++++++++++++++++++ apps/admin-api/src/error.rs | 26 +++++++- apps/admin-api/src/service.rs | 67 +++++++++++++++++-- apps/mcp-server/src/catalog.rs | 8 +++ docs/demo-runbook.md | 105 ++++++++++++++++++++++++++++++ 7 files changed, 320 insertions(+), 13 deletions(-) create mode 100644 docs/demo-runbook.md diff --git a/README.md b/README.md index 8ec25ac..96d4320 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,7 @@ MCPaaS - это low-code платформа для публикации внеш - `docs/testing-strategy.md` - стратегия тестирования до и во время разработки. - `docs/runtime-config.md` - конфигурация окружения, storage и секретов. - `docs/deployment.md` - контейнерный деплой, reverse proxy и CI/CD. +- `docs/demo-runbook.md` - пошаговый сценарий локального запуска и воспроизводимого демо. - `docs/rust-design.md` - распределение методов, `impl`, `trait` и service-слоя без god-struct. - `docs/development-rules.md` - правила разработки, TDD-процесс и git workflow. - `docs/rust-code-rules.md` - Rust-specific правила кода, toolchain и linting. diff --git a/TASKS.md b/TASKS.md index ec4910e..8c9d070 100644 --- a/TASKS.md +++ b/TASKS.md @@ -2,20 +2,21 @@ ## Current -### `feat/grpc-support` +### `feat/hardening` Status: completed DoD: -- gRPC descriptor set can be uploaded and inspected through admin-api -- unary gRPC operation can be tested through runtime -- published gRPC tool is callable through MCP -- integration tests cover adapter, runtime, admin-api and MCP gRPC scenarios +- демонстрационные сценарии воспроизводимы +- логирование и ошибки читаемы +- YAML roundtrip проверен +- publish/reload flow стабилен +- документация по запуску достаточна для повторения демо ## Next -- `feat/hardening` +- `feat/deployment-cd-polish` ## Backlog diff --git a/apps/admin-api/src/app.rs b/apps/admin-api/src/app.rs index 324efd1..83848d0 100644 --- a/apps/admin-api/src/app.rs +++ b/apps/admin-api/src/app.rs @@ -375,6 +375,119 @@ mod tests { assert_eq!(imported["import_mode"], "upsert"); } + #[tokio::test] + async fn roundtrips_graphql_operation_through_yaml_upsert() { + let registry = test_registry().await; + let storage_root = test_storage_root("yaml_graphql"); + let upstream_base_url = spawn_graphql_server().await; + let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await; + let client = reqwest::Client::new(); + + let created = client + .post(format!("{base_url}/operations")) + .json(&test_graphql_operation_payload( + &upstream_base_url, + "crm_yaml_graphql", + )) + .send() + .await + .unwrap() + .json::() + .await + .unwrap(); + let operation_id = created["operation_id"].as_str().unwrap().to_owned(); + + let yaml = client + .get(format!("{base_url}/operations/{operation_id}/export")) + .send() + .await + .unwrap() + .text() + .await + .unwrap(); + let imported = client + .post(format!("{base_url}/operations/import?mode=upsert")) + .body(yaml) + .send() + .await + .unwrap() + .json::() + .await + .unwrap(); + let test_run = client + .post(format!("{base_url}/operations/{operation_id}/test-runs")) + .json(&json!({ + "version": 2, + "input": { "email": "user@example.com" } + })) + .send() + .await + .unwrap() + .json::() + .await + .unwrap(); + + assert_eq!(imported["operation_id"], operation_id); + assert_eq!(imported["version"], 2); + assert_eq!(test_run["ok"], true); + assert_eq!(test_run["response_preview"]["id"], "lead_123"); + } + + #[tokio::test] + async fn roundtrips_grpc_operation_through_yaml_upsert() { + let registry = test_registry().await; + let storage_root = test_storage_root("yaml_grpc"); + let server_addr = grpc_test_support::spawn_unary_echo_server().await; + let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await; + let client = reqwest::Client::new(); + + let created = client + .post(format!("{base_url}/operations")) + .json(&test_grpc_operation_payload(&server_addr, "echo_yaml_grpc")) + .send() + .await + .unwrap() + .json::() + .await + .unwrap(); + let operation_id = created["operation_id"].as_str().unwrap().to_owned(); + + let yaml = client + .get(format!("{base_url}/operations/{operation_id}/export")) + .send() + .await + .unwrap() + .text() + .await + .unwrap(); + let imported = client + .post(format!("{base_url}/operations/import?mode=upsert")) + .body(yaml) + .send() + .await + .unwrap() + .json::() + .await + .unwrap(); + let test_run = client + .post(format!("{base_url}/operations/{operation_id}/test-runs")) + .json(&json!({ + "version": 2, + "input": { "message": "hello" } + })) + .send() + .await + .unwrap() + .json::() + .await + .unwrap(); + + assert_eq!(imported["operation_id"], operation_id); + assert_eq!(imported["version"], 2); + assert_eq!(test_run["ok"], true); + assert_eq!(test_run["response_preview"]["message"], "hello"); + } + #[tokio::test] async fn uploads_samples_and_generates_draft() { let registry = test_registry().await; diff --git a/apps/admin-api/src/error.rs b/apps/admin-api/src/error.rs index 6409774..6f8778d 100644 --- a/apps/admin-api/src/error.rs +++ b/apps/admin-api/src/error.rs @@ -9,6 +9,7 @@ use mcpaas_runtime::RuntimeError; use mcpaas_schema::SchemaError; use serde_json::{Value, json}; use thiserror::Error; +use tracing::{error, warn}; use crate::storage::StorageError; @@ -70,6 +71,17 @@ impl ApiError { impl IntoResponse for ApiError { fn into_response(self) -> Response { + match &self { + Self::Internal { message } => { + error!(error_code = self.code(), error_message = %message) + } + Self::Validation { message } + | Self::NotFound { message } + | Self::Conflict { message } => { + warn!(error_code = self.code(), error_message = %message) + } + } + let body = Json(json!({ "error": { "code": self.code(), @@ -137,7 +149,19 @@ impl From for ApiError { pub fn runtime_test_failure(error: &RuntimeError) -> Value { json!({ - "code": "runtime_test_error", + "code": runtime_test_failure_code(error), "message": error.to_string() }) } + +fn runtime_test_failure_code(error: &RuntimeError) -> &'static str { + match error { + RuntimeError::Schema(_) => "runtime_schema_error", + RuntimeError::Mapping(_) => "runtime_mapping_error", + RuntimeError::GraphqlAdapter(_) => "runtime_graphql_error", + RuntimeError::GrpcAdapter(_) => "runtime_grpc_error", + RuntimeError::RestAdapter(_) => "runtime_rest_error", + RuntimeError::UnsupportedProtocol { .. } => "runtime_protocol_error", + RuntimeError::InvalidPreparedRequest { .. } => "runtime_request_error", + } +} diff --git a/apps/admin-api/src/service.rs b/apps/admin-api/src/service.rs index db8c0ad..5024c88 100644 --- a/apps/admin-api/src/service.rs +++ b/apps/admin-api/src/service.rs @@ -16,6 +16,7 @@ use mcpaas_schema::Schema; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use time::{OffsetDateTime, format_description::well_known::Rfc3339}; +use tracing::{info, instrument}; use uuid::Uuid; use crate::{error::ApiError, storage::LocalArtifactStorage}; @@ -170,10 +171,12 @@ impl AdminService { } } + #[instrument(skip(self))] pub async fn list_operations(&self) -> Result, ApiError> { Ok(self.registry.list_operations().await?) } + #[instrument(skip(self))] pub async fn get_operation( &self, operation_id: &OperationId, @@ -186,6 +189,7 @@ impl AdminService { }) } + #[instrument(skip(self))] pub async fn get_operation_version( &self, operation_id: &OperationId, @@ -202,6 +206,7 @@ impl AdminService { }) } + #[instrument(skip(self, payload), fields(protocol = ?payload.protocol, operation_name = %payload.name))] pub async fn create_operation( &self, payload: OperationPayload, @@ -243,6 +248,7 @@ impl AdminService { }; self.registry.create_operation(&snapshot, None).await?; + info!(operation_id = %operation_id.as_str(), version = 1, "operation created"); Ok(CreatedOperationResponse { operation_id: operation_id.as_str().to_owned(), @@ -251,6 +257,7 @@ impl AdminService { }) } + #[instrument(skip(self, payload), fields(operation_id = %operation_id.as_str(), protocol = ?payload.operation.protocol))] pub async fn create_version( &self, operation_id: &OperationId, @@ -293,6 +300,7 @@ impl AdminService { created_by: None, }) .await?; + info!(operation_id = %operation_id.as_str(), version, "operation version created"); Ok(CreatedOperationResponse { operation_id: operation_id.as_str().to_owned(), @@ -301,6 +309,7 @@ impl AdminService { }) } + #[instrument(skip(self), fields(operation_id = %operation_id.as_str(), version))] pub async fn publish_operation( &self, operation_id: &OperationId, @@ -315,6 +324,7 @@ impl AdminService { published_by: None, }) .await?; + info!(operation_id = %operation_id.as_str(), version, "operation published"); Ok(PublishResponse { operation_id: operation_id.as_str().to_owned(), @@ -323,6 +333,7 @@ impl AdminService { }) } + #[instrument(skip(self, payload), fields(operation_id = %operation_id.as_str(), version = payload.version))] pub async fn run_test( &self, operation_id: &OperationId, @@ -363,10 +374,12 @@ impl AdminService { } } + #[instrument(skip(self))] pub async fn list_auth_profiles(&self) -> Result, ApiError> { Ok(self.registry.list_auth_profiles().await?) } + #[instrument(skip(self, payload), fields(operation_id = %operation_id.as_str(), source_name = source_name.unwrap_or("descriptor-set.bin")))] pub async fn upload_descriptor_set( &self, operation_id: &OperationId, @@ -412,6 +425,12 @@ impl AdminService { }, }) .await?; + info!( + operation_id = %operation_id.as_str(), + descriptor_id = %descriptor_id.as_str(), + version = summary.current_draft_version, + "descriptor set uploaded" + ); Ok(DescriptorUploadResponse { descriptor_id: descriptor_id.as_str().to_owned(), @@ -419,6 +438,7 @@ impl AdminService { }) } + #[instrument(skip(self, payload), fields(operation_id = %operation_id.as_str(), source_name = source_name.unwrap_or("schema.proto")))] pub async fn upload_proto_file( &self, operation_id: &OperationId, @@ -459,6 +479,12 @@ impl AdminService { }, }) .await?; + info!( + operation_id = %operation_id.as_str(), + descriptor_id = %descriptor_id.as_str(), + version = summary.current_draft_version, + "proto file uploaded" + ); Ok(DescriptorUploadResponse { descriptor_id: descriptor_id.as_str().to_owned(), @@ -466,6 +492,7 @@ impl AdminService { }) } + #[instrument(skip(self), fields(operation_id = %operation_id.as_str(), version))] pub async fn list_grpc_services( &self, operation_id: &OperationId, @@ -499,6 +526,7 @@ impl AdminService { .collect::, _>>() } + #[instrument(skip(self))] pub async fn get_auth_profile( &self, auth_profile_id: &AuthProfileId, @@ -514,6 +542,7 @@ impl AdminService { }) } + #[instrument(skip(self, payload), fields(auth_profile_name = %payload.name, auth_kind = ?payload.kind))] pub async fn create_auth_profile( &self, payload: AuthProfilePayload, @@ -533,10 +562,12 @@ impl AdminService { self.registry .save_auth_profile(SaveAuthProfileRequest { profile: &profile }) .await?; + info!(auth_profile_id = %profile.id.as_str(), "auth profile created"); Ok(profile) } + #[instrument(skip(self), fields(operation_id = %operation_id.as_str(), version = query.version.unwrap_or_default(), mode = ?query.mode))] pub async fn export_operation( &self, operation_id: &OperationId, @@ -566,6 +597,7 @@ impl AdminService { serde_yaml::to_string(&document).map_err(|error| ApiError::internal(error.to_string())) } + #[instrument(skip(self, yaml_document), fields(mode = ?query.mode))] pub async fn import_operation( &self, query: ImportQuery, @@ -615,25 +647,38 @@ impl AdminService { ) .await?; - Ok(ImportResponse { + let response = ImportResponse { operation_id: created.operation_id, version: created.version, import_mode: ImportMode::Upsert, warnings: Vec::new(), - }) + }; + info!( + operation_id = %response.operation_id, + version = response.version, + "operation imported by upsert" + ); + Ok(response) } else { let created = self.create_operation(payload).await?; - Ok(ImportResponse { + let response = ImportResponse { operation_id: created.operation_id, version: created.version, import_mode: ImportMode::Upsert, warnings: Vec::new(), - }) + }; + info!( + operation_id = %response.operation_id, + version = response.version, + "operation imported by upsert" + ); + Ok(response) } } } } + #[instrument(skip(self, payload), fields(operation_id = %operation_id.as_str(), sample_kind = ?sample_kind))] pub async fn save_json_sample( &self, operation_id: &OperationId, @@ -667,10 +712,17 @@ impl AdminService { self.registry .save_sample_metadata(SaveSampleMetadataRequest { sample: &metadata }) .await?; + info!( + operation_id = %operation_id.as_str(), + sample_id = %metadata.id.as_str(), + version, + "json sample saved" + ); Ok(metadata) } + #[instrument(skip(self, payload), fields(operation_id = %operation_id.as_str()))] pub async fn generate_draft( &self, operation_id: &OperationId, @@ -722,13 +774,16 @@ impl AdminService { warnings: Vec::new(), }; - Ok(DraftGenerationResult { + let result = DraftGenerationResult { generated_draft, input_schema, output_schema, input_mapping, output_mapping, - }) + }; + info!(operation_id = %operation_id.as_str(), "draft generated from samples"); + + Ok(result) } fn validate_operation_payload(&self, payload: &OperationPayload) -> Result<(), ApiError> { diff --git a/apps/mcp-server/src/catalog.rs b/apps/mcp-server/src/catalog.rs index 8be3242..4e2e773 100644 --- a/apps/mcp-server/src/catalog.rs +++ b/apps/mcp-server/src/catalog.rs @@ -6,6 +6,7 @@ use std::{ use mcpaas_registry::{PostgresRegistry, RegistryError, RegistryOperation}; use tokio::sync::RwLock; +use tracing::info; #[derive(Clone)] pub struct PublishedToolCatalog { @@ -66,11 +67,18 @@ impl PublishedToolCatalog { .map(|operation| (operation.name.clone(), operation)) .collect::>(); let mut guard = self.cached.write().await; + let previous_count = guard.tools.len(); guard.loaded_at = Some(Instant::now()); guard.tools = tools; guard.tools_by_name = tools_by_name; + info!( + published_tool_count = guard.tools.len(), + previous_published_tool_count = previous_count, + "published tool catalog refreshed" + ); + Ok(()) } } diff --git a/docs/demo-runbook.md b/docs/demo-runbook.md new file mode 100644 index 0000000..6b07124 --- /dev/null +++ b/docs/demo-runbook.md @@ -0,0 +1,105 @@ +# Demo Runbook + +## 1. Цель документа + +Этот документ фиксирует минимальный воспроизводимый сценарий запуска и демонстрации MCPaaS без знания внутренней структуры кода. + +## 2. Предусловия + +- доступен `PostgreSQL`; +- задан `MCPAAS_DATABASE_URL`; +- задан `MCPAAS_STORAGE_ROOT`; +- доступны `MCPAAS_ADMIN_BIND` и `MCPAAS_MCP_BIND`; +- для UI установлен `Node.js`; +- для Rust-части установлен toolchain из `rust-toolchain.toml`. + +## 3. Быстрый локальный запуск + +### Backend + +```bash +cargo run -p admin-api +``` + +Во втором терминале: + +```bash +cargo run -p mcp-server +``` + +### UI + +```bash +cd apps/ui +npm install +npm run dev +``` + +## 4. Контрольные health endpoints + +```bash +curl http://127.0.0.1:3001/health +curl http://127.0.0.1:3002/health +``` + +Ожидаемо: + +- `admin-api` возвращает `{"service":"admin-api","status":"ok"}` +- `mcp-server` возвращает `{"service":"mcp-server","status":"ok"}` + +## 5. Demo flow + +### REST + +1. Создать REST operation через UI или `admin-api`. +2. Выполнить `test-run`. +3. Опубликовать operation. +4. Проверить появление tool в MCP через `tools/list`. +5. Выполнить `tools/call`. + +### GraphQL + +1. Создать GraphQL operation с фиксированным `query` или `mutation`. +2. Выполнить `test-run`. +3. Экспортировать YAML. +4. Импортировать YAML в режиме `upsert`. +5. Опубликовать operation и вызвать ее через MCP. + +### gRPC + +1. Создать gRPC operation. +2. Загрузить `descriptor set`. +3. Проверить `grpc/services` discovery summary. +4. Выполнить `test-run`. +5. Экспортировать YAML. +6. Импортировать YAML в режиме `upsert`. +7. Опубликовать operation и вызвать ее через MCP. + +## 6. Проверка publish/reload flow + +После публикации новой операции `mcp-server` не требует restart. + +Проверка: + +1. Открыть MCP session. +2. Вызвать `tools/list`. +3. Опубликовать новую operation через `admin-api`. +4. Повторно вызвать `tools/list`. +5. Убедиться, что новый tool появился после refresh interval. + +## 7. Проверка YAML roundtrip + +YAML roundtrip считается успешным, если: + +1. operation экспортируется через `/api/admin/operations/{operation_id}/export`; +2. экспортированный YAML импортируется через `/api/admin/operations/import?mode=upsert`; +3. создается новая версия operation; +4. `test-run` новой версии проходит успешно. + +## 8. Что считать готовым demo state + +- все три протокола проходят сценарий `create -> test-run -> publish -> MCP call`; +- YAML export/import проходит хотя бы для `REST`, `GraphQL` и `gRPC`; +- `mcp-server` подхватывает published changes без restart; +- ошибки `admin-api` и runtime возвращаются в читаемом виде; +- логи позволяют понять, какая операция создавалась, тестировалась, публиковалась или импортировалась.