initial-commit

This commit is contained in:
Guillaume GRABÉ
2026-05-12 17:06:43 +02:00
commit 051a080dfa
110 changed files with 26377 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
pub mod yaml;
+540
View File
@@ -0,0 +1,540 @@
use crate::charybdis::entities::{Entity, entity::Metadata, entity::Spec};
use serde_json::{Value, json};
use std::collections::HashMap;
/// Create a Backstage Location YAML that lists all entities
pub fn create_location_yaml(entities: &[Entity], base_url: &str) -> String {
let mut targets = Vec::new();
for entity in entities {
// Generate URL for each entity
let entity_url = format!("{}/yaml/entities/{}", base_url, entity.id);
targets.push(entity_url);
}
let location = json!({
"apiVersion": "backstage.io/v1alpha1",
"kind": "Location",
"metadata": {
"name": "charybdis-all-entities",
"description": "Dynamic location managed by Charybdis"
},
"spec": {
"type": "charybdis",
"targets": targets
}
});
serde_yaml::to_string(&location).unwrap_or_else(|_| String::from("# Error generating YAML"))
}
/// Convert a Charybdis Entity to Backstage YAML format
pub fn entity_to_yaml(entity: &Entity) -> Result<String, Box<dyn std::error::Error>> {
let mut backstage_entity: HashMap<String, Value> = HashMap::new();
// API version
backstage_entity.insert("apiVersion".to_string(), json!("backstage.io/v1alpha1"));
// Kind
backstage_entity.insert("kind".to_string(), json!(entity.kind));
// Metadata
let metadata = extract_metadata(entity)?;
backstage_entity.insert("metadata".to_string(), metadata);
// Spec
if let Some(spec) = extract_spec(entity) {
backstage_entity.insert("spec".to_string(), spec);
}
// Convert to YAML
Ok(serde_yaml::to_string(&backstage_entity)?)
}
/// Extract metadata from Entity based on its type
fn extract_metadata(entity: &Entity) -> Result<Value, Box<dyn std::error::Error>> {
let mut metadata = json!({});
match &entity.metadata {
Some(Metadata::ServiceMetadata(m)) => {
metadata["name"] = json!(m.name);
if !m.namespace.is_empty() {
metadata["namespace"] = json!(m.namespace);
}
if !m.description.is_empty() {
metadata["description"] = json!(m.description);
}
if !m.labels.is_empty() {
metadata["labels"] = json!(m.labels);
}
if !m.tags.is_empty() {
metadata["tags"] = json!(m.tags);
}
if !m.links.is_empty() {
let links: Vec<Value> = m
.links
.iter()
.map(|link| {
json!({
"url": link.url,
"title": link.title,
"icon": link.icon
})
})
.collect();
metadata["links"] = json!(links);
}
}
Some(Metadata::SystemMetadata(m)) => {
metadata["name"] = json!(m.name);
if !m.namespace.is_empty() {
metadata["namespace"] = json!(m.namespace);
}
if !m.description.is_empty() {
metadata["description"] = json!(m.description);
}
if !m.labels.is_empty() {
metadata["labels"] = json!(m.labels);
}
if !m.tags.is_empty() {
metadata["tags"] = json!(m.tags);
}
if !m.links.is_empty() {
let links: Vec<Value> = m
.links
.iter()
.map(|link| {
json!({
"url": link.url,
"title": link.title,
"icon": link.icon
})
})
.collect();
metadata["links"] = json!(links);
}
}
Some(Metadata::ComponentMetadata(m)) => {
metadata["name"] = json!(m.name);
if !m.namespace.is_empty() {
metadata["namespace"] = json!(m.namespace);
}
if !m.description.is_empty() {
metadata["description"] = json!(m.description);
}
if !m.labels.is_empty() {
metadata["labels"] = json!(m.labels);
}
if !m.tags.is_empty() {
metadata["tags"] = json!(m.tags);
}
if !m.links.is_empty() {
let links: Vec<Value> = m
.links
.iter()
.map(|link| {
json!({
"url": link.url,
"title": link.title,
"icon": link.icon
})
})
.collect();
metadata["links"] = json!(links);
}
}
Some(Metadata::ApiMetadata(m)) => {
metadata["name"] = json!(m.name);
if !m.namespace.is_empty() {
metadata["namespace"] = json!(m.namespace);
}
if !m.description.is_empty() {
metadata["description"] = json!(m.description);
}
if !m.labels.is_empty() {
metadata["labels"] = json!(m.labels);
}
if !m.tags.is_empty() {
metadata["tags"] = json!(m.tags);
}
if !m.links.is_empty() {
let links: Vec<Value> = m
.links
.iter()
.map(|link| {
json!({
"url": link.url,
"title": link.title,
"icon": link.icon
})
})
.collect();
metadata["links"] = json!(links);
}
}
Some(Metadata::UserMetadata(m)) => {
metadata["name"] = json!(m.name);
if !m.namespace.is_empty() {
metadata["namespace"] = json!(m.namespace);
}
if !m.description.is_empty() {
metadata["description"] = json!(m.description);
}
if !m.labels.is_empty() {
metadata["labels"] = json!(m.labels);
}
if !m.tags.is_empty() {
metadata["tags"] = json!(m.tags);
}
if !m.links.is_empty() {
let links: Vec<Value> = m
.links
.iter()
.map(|link| {
json!({
"url": link.url,
"title": link.title,
"icon": link.icon
})
})
.collect();
metadata["links"] = json!(links);
}
}
Some(Metadata::GroupMetadata(m)) => {
metadata["name"] = json!(m.name);
if !m.namespace.is_empty() {
metadata["namespace"] = json!(m.namespace);
}
if !m.description.is_empty() {
metadata["description"] = json!(m.description);
}
if !m.labels.is_empty() {
metadata["labels"] = json!(m.labels);
}
if !m.tags.is_empty() {
metadata["tags"] = json!(m.tags);
}
if !m.links.is_empty() {
let links: Vec<Value> = m
.links
.iter()
.map(|link| {
json!({
"url": link.url,
"title": link.title,
"icon": link.icon
})
})
.collect();
metadata["links"] = json!(links);
}
}
Some(Metadata::DomainMetadata(m)) => {
metadata["name"] = json!(m.name);
if !m.namespace.is_empty() {
metadata["namespace"] = json!(m.namespace);
}
if !m.description.is_empty() {
metadata["description"] = json!(m.description);
}
if !m.labels.is_empty() {
metadata["labels"] = json!(m.labels);
}
if !m.tags.is_empty() {
metadata["tags"] = json!(m.tags);
}
if !m.links.is_empty() {
let links: Vec<Value> = m
.links
.iter()
.map(|link| {
json!({
"url": link.url,
"title": link.title,
"icon": link.icon
})
})
.collect();
metadata["links"] = json!(links);
}
}
Some(Metadata::ResourceMetadata(m)) => {
metadata["name"] = json!(m.name);
if !m.namespace.is_empty() {
metadata["namespace"] = json!(m.namespace);
}
if !m.description.is_empty() {
metadata["description"] = json!(m.description);
}
if !m.labels.is_empty() {
metadata["labels"] = json!(m.labels);
}
if !m.tags.is_empty() {
metadata["tags"] = json!(m.tags);
}
if !m.links.is_empty() {
let links: Vec<Value> = m
.links
.iter()
.map(|link| {
json!({
"url": link.url,
"title": link.title,
"icon": link.icon
})
})
.collect();
metadata["links"] = json!(links);
}
}
Some(Metadata::DefectdojoMetadata(m)) => {
// DefectDojo plugin metadata
metadata["name"] = json!(m.name);
if !m.description.is_empty() {
metadata["description"] = json!(m.description);
}
if !m.resource_type.is_empty() {
metadata["annotations"] = json!({
"defectdojo.com/resource-type": m.resource_type
});
}
}
Some(Metadata::KeycloakMetadata(m)) => {
// Keycloak plugin metadata
metadata["name"] = json!(m.name);
if !m.description.is_empty() {
metadata["description"] = json!(m.description);
}
let mut annotations = serde_json::Map::new();
if !m.resource_type.is_empty() {
annotations.insert("keycloak.org/resource-type".to_string(), json!(m.resource_type));
}
if !m.realm.is_empty() {
annotations.insert("keycloak.org/realm".to_string(), json!(m.realm));
}
if !annotations.is_empty() {
metadata["annotations"] = Value::Object(annotations);
}
}
Some(Metadata::DependencytrackMetadata(m)) => {
// Dependency-Track plugin metadata
metadata["name"] = json!(m.name);
if !m.description.is_empty() {
metadata["description"] = json!(m.description);
}
if !m.resource_type.is_empty() {
metadata["annotations"] = json!({
"dependencytrack.org/resource-type": m.resource_type
});
}
}
Some(Metadata::FindingMetadata(m)) => {
metadata["name"] = json!(m.title);
if !m.namespace.is_empty() {
metadata["namespace"] = json!(m.namespace);
}
if !m.description.is_empty() {
metadata["description"] = json!(m.description);
}
if !m.tags.is_empty() {
metadata["tags"] = json!(m.tags);
}
}
None => {
return Err("Entity has no metadata".into());
}
}
// Add annotations
if !entity.annotations.is_empty() {
metadata["annotations"] = json!(entity.annotations);
}
Ok(metadata)
}
/// Extract spec from Entity based on its type
fn extract_spec(entity: &Entity) -> Option<Value> {
match &entity.spec {
Some(Spec::ServiceSpec(s)) => {
let mut spec = serde_json::Map::new();
spec.insert("type".to_string(), json!(s.r#type));
spec.insert("lifecycle".to_string(), json!(s.lifecycle));
spec.insert("owner".to_string(), json!(s.owner));
if !s.system.is_empty() {
spec.insert("system".to_string(), json!(s.system));
}
if !s.depends_on.is_empty() {
spec.insert("dependsOn".to_string(), json!(s.depends_on));
}
if !s.provides_apis.is_empty() {
spec.insert("providesApis".to_string(), json!(s.provides_apis));
}
if !s.consumes_apis.is_empty() {
spec.insert("consumesApis".to_string(), json!(s.consumes_apis));
}
Some(Value::Object(spec))
}
Some(Spec::SystemSpec(s)) => {
let mut spec = serde_json::Map::new();
spec.insert("owner".to_string(), json!(s.owner));
if !s.domain.is_empty() {
spec.insert("domain".to_string(), json!(s.domain));
}
Some(Value::Object(spec))
}
Some(Spec::ComponentSpec(s)) => {
let mut spec = serde_json::Map::new();
spec.insert("type".to_string(), json!(s.r#type));
spec.insert("lifecycle".to_string(), json!(s.lifecycle));
spec.insert("owner".to_string(), json!(s.owner));
if !s.system.is_empty() {
spec.insert("system".to_string(), json!(s.system));
}
if !s.subcomponent_of.is_empty() {
spec.insert("subcomponentOf".to_string(), json!(s.subcomponent_of));
}
if !s.depends_on.is_empty() {
spec.insert("dependsOn".to_string(), json!(s.depends_on));
}
if !s.provides_apis.is_empty() {
spec.insert("providesApis".to_string(), json!(s.provides_apis));
}
if !s.consumes_apis.is_empty() {
spec.insert("consumesApis".to_string(), json!(s.consumes_apis));
}
Some(Value::Object(spec))
}
Some(Spec::ApiSpec(s)) => {
let mut spec = serde_json::Map::new();
spec.insert("type".to_string(), json!(s.r#type));
spec.insert("lifecycle".to_string(), json!(s.lifecycle));
spec.insert("owner".to_string(), json!(s.owner));
if !s.system.is_empty() {
spec.insert("system".to_string(), json!(s.system));
}
spec.insert("definition".to_string(), json!(s.definition));
Some(Value::Object(spec))
}
Some(Spec::UserSpec(s)) => {
let mut spec = json!({});
if let Some(profile) = &s.profile {
spec["profile"] = json!({
"displayName": profile.display_name,
"email": profile.email,
"picture": profile.picture,
});
}
if !s.member_of.is_empty() {
spec["memberOf"] = json!(s.member_of);
}
Some(spec)
}
Some(Spec::GroupSpec(s)) => {
let mut spec = json!({
"type": s.r#type,
});
if let Some(profile) = &s.profile {
spec["profile"] = json!({
"displayName": profile.display_name,
"email": profile.email,
"picture": profile.picture,
});
}
if !s.parent.is_empty() {
spec["parent"] = json!(s.parent);
}
if !s.children.is_empty() {
spec["children"] = json!(s.children);
}
if !s.members.is_empty() {
spec["members"] = json!(s.members);
}
Some(spec)
}
Some(Spec::DomainSpec(s)) => Some(json!({
"owner": s.owner,
})),
Some(Spec::ResourceSpec(s)) => Some(json!({
"type": s.r#type,
"owner": s.owner,
"system": if s.system.is_empty() { Value::Null } else { json!(s.system) },
"dependsOn": if s.depends_on.is_empty() { Value::Null } else { json!(s.depends_on) },
})),
Some(Spec::DefectdojoSpec(s)) => {
// DefectDojo plugin spec - parse config_json if present
let mut spec = json!({});
if !s.config_json.is_empty() {
if let Ok(config) = serde_json::from_str::<Value>(&s.config_json) {
spec = config;
}
}
if !s.sync_status.is_empty() {
spec["sync_status"] = json!(s.sync_status);
}
Some(spec)
}
Some(Spec::KeycloakSpec(s)) => {
// Keycloak plugin spec - parse config_json if present
let mut spec = json!({});
if !s.config_json.is_empty() {
if let Ok(config) = serde_json::from_str::<Value>(&s.config_json) {
spec = config;
}
}
if !s.sync_status.is_empty() {
spec["sync_status"] = json!(s.sync_status);
}
if !s.last_sync.is_empty() {
spec["last_sync"] = json!(s.last_sync);
}
if !s.error_message.is_empty() {
spec["error_message"] = json!(s.error_message);
}
Some(spec)
}
Some(Spec::DependencytrackSpec(s)) => {
// Dependency-Track plugin spec - parse config_json if present
let mut spec = json!({});
if !s.config_json.is_empty() {
if let Ok(config) = serde_json::from_str::<Value>(&s.config_json) {
spec = config;
}
}
if !s.sync_status.is_empty() {
spec["sync_status"] = json!(s.sync_status);
}
if !s.last_sync.is_empty() {
spec["last_sync"] = json!(s.last_sync);
}
if !s.error_message.is_empty() {
spec["error_message"] = json!(s.error_message);
}
Some(spec)
}
Some(Spec::FindingSpec(s)) => {
let mut spec = json!({});
spec["component_ref"] = json!(s.component_ref);
spec["lifecycle"] = json!(s.lifecycle);
spec["severity"] = json!(s.severity);
spec["state"] = json!(s.state);
spec["scanner"] = json!(s.scanner);
spec["rule_id"] = json!(s.rule_id);
if !s.file_path.is_empty() {
spec["file_path"] = json!(s.file_path);
}
if s.line_start > 0 {
spec["line_start"] = json!(s.line_start);
}
if !s.cwe.is_empty() {
spec["cwe"] = json!(s.cwe);
}
if !s.cve.is_empty() {
spec["cve"] = json!(s.cve);
}
Some(spec)
}
None => None,
}
}
+154
View File
@@ -0,0 +1,154 @@
use crate::database::EntityRepository;
use axum::{
Router,
extract::{Path, State},
http::StatusCode,
response::{IntoResponse, Response},
routing::get,
};
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::sync::RwLock;
use tracing::{debug, error, info};
mod backstage;
struct CachedResponse {
data: String,
cached_at: Instant,
}
/// State shared across YAML adapter handlers
#[derive(Clone)]
pub struct YamlAdapterState {
pub repository: Arc<EntityRepository>,
pub base_url: String,
cache: Arc<RwLock<Option<CachedResponse>>>,
cache_ttl: Duration,
}
impl YamlAdapterState {
/// Invalidate the locations cache (for event-based invalidation on entity changes)
pub async fn invalidate_cache(&self) {
let mut cache = self.cache.write().await;
*cache = None;
}
}
/// Start the YAML adapter HTTP server
pub async fn start_yaml_adapter(
host: String,
port: u16,
repository: Arc<EntityRepository>,
base_url: String,
) -> Result<(), Box<dyn std::error::Error>> {
let state = YamlAdapterState {
repository,
base_url,
cache: Arc::new(RwLock::new(None)),
cache_ttl: Duration::from_secs(30),
};
let app = Router::new()
.route("/yaml/locations", get(get_locations))
.route("/yaml/entities/:id", get(get_entity))
.with_state(state);
let addr = format!("{}:{}", host, port);
info!("Starting YAML adapter HTTP server on {}", addr);
let listener = tokio::net::TcpListener::bind(&addr).await?;
axum::serve(listener, app).await?;
Ok(())
}
/// GET /yaml/locations
/// Returns a Backstage Location entity listing all entities in Charybdis
async fn get_locations(State(state): State<YamlAdapterState>) -> Response {
info!("Received GET /yaml/locations request");
// Check cache first
{
let cache = state.cache.read().await;
if let Some(ref cached) = *cache {
if cached.cached_at.elapsed() < state.cache_ttl {
debug!("Serving /yaml/locations from cache");
return (
StatusCode::OK,
[("content-type", "text/yaml; charset=utf-8")],
cached.data.clone(),
)
.into_response();
}
}
}
// Cache miss or expired — fetch from database
match state.repository.list_all().await {
Ok(entities) => {
let location_yaml = backstage::create_location_yaml(&entities, &state.base_url);
// Update cache
{
let mut cache = state.cache.write().await;
*cache = Some(CachedResponse {
data: location_yaml.clone(),
cached_at: Instant::now(),
});
}
(
StatusCode::OK,
[("content-type", "text/yaml; charset=utf-8")],
location_yaml,
)
.into_response()
}
Err(e) => {
error!(error = %e, "Failed to list entities for location");
(
StatusCode::INTERNAL_SERVER_ERROR,
"Failed to retrieve entities",
)
.into_response()
}
}
}
/// GET /yaml/entities/:id
/// Returns a specific entity in Backstage YAML format
async fn get_entity(Path(id): Path<String>, State(state): State<YamlAdapterState>) -> Response {
info!(entity_id = %id, "Received GET /yaml/entities/:id request");
match state.repository.get_by_id(&id).await {
Ok(Some(entity)) => match backstage::entity_to_yaml(&entity) {
Ok(yaml) => (
StatusCode::OK,
[("content-type", "text/yaml; charset=utf-8")],
yaml,
)
.into_response(),
Err(e) => {
error!(entity_id = %id, error = %e, "Failed to convert entity to YAML");
(
StatusCode::INTERNAL_SERVER_ERROR,
"Failed to convert entity to YAML",
)
.into_response()
}
},
Ok(None) => {
info!(entity_id = %id, "Entity not found");
(StatusCode::NOT_FOUND, "Entity not found").into_response()
}
Err(e) => {
error!(entity_id = %id, error = %e, "Failed to retrieve entity");
(
StatusCode::INTERNAL_SERVER_ERROR,
"Failed to retrieve entity",
)
.into_response()
}
}
}