feat: harden demo flows and observability
This commit is contained in:
@@ -36,6 +36,7 @@ MCPaaS - это low-code платформа для публикации внеш
|
|||||||
- `docs/testing-strategy.md` - стратегия тестирования до и во время разработки.
|
- `docs/testing-strategy.md` - стратегия тестирования до и во время разработки.
|
||||||
- `docs/runtime-config.md` - конфигурация окружения, storage и секретов.
|
- `docs/runtime-config.md` - конфигурация окружения, storage и секретов.
|
||||||
- `docs/deployment.md` - контейнерный деплой, reverse proxy и CI/CD.
|
- `docs/deployment.md` - контейнерный деплой, reverse proxy и CI/CD.
|
||||||
|
- `docs/demo-runbook.md` - пошаговый сценарий локального запуска и воспроизводимого демо.
|
||||||
- `docs/rust-design.md` - распределение методов, `impl`, `trait` и service-слоя без god-struct.
|
- `docs/rust-design.md` - распределение методов, `impl`, `trait` и service-слоя без god-struct.
|
||||||
- `docs/development-rules.md` - правила разработки, TDD-процесс и git workflow.
|
- `docs/development-rules.md` - правила разработки, TDD-процесс и git workflow.
|
||||||
- `docs/rust-code-rules.md` - Rust-specific правила кода, toolchain и linting.
|
- `docs/rust-code-rules.md` - Rust-specific правила кода, toolchain и linting.
|
||||||
|
|||||||
@@ -2,20 +2,21 @@
|
|||||||
|
|
||||||
## Current
|
## Current
|
||||||
|
|
||||||
### `feat/grpc-support`
|
### `feat/hardening`
|
||||||
|
|
||||||
Status: completed
|
Status: completed
|
||||||
|
|
||||||
DoD:
|
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
|
- YAML roundtrip проверен
|
||||||
- integration tests cover adapter, runtime, admin-api and MCP gRPC scenarios
|
- publish/reload flow стабилен
|
||||||
|
- документация по запуску достаточна для повторения демо
|
||||||
|
|
||||||
## Next
|
## Next
|
||||||
|
|
||||||
- `feat/hardening`
|
- `feat/deployment-cd-polish`
|
||||||
|
|
||||||
## Backlog
|
## Backlog
|
||||||
|
|
||||||
|
|||||||
@@ -375,6 +375,119 @@ mod tests {
|
|||||||
assert_eq!(imported["import_mode"], "upsert");
|
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::<Value>()
|
||||||
|
.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::<Value>()
|
||||||
|
.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::<Value>()
|
||||||
|
.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::<Value>()
|
||||||
|
.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::<Value>()
|
||||||
|
.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::<Value>()
|
||||||
|
.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]
|
#[tokio::test]
|
||||||
async fn uploads_samples_and_generates_draft() {
|
async fn uploads_samples_and_generates_draft() {
|
||||||
let registry = test_registry().await;
|
let registry = test_registry().await;
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ use mcpaas_runtime::RuntimeError;
|
|||||||
use mcpaas_schema::SchemaError;
|
use mcpaas_schema::SchemaError;
|
||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
|
use tracing::{error, warn};
|
||||||
|
|
||||||
use crate::storage::StorageError;
|
use crate::storage::StorageError;
|
||||||
|
|
||||||
@@ -70,6 +71,17 @@ impl ApiError {
|
|||||||
|
|
||||||
impl IntoResponse for ApiError {
|
impl IntoResponse for ApiError {
|
||||||
fn into_response(self) -> Response {
|
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!({
|
let body = Json(json!({
|
||||||
"error": {
|
"error": {
|
||||||
"code": self.code(),
|
"code": self.code(),
|
||||||
@@ -137,7 +149,19 @@ impl From<StorageError> for ApiError {
|
|||||||
|
|
||||||
pub fn runtime_test_failure(error: &RuntimeError) -> Value {
|
pub fn runtime_test_failure(error: &RuntimeError) -> Value {
|
||||||
json!({
|
json!({
|
||||||
"code": "runtime_test_error",
|
"code": runtime_test_failure_code(error),
|
||||||
"message": error.to_string()
|
"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",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ use mcpaas_schema::Schema;
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
use time::{OffsetDateTime, format_description::well_known::Rfc3339};
|
use time::{OffsetDateTime, format_description::well_known::Rfc3339};
|
||||||
|
use tracing::{info, instrument};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::{error::ApiError, storage::LocalArtifactStorage};
|
use crate::{error::ApiError, storage::LocalArtifactStorage};
|
||||||
@@ -170,10 +171,12 @@ impl AdminService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[instrument(skip(self))]
|
||||||
pub async fn list_operations(&self) -> Result<Vec<OperationSummary>, ApiError> {
|
pub async fn list_operations(&self) -> Result<Vec<OperationSummary>, ApiError> {
|
||||||
Ok(self.registry.list_operations().await?)
|
Ok(self.registry.list_operations().await?)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[instrument(skip(self))]
|
||||||
pub async fn get_operation(
|
pub async fn get_operation(
|
||||||
&self,
|
&self,
|
||||||
operation_id: &OperationId,
|
operation_id: &OperationId,
|
||||||
@@ -186,6 +189,7 @@ impl AdminService {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[instrument(skip(self))]
|
||||||
pub async fn get_operation_version(
|
pub async fn get_operation_version(
|
||||||
&self,
|
&self,
|
||||||
operation_id: &OperationId,
|
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(
|
pub async fn create_operation(
|
||||||
&self,
|
&self,
|
||||||
payload: OperationPayload,
|
payload: OperationPayload,
|
||||||
@@ -243,6 +248,7 @@ impl AdminService {
|
|||||||
};
|
};
|
||||||
|
|
||||||
self.registry.create_operation(&snapshot, None).await?;
|
self.registry.create_operation(&snapshot, None).await?;
|
||||||
|
info!(operation_id = %operation_id.as_str(), version = 1, "operation created");
|
||||||
|
|
||||||
Ok(CreatedOperationResponse {
|
Ok(CreatedOperationResponse {
|
||||||
operation_id: operation_id.as_str().to_owned(),
|
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(
|
pub async fn create_version(
|
||||||
&self,
|
&self,
|
||||||
operation_id: &OperationId,
|
operation_id: &OperationId,
|
||||||
@@ -293,6 +300,7 @@ impl AdminService {
|
|||||||
created_by: None,
|
created_by: None,
|
||||||
})
|
})
|
||||||
.await?;
|
.await?;
|
||||||
|
info!(operation_id = %operation_id.as_str(), version, "operation version created");
|
||||||
|
|
||||||
Ok(CreatedOperationResponse {
|
Ok(CreatedOperationResponse {
|
||||||
operation_id: operation_id.as_str().to_owned(),
|
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(
|
pub async fn publish_operation(
|
||||||
&self,
|
&self,
|
||||||
operation_id: &OperationId,
|
operation_id: &OperationId,
|
||||||
@@ -315,6 +324,7 @@ impl AdminService {
|
|||||||
published_by: None,
|
published_by: None,
|
||||||
})
|
})
|
||||||
.await?;
|
.await?;
|
||||||
|
info!(operation_id = %operation_id.as_str(), version, "operation published");
|
||||||
|
|
||||||
Ok(PublishResponse {
|
Ok(PublishResponse {
|
||||||
operation_id: operation_id.as_str().to_owned(),
|
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(
|
pub async fn run_test(
|
||||||
&self,
|
&self,
|
||||||
operation_id: &OperationId,
|
operation_id: &OperationId,
|
||||||
@@ -363,10 +374,12 @@ impl AdminService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[instrument(skip(self))]
|
||||||
pub async fn list_auth_profiles(&self) -> Result<Vec<AuthProfile>, ApiError> {
|
pub async fn list_auth_profiles(&self) -> Result<Vec<AuthProfile>, ApiError> {
|
||||||
Ok(self.registry.list_auth_profiles().await?)
|
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(
|
pub async fn upload_descriptor_set(
|
||||||
&self,
|
&self,
|
||||||
operation_id: &OperationId,
|
operation_id: &OperationId,
|
||||||
@@ -412,6 +425,12 @@ impl AdminService {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
.await?;
|
.await?;
|
||||||
|
info!(
|
||||||
|
operation_id = %operation_id.as_str(),
|
||||||
|
descriptor_id = %descriptor_id.as_str(),
|
||||||
|
version = summary.current_draft_version,
|
||||||
|
"descriptor set uploaded"
|
||||||
|
);
|
||||||
|
|
||||||
Ok(DescriptorUploadResponse {
|
Ok(DescriptorUploadResponse {
|
||||||
descriptor_id: descriptor_id.as_str().to_owned(),
|
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(
|
pub async fn upload_proto_file(
|
||||||
&self,
|
&self,
|
||||||
operation_id: &OperationId,
|
operation_id: &OperationId,
|
||||||
@@ -459,6 +479,12 @@ impl AdminService {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
.await?;
|
.await?;
|
||||||
|
info!(
|
||||||
|
operation_id = %operation_id.as_str(),
|
||||||
|
descriptor_id = %descriptor_id.as_str(),
|
||||||
|
version = summary.current_draft_version,
|
||||||
|
"proto file uploaded"
|
||||||
|
);
|
||||||
|
|
||||||
Ok(DescriptorUploadResponse {
|
Ok(DescriptorUploadResponse {
|
||||||
descriptor_id: descriptor_id.as_str().to_owned(),
|
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(
|
pub async fn list_grpc_services(
|
||||||
&self,
|
&self,
|
||||||
operation_id: &OperationId,
|
operation_id: &OperationId,
|
||||||
@@ -499,6 +526,7 @@ impl AdminService {
|
|||||||
.collect::<Result<Vec<_>, _>>()
|
.collect::<Result<Vec<_>, _>>()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[instrument(skip(self))]
|
||||||
pub async fn get_auth_profile(
|
pub async fn get_auth_profile(
|
||||||
&self,
|
&self,
|
||||||
auth_profile_id: &AuthProfileId,
|
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(
|
pub async fn create_auth_profile(
|
||||||
&self,
|
&self,
|
||||||
payload: AuthProfilePayload,
|
payload: AuthProfilePayload,
|
||||||
@@ -533,10 +562,12 @@ impl AdminService {
|
|||||||
self.registry
|
self.registry
|
||||||
.save_auth_profile(SaveAuthProfileRequest { profile: &profile })
|
.save_auth_profile(SaveAuthProfileRequest { profile: &profile })
|
||||||
.await?;
|
.await?;
|
||||||
|
info!(auth_profile_id = %profile.id.as_str(), "auth profile created");
|
||||||
|
|
||||||
Ok(profile)
|
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(
|
pub async fn export_operation(
|
||||||
&self,
|
&self,
|
||||||
operation_id: &OperationId,
|
operation_id: &OperationId,
|
||||||
@@ -566,6 +597,7 @@ impl AdminService {
|
|||||||
serde_yaml::to_string(&document).map_err(|error| ApiError::internal(error.to_string()))
|
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(
|
pub async fn import_operation(
|
||||||
&self,
|
&self,
|
||||||
query: ImportQuery,
|
query: ImportQuery,
|
||||||
@@ -615,25 +647,38 @@ impl AdminService {
|
|||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
Ok(ImportResponse {
|
let response = ImportResponse {
|
||||||
operation_id: created.operation_id,
|
operation_id: created.operation_id,
|
||||||
version: created.version,
|
version: created.version,
|
||||||
import_mode: ImportMode::Upsert,
|
import_mode: ImportMode::Upsert,
|
||||||
warnings: Vec::new(),
|
warnings: Vec::new(),
|
||||||
})
|
};
|
||||||
|
info!(
|
||||||
|
operation_id = %response.operation_id,
|
||||||
|
version = response.version,
|
||||||
|
"operation imported by upsert"
|
||||||
|
);
|
||||||
|
Ok(response)
|
||||||
} else {
|
} else {
|
||||||
let created = self.create_operation(payload).await?;
|
let created = self.create_operation(payload).await?;
|
||||||
Ok(ImportResponse {
|
let response = ImportResponse {
|
||||||
operation_id: created.operation_id,
|
operation_id: created.operation_id,
|
||||||
version: created.version,
|
version: created.version,
|
||||||
import_mode: ImportMode::Upsert,
|
import_mode: ImportMode::Upsert,
|
||||||
warnings: Vec::new(),
|
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(
|
pub async fn save_json_sample(
|
||||||
&self,
|
&self,
|
||||||
operation_id: &OperationId,
|
operation_id: &OperationId,
|
||||||
@@ -667,10 +712,17 @@ impl AdminService {
|
|||||||
self.registry
|
self.registry
|
||||||
.save_sample_metadata(SaveSampleMetadataRequest { sample: &metadata })
|
.save_sample_metadata(SaveSampleMetadataRequest { sample: &metadata })
|
||||||
.await?;
|
.await?;
|
||||||
|
info!(
|
||||||
|
operation_id = %operation_id.as_str(),
|
||||||
|
sample_id = %metadata.id.as_str(),
|
||||||
|
version,
|
||||||
|
"json sample saved"
|
||||||
|
);
|
||||||
|
|
||||||
Ok(metadata)
|
Ok(metadata)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[instrument(skip(self, payload), fields(operation_id = %operation_id.as_str()))]
|
||||||
pub async fn generate_draft(
|
pub async fn generate_draft(
|
||||||
&self,
|
&self,
|
||||||
operation_id: &OperationId,
|
operation_id: &OperationId,
|
||||||
@@ -722,13 +774,16 @@ impl AdminService {
|
|||||||
warnings: Vec::new(),
|
warnings: Vec::new(),
|
||||||
};
|
};
|
||||||
|
|
||||||
Ok(DraftGenerationResult {
|
let result = DraftGenerationResult {
|
||||||
generated_draft,
|
generated_draft,
|
||||||
input_schema,
|
input_schema,
|
||||||
output_schema,
|
output_schema,
|
||||||
input_mapping,
|
input_mapping,
|
||||||
output_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> {
|
fn validate_operation_payload(&self, payload: &OperationPayload) -> Result<(), ApiError> {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ use std::{
|
|||||||
|
|
||||||
use mcpaas_registry::{PostgresRegistry, RegistryError, RegistryOperation};
|
use mcpaas_registry::{PostgresRegistry, RegistryError, RegistryOperation};
|
||||||
use tokio::sync::RwLock;
|
use tokio::sync::RwLock;
|
||||||
|
use tracing::info;
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct PublishedToolCatalog {
|
pub struct PublishedToolCatalog {
|
||||||
@@ -66,11 +67,18 @@ impl PublishedToolCatalog {
|
|||||||
.map(|operation| (operation.name.clone(), operation))
|
.map(|operation| (operation.name.clone(), operation))
|
||||||
.collect::<HashMap<_, _>>();
|
.collect::<HashMap<_, _>>();
|
||||||
let mut guard = self.cached.write().await;
|
let mut guard = self.cached.write().await;
|
||||||
|
let previous_count = guard.tools.len();
|
||||||
|
|
||||||
guard.loaded_at = Some(Instant::now());
|
guard.loaded_at = Some(Instant::now());
|
||||||
guard.tools = tools;
|
guard.tools = tools;
|
||||||
guard.tools_by_name = tools_by_name;
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 возвращаются в читаемом виде;
|
||||||
|
- логи позволяют понять, какая операция создавалась, тестировалась, публиковалась или импортировалась.
|
||||||
Reference in New Issue
Block a user