diff --git a/Cargo.lock b/Cargo.lock index 904f4f4..155699f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -78,6 +78,20 @@ dependencies = [ "subtle", ] +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom 0.3.4", + "once_cell", + "serde", + "version_check", + "zerocopy", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -87,6 +101,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + [[package]] name = "android_system_properties" version = "0.1.5" @@ -292,6 +312,21 @@ dependencies = [ "sha2 0.10.9", ] +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + [[package]] name = "bitflags" version = "2.11.1" @@ -353,12 +388,24 @@ dependencies = [ "cipher 0.4.4", ] +[[package]] +name = "borrow-or-share" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc0b364ead1874514c8c2855ab558056ebfeb775653e7ae45ff72f28f8f3166c" + [[package]] name = "bumpalo" version = "3.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +[[package]] +name = "bytecount" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" + [[package]] name = "byteorder" version = "1.5.0" @@ -1068,6 +1115,15 @@ dependencies = [ "zeroize", ] +[[package]] +name = "email_address" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449" +dependencies = [ + "serde", +] + [[package]] name = "encoding_rs" version = "0.8.35" @@ -1105,6 +1161,17 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "fancy-regex" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1e1dacd0d2082dfcf1351c4bdd566bbe89a2b263235a2b50058f1e130a47277" +dependencies = [ + "bit-set", + "regex-automata", + "regex-syntax", +] + [[package]] name = "fastrand" version = "2.4.1" @@ -1143,6 +1210,17 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "fluent-uri" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc74ac4d8359ae70623506d512209619e5cf8f347124910440dbc221714b328e" +dependencies = [ + "borrow-or-share", + "ref-cast", + "serde", +] + [[package]] name = "fnv" version = "1.0.7" @@ -1155,6 +1233,12 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + [[package]] name = "foreign-types" version = "0.3.2" @@ -1189,6 +1273,16 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fraction" +version = "0.15.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e076045bb43dac435333ed5f04caf35c7463631d0dae2deb2638d94dd0a5b872" +dependencies = [ + "lazy_static", + "num", +] + [[package]] name = "fs_extra" version = "1.3.0" @@ -1409,7 +1503,18 @@ version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ - "foldhash", + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", ] [[package]] @@ -1922,6 +2027,33 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "jsonschema" +version = "0.46.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a5fe5206f06e589caf25e79fc05ccdf91fca745685fe9fe1a13bbdfb479a631" +dependencies = [ + "ahash", + "bytecount", + "data-encoding", + "email_address", + "fancy-regex", + "fraction", + "getrandom 0.3.4", + "idna", + "itoa", + "num-cmp", + "num-traits", + "percent-encoding", + "referencing", + "regex", + "regex-syntax", + "serde", + "serde_json", + "unicode-general-category", + "uuid-simd", +] + [[package]] name = "keccak" version = "0.2.0" @@ -2054,6 +2186,12 @@ version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +[[package]] +name = "micromap" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a86d3146ed3995b5913c414f6664344b9617457320782e64f0bb44afd49d74" + [[package]] name = "mime" version = "0.3.17" @@ -2175,6 +2313,30 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + [[package]] name = "num-bigint-dig" version = "0.8.6" @@ -2192,6 +2354,21 @@ dependencies = [ "zeroize", ] +[[package]] +name = "num-cmp" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63335b2e2c34fae2fb0aa2cecfd9f0832a1e24b3b32ecec612c3426d46dc8aaa" + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + [[package]] name = "num-conv" version = "0.2.1" @@ -2218,6 +2395,17 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -2325,6 +2513,12 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "outref" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" + [[package]] name = "p256" version = "0.13.2" @@ -2743,6 +2937,7 @@ dependencies = [ "getrandom 0.4.2", "http", "js-sys", + "jsonschema", "keyring", "mockito", "open", @@ -2966,6 +3161,23 @@ dependencies = [ "syn", ] +[[package]] +name = "referencing" +version = "0.46.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69e4e17ef386c5383591d07623d3de49cbc601156e7582973e6db98d66a57de2" +dependencies = [ + "ahash", + "fluent-uri", + "getrandom 0.3.4", + "hashbrown 0.16.1", + "itoa", + "micromap", + "parking_lot", + "percent-encoding", + "serde_json", +] + [[package]] name = "regex" version = "1.12.3" @@ -4250,6 +4462,12 @@ version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" +[[package]] +name = "unicode-general-category" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b993bddc193ae5bd0d623b49ec06ac3e9312875fdae725a975c51db1cc1677f" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -4349,6 +4567,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "uuid-simd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b082222b4f6619906941c17eb2297fff4c2fb96cb60164170522942a200bd8" +dependencies = [ + "outref", + "vsimd", +] + [[package]] name = "vcpkg" version = "0.2.15" @@ -4361,6 +4589,12 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "vsimd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64" + [[package]] name = "walkdir" version = "2.5.0" diff --git a/Cargo.toml b/Cargo.toml index bd23f89..4a9ff12 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -157,6 +157,7 @@ serde-wasm-bindgen = { version = "0.6", optional = true } # RNG support in browser WASM (getrandom 0.4 — wasm_js feature enables JS crypto API) getrandom = { version = "0.4", features = ["wasm_js"], optional = true } +jsonschema = { version = "0.46.5", default-features = false } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] reqwest = { version = ">=0.13, <0.13.3", features = ["stream"] } diff --git a/src/commands/idp/migrate.rs b/src/commands/idp/migrate.rs new file mode 100644 index 0000000..0aac781 --- /dev/null +++ b/src/commands/idp/migrate.rs @@ -0,0 +1,2066 @@ +use crate::config::Config; +use anyhow::Result; + +// --------------------------------------------------------------------------- +// ANSI colour constants (following status_pages.rs pattern) +// --------------------------------------------------------------------------- + +const ANSI_GREEN: &str = "\x1b[32m"; +const ANSI_RED: &str = "\x1b[31m"; +const ANSI_YELLOW: &str = "\x1b[33m"; +const ANSI_BOLD: &str = "\x1b[1m"; +const ANSI_DIM: &str = "\x1b[2m"; +const ANSI_RESET: &str = "\x1b[0m"; + +// --------------------------------------------------------------------------- +// Migration outcome +// --------------------------------------------------------------------------- + +#[derive(Debug)] +enum MigrateStatus { + Migrated { warnings: Vec }, + Skipped, + Failed(String), +} + +#[derive(Debug)] +struct MigrateOutcome { + path: std::path::PathBuf, + status: MigrateStatus, + services: usize, + systems: usize, +} + +// --------------------------------------------------------------------------- +// Version detection +// --------------------------------------------------------------------------- + +const V1_CATALOG_KINDS: &[&str] = &["service", "library", "datastore", "queue", "api"]; + +/// Returns one of: "v3", "v2.2", "v2.1", "v2", "v1", "v1-noncatalog", "unknown" +fn detect_version(doc: &serde_json::Value) -> &'static str { + if doc.get("apiVersion").and_then(|v| v.as_str()) == Some("v3") { + return "v3"; + } + match doc.get("schema-version").and_then(|v| v.as_str()) { + Some("v2.2") => return "v2.2", + Some("v2.1") => return "v2.1", + Some("v2") => return "v2", + Some("v1") => { + let kind = doc.get("kind").and_then(|v| v.as_str()).unwrap_or(""); + if !kind.is_empty() && !V1_CATALOG_KINDS.contains(&kind) { + return "v1-noncatalog"; + } + return "v1"; + } + Some(_) => return "unknown", + None => {} + } + // Implicit v1: info.dd-service present with no schema-version + if doc.get("info").and_then(|i| i.get("dd-service")).is_some() { + return "v1"; + } + "unknown" +} + +// --------------------------------------------------------------------------- +// Link type remapping +// --------------------------------------------------------------------------- + +fn remap_link_type(t: &str) -> String { + match t { + "wiki" => "doc", + "code" => "repo", + "url" | "oncall" | "link" => "other", + other => other, + } + .to_string() +} + +// --------------------------------------------------------------------------- +// Field accessor helpers +// --------------------------------------------------------------------------- + +fn str_field(doc: &serde_json::Value, key: &str) -> Option { + doc.get(key).and_then(|v| v.as_str()).map(String::from) +} + +fn arr_field<'a>(doc: &'a serde_json::Value, key: &str) -> &'a [serde_json::Value] { + doc.get(key) + .and_then(|v| v.as_array()) + .map(|v| v.as_slice()) + .unwrap_or(&[]) +} + +fn nested_str(doc: &serde_json::Value, outer: &str, inner: &str) -> Option { + doc.get(outer) + .and_then(|o| o.get(inner)) + .and_then(|v| v.as_str()) + .map(String::from) +} + +fn is_empty_value(v: &serde_json::Value) -> bool { + match v { + serde_json::Value::Null => true, + serde_json::Value::Object(m) => m.is_empty(), + serde_json::Value::Array(a) => a.is_empty(), + _ => false, + } +} + +// --------------------------------------------------------------------------- +// Canonical output ordering +// --------------------------------------------------------------------------- + +const TOP_FIELD_ORDER: &[&str] = &[ + "apiVersion", + "kind", + "metadata", + "spec", + "integrations", + "datadog", + "extensions", +]; +const METADATA_FIELD_ORDER: &[&str] = &[ + "name", + "displayName", + "description", + "owner", + "additionalOwners", + "tags", + "contacts", + "links", +]; +const SPEC_FIELD_ORDER: &[&str] = &[ + "lifecycle", + "tier", + "type", + "languages", + "dependsOn", + "componentOf", +]; + +fn reorder_map( + obj: &serde_json::Map, + order: &[&str], +) -> serde_json::Map { + let mut out = serde_json::Map::new(); + for &key in order { + if let Some(v) = obj.get(key) { + if !is_empty_value(v) { + out.insert(key.to_string(), v.clone()); + } + } + } + for (k, v) in obj { + if !order.contains(&k.as_str()) { + out.insert(k.clone(), v.clone()); + } + } + out +} + +fn ordered_entity(raw: serde_json::Value) -> serde_json::Value { + let obj = match raw.as_object() { + Some(o) => o, + None => return raw, + }; + let mut result = reorder_map(obj, TOP_FIELD_ORDER); + if let Some(serde_json::Value::Object(meta)) = result.get("metadata").cloned() { + result.insert( + "metadata".to_string(), + serde_json::Value::Object(reorder_map(&meta, METADATA_FIELD_ORDER)), + ); + } + if let Some(serde_json::Value::Object(spec)) = result.get("spec").cloned() { + result.insert( + "spec".to_string(), + serde_json::Value::Object(reorder_map(&spec, SPEC_FIELD_ORDER)), + ); + } + serde_json::Value::Object(result) +} + +// --------------------------------------------------------------------------- +// Companion system entity factory +// --------------------------------------------------------------------------- + +fn make_system_entity( + application_name: &str, + team: Option<&str>, + service_name: Option<&str>, +) -> serde_json::Value { + let mut metadata = serde_json::json!({ "name": application_name }); + if let Some(t) = team { + metadata["owner"] = serde_json::json!(t); + } + let mut entity = serde_json::json!({ + "apiVersion": "v3", + "kind": "system", + "metadata": metadata, + }); + if let Some(svc) = service_name { + entity["spec"] = serde_json::json!({ + "components": [format!("service:{svc}")] + }); + } + entity +} + +// --------------------------------------------------------------------------- +// Link array helpers (shared by v2/v2.1/v2.2) +// --------------------------------------------------------------------------- + +fn migrate_links(links_src: &[serde_json::Value]) -> Vec { + links_src + .iter() + .filter_map(|lnk| { + let obj = lnk.as_object()?; + let mut new_link = serde_json::Map::new(); + for (k, v) in obj { + new_link.insert(k.clone(), v.clone()); + } + let t = obj.get("type").and_then(|v| v.as_str()).unwrap_or("other"); + new_link.insert("type".to_string(), serde_json::json!(remap_link_type(t))); + Some(serde_json::Value::Object(new_link)) + }) + .collect() +} + +fn repos_to_links(repos: &[serde_json::Value]) -> Vec { + repos + .iter() + .filter_map(|r| { + let obj = r.as_object()?; + let mut link = serde_json::json!({ "type": "repo" }); + for key in &["name", "provider", "url"] { + if let Some(v) = obj.get(*key) { + link[key] = v.clone(); + } + } + Some(link) + }) + .collect() +} + +fn docs_to_links(docs: &[serde_json::Value]) -> Vec { + docs.iter() + .filter_map(|d| { + let obj = d.as_object()?; + let mut link = serde_json::json!({ "type": "doc" }); + for key in &["name", "provider", "url"] { + if let Some(v) = obj.get(*key) { + link[key] = v.clone(); + } + } + Some(link) + }) + .collect() +} + +// --------------------------------------------------------------------------- +// Shared helpers: entity kind detection + integrations builder +// --------------------------------------------------------------------------- + +fn detect_entity_kind(doc: &serde_json::Value) -> (String, String) { + let keys = [ + ("dd-service", "service"), + ("dd-package", "library"), + ("dd-datastore", "datastore"), + ("dd-queue", "queue"), + ("dd-api", "api"), + ]; + for (key, kind) in &keys { + if let Some(name) = str_field(doc, key) { + return (kind.to_string(), name); + } + } + ("service".to_string(), String::new()) +} + +fn build_integrations( + integrations_src: serde_json::Value, + mut ext_map: serde_json::Map, +) -> ( + serde_json::Map, + serde_json::Map, +) { + let mut out = serde_json::Map::new(); + + if let Some(pd) = integrations_src.get("pagerduty") { + if let Some(url) = pd.as_str() { + out.insert("pagerduty".into(), serde_json::json!({ "serviceURL": url })); + } else if let Some(obj) = pd.as_object() { + let service_url = obj + .get("service-url") + .or_else(|| obj.get("serviceURL")) + .and_then(|v| v.as_str()); + if let Some(url) = service_url { + out.insert("pagerduty".into(), serde_json::json!({ "serviceURL": url })); + } + } + } + + if let Some(og) = integrations_src.get("opsgenie") { + if let Some(obj) = og.as_object() { + let service_url = obj + .get("service-url") + .or_else(|| obj.get("serviceURL")) + .and_then(|v| v.as_str()); + if let Some(url) = service_url { + let mut og_out = serde_json::json!({ "serviceURL": url }); + if let Some(region) = obj.get("region") { + og_out["region"] = region.clone(); + } + out.insert("opsgenie".into(), og_out); + } else { + eprintln!( + "{ANSI_YELLOW}WARNING: 'integrations.opsgenie' has no 'service-url'. \ + Preserving in extensions['x-migrated/opsgenie'].{ANSI_RESET}" + ); + ext_map.insert("x-migrated/opsgenie".into(), og.clone()); + } + } + } + + (out, ext_map) +} + +// --------------------------------------------------------------------------- +// v1 → v3 +// --------------------------------------------------------------------------- + +fn migrate_v1(doc: &serde_json::Value) -> (serde_json::Value, Vec) { + let _info = doc.get("info").cloned().unwrap_or_default(); + let org = doc.get("org").cloned().unwrap_or_default(); + let contact = doc.get("contact").cloned().unwrap_or_default(); + let external_resources = arr_field(doc, "external-resources"); + let tags = arr_field(doc, "tags"); + let integrations_src = doc.get("integrations").cloned().unwrap_or_default(); + let extensions_src = doc.get("extensions").cloned(); + let repos_src = arr_field(doc, "repos"); + let dependencies = arr_field(doc, "dependencies"); + let source_patterns = arr_field(doc, "source_patterns"); + + // Entity kind/name from info or root dd-* key + let entity_keys = [ + ("dd-service", "service"), + ("dd-package", "library"), + ("dd-datastore", "datastore"), + ("dd-queue", "queue"), + ("dd-api", "api"), + ]; + let (kind, entity_name) = { + let mut k = "service".to_string(); + let mut n = String::new(); + for (key, kind_val) in &entity_keys { + if let Some(name) = nested_str(doc, "info", key).or_else(|| str_field(doc, key)) { + k = kind_val.to_string(); + n = name; + break; + } + } + (k, n) + }; + + let team = str_field(&org, "team"); + let application = str_field(&org, "application"); + + let mut metadata = serde_json::json!({ "name": entity_name }); + if let Some(dn) = nested_str(doc, "info", "display-name") { + metadata["displayName"] = serde_json::json!(dn); + } + if let Some(desc) = nested_str(doc, "info", "description") { + metadata["description"] = serde_json::json!(desc); + } + if let Some(t) = &team { + metadata["owner"] = serde_json::json!(t); + } + if !tags.is_empty() { + metadata["tags"] = serde_json::json!(tags); + } + + // contacts from contact block + let mut contacts: Vec = Vec::new(); + if let Some(email) = str_field(&contact, "email") { + contacts.push(serde_json::json!({ "name": "Email", "type": "email", "contact": email })); + } + if let Some(slack) = str_field(&contact, "slack") { + contacts.push(serde_json::json!({ "name": "Slack", "type": "slack", "contact": slack })); + } + if !contacts.is_empty() { + metadata["contacts"] = serde_json::json!(contacts); + } + + // links from external-resources + let mut links: Vec = Vec::new(); + for res in external_resources { + if let Some(obj) = res.as_object() { + let mut link = serde_json::Map::new(); + if let Some(name) = obj.get("name").and_then(|v| v.as_str()) { + link.insert("name".to_string(), serde_json::json!(name)); + } + let t = obj.get("type").and_then(|v| v.as_str()).unwrap_or("other"); + link.insert("type".to_string(), serde_json::json!(remap_link_type(t))); + if let Some(url) = obj.get("url").and_then(|v| v.as_str()) { + link.insert("url".to_string(), serde_json::json!(url)); + } + links.push(serde_json::Value::Object(link)); + } + } + links.extend(repos_to_links(repos_src)); + if let Some(github_url) = str_field(&integrations_src, "github") { + links.push(serde_json::json!({ + "name": "GitHub", "type": "repo", + "url": github_url, "provider": "Github" + })); + } + if !links.is_empty() { + metadata["links"] = serde_json::json!(links); + } + + // spec + let mut spec = serde_json::Map::new(); + if let Some(tier) = nested_str(doc, "info", "service-tier") { + spec.insert("tier".to_string(), serde_json::json!(tier)); + } + if let Some(app) = &application { + spec.insert( + "componentOf".to_string(), + serde_json::json!([format!("system:{app}")]), + ); + } + if !dependencies.is_empty() { + let depends: Vec = dependencies + .iter() + .filter_map(|d| d.as_str()) + .map(|d| { + if d.contains(':') { + d.to_string() + } else { + format!("{kind}:{d}") + } + }) + .collect(); + spec.insert("dependsOn".to_string(), serde_json::json!(depends)); + } + + // integrations + let mut integrations = serde_json::Map::new(); + if let Some(pd) = integrations_src.get("pagerduty") { + if let Some(url) = pd.as_str() { + integrations.insert( + "pagerduty".to_string(), + serde_json::json!({ "serviceURL": url }), + ); + } else if pd.is_object() { + integrations.insert("pagerduty".to_string(), pd.clone()); + } + } + + // extensions + let mut ext_map: serde_json::Map = extensions_src + .and_then(|v| v.as_object().cloned()) + .unwrap_or_default(); + if !source_patterns.is_empty() { + ext_map.insert( + "x-migrated/source_patterns".to_string(), + serde_json::json!(source_patterns), + ); + } + + let mut entity = serde_json::json!({ + "apiVersion": "v3", + "kind": kind, + "metadata": metadata, + }); + if !spec.is_empty() { + entity["spec"] = serde_json::Value::Object(spec); + } + if !integrations.is_empty() { + entity["integrations"] = serde_json::Value::Object(integrations); + } + if !ext_map.is_empty() { + entity["extensions"] = serde_json::Value::Object(ext_map); + } + + let mut companions = Vec::new(); + if let Some(app) = &application { + eprintln!( + "{ANSI_YELLOW}WARNING: 'org.application' field found (value: '{app}'). \ + A companion 'kind: system' entity will be generated.{ANSI_RESET}" + ); + companions.push(make_system_entity(app, team.as_deref(), Some(&entity_name))); + } + + (ordered_entity(entity), companions) +} + +// --------------------------------------------------------------------------- +// v2 → v3 +// --------------------------------------------------------------------------- + +const KNOWN_V2_FIELDS: &[&str] = &[ + "schema-version", + "dd-service", + "dd-package", + "dd-datastore", + "dd-queue", + "dd-api", + "team", + "dd-team", + "description", + "display-name", + "application", + "tier", + "lifecycle", + "type", + "languages", + "contacts", + "links", + "repos", + "docs", + "tags", + "integrations", + "extensions", +]; + +fn migrate_v2(doc: &serde_json::Value) -> (serde_json::Value, Vec) { + let (kind, service_name) = detect_entity_kind(doc); + let team = str_field(doc, "team").or_else(|| str_field(doc, "dd-team")); + let description = str_field(doc, "description"); + let display_name = str_field(doc, "display-name"); + let application = str_field(doc, "application"); + let lifecycle = str_field(doc, "lifecycle"); + let tier = str_field(doc, "tier"); + let service_type = str_field(doc, "type"); + let languages = arr_field(doc, "languages"); + let contacts_src = arr_field(doc, "contacts"); + let links_src = arr_field(doc, "links"); + let repos_src = arr_field(doc, "repos"); + let docs_src = arr_field(doc, "docs"); + let tags = arr_field(doc, "tags"); + let integrations_src = doc.get("integrations").cloned().unwrap_or_default(); + + let mut metadata = serde_json::json!({ "name": service_name }); + if let Some(dn) = display_name { + metadata["displayName"] = serde_json::json!(dn); + } + if let Some(d) = description { + metadata["description"] = serde_json::json!(d); + } + if let Some(t) = &team { + metadata["owner"] = serde_json::json!(t); + } + if !tags.is_empty() { + metadata["tags"] = serde_json::json!(tags); + } + if !contacts_src.is_empty() { + metadata["contacts"] = serde_json::json!(contacts_src); + } + + let mut links = migrate_links(links_src); + links.extend(repos_to_links(repos_src)); + links.extend(docs_to_links(docs_src)); + if !links.is_empty() { + metadata["links"] = serde_json::json!(links); + } + + let mut spec = serde_json::Map::new(); + if let Some(lc) = lifecycle { + spec.insert("lifecycle".into(), serde_json::json!(lc)); + } + if let Some(t) = tier { + spec.insert("tier".into(), serde_json::json!(t)); + } + if let Some(st) = service_type { + spec.insert("type".into(), serde_json::json!(st)); + } + if !languages.is_empty() { + spec.insert("languages".into(), serde_json::json!(languages)); + } + if let Some(app) = &application { + spec.insert( + "componentOf".into(), + serde_json::json!([format!("system:{app}")]), + ); + } + + let mut ext_map: serde_json::Map = doc + .get("extensions") + .and_then(|v| v.as_object().cloned()) + .unwrap_or_default(); + if let Some(obj) = doc.as_object() { + for (k, v) in obj { + if !KNOWN_V2_FIELDS.contains(&k.as_str()) { + ext_map.insert(format!("x-migrated/{k}"), v.clone()); + } + } + } + + let (integrations, ext_map) = build_integrations(integrations_src, ext_map); + + let mut entity = serde_json::json!({ "apiVersion": "v3", "kind": kind, "metadata": metadata }); + if !spec.is_empty() { + entity["spec"] = serde_json::Value::Object(spec); + } + if !integrations.is_empty() { + entity["integrations"] = serde_json::Value::Object(integrations); + } + if !ext_map.is_empty() { + entity["extensions"] = serde_json::Value::Object(ext_map); + } + + let mut companions = Vec::new(); + if let Some(app) = &application { + eprintln!( + "{ANSI_YELLOW}WARNING: 'application' field found (value: '{app}'). \ + A companion 'kind: system' entity will be generated.{ANSI_RESET}" + ); + companions.push(make_system_entity( + app, + team.as_deref(), + Some(&service_name), + )); + } + (ordered_entity(entity), companions) +} + +// --------------------------------------------------------------------------- +// v2.1 → v3 +// --------------------------------------------------------------------------- + +const KNOWN_V2_1_FIELDS: &[&str] = &[ + "schema-version", + "dd-service", + "dd-package", + "dd-datastore", + "dd-queue", + "dd-api", + "team", + "dd-team", + "description", + "display-name", + "application", + "tier", + "lifecycle", + "contacts", + "links", + "repos", + "docs", + "tags", + "integrations", + "extensions", +]; + +fn migrate_v2_1(doc: &serde_json::Value) -> (serde_json::Value, Vec) { + let (kind, service_name) = detect_entity_kind(doc); + let team = str_field(doc, "team").or_else(|| str_field(doc, "dd-team")); + let description = str_field(doc, "description"); + let display_name = str_field(doc, "display-name"); + let application = str_field(doc, "application"); + let lifecycle = str_field(doc, "lifecycle"); + let tier = str_field(doc, "tier"); + let contacts_src = arr_field(doc, "contacts"); + let links_src = arr_field(doc, "links"); + let repos_src = arr_field(doc, "repos"); + let docs_src = arr_field(doc, "docs"); + let tags = arr_field(doc, "tags"); + let integrations_src = doc.get("integrations").cloned().unwrap_or_default(); + + let mut metadata = serde_json::json!({ "name": service_name }); + if let Some(dn) = display_name { + metadata["displayName"] = serde_json::json!(dn); + } + if let Some(d) = description { + metadata["description"] = serde_json::json!(d); + } + if let Some(t) = &team { + metadata["owner"] = serde_json::json!(t); + } + if !tags.is_empty() { + metadata["tags"] = serde_json::json!(tags); + } + if !contacts_src.is_empty() { + metadata["contacts"] = serde_json::json!(contacts_src); + } + + let mut links = migrate_links(links_src); + links.extend(repos_to_links(repos_src)); + links.extend(docs_to_links(docs_src)); + if !links.is_empty() { + metadata["links"] = serde_json::json!(links); + } + + let mut spec = serde_json::Map::new(); + if let Some(lc) = lifecycle { + spec.insert("lifecycle".into(), serde_json::json!(lc)); + } + if let Some(t) = tier { + spec.insert("tier".into(), serde_json::json!(t)); + } + if let Some(app) = &application { + spec.insert( + "componentOf".into(), + serde_json::json!([format!("system:{app}")]), + ); + } + + let mut ext_map: serde_json::Map = doc + .get("extensions") + .and_then(|v| v.as_object().cloned()) + .unwrap_or_default(); + if let Some(obj) = doc.as_object() { + for (k, v) in obj { + if !KNOWN_V2_1_FIELDS.contains(&k.as_str()) { + ext_map.insert(format!("x-migrated/{k}"), v.clone()); + } + } + } + + let (integrations, ext_map) = build_integrations(integrations_src, ext_map); + + let mut entity = serde_json::json!({ "apiVersion": "v3", "kind": kind, "metadata": metadata }); + if !spec.is_empty() { + entity["spec"] = serde_json::Value::Object(spec); + } + if !integrations.is_empty() { + entity["integrations"] = serde_json::Value::Object(integrations); + } + if !ext_map.is_empty() { + entity["extensions"] = serde_json::Value::Object(ext_map); + } + + let mut companions = Vec::new(); + if let Some(app) = &application { + eprintln!( + "{ANSI_YELLOW}WARNING: 'application' field found (value: '{app}'). \ + A companion 'kind: system' entity will be generated.{ANSI_RESET}" + ); + companions.push(make_system_entity( + app, + team.as_deref(), + Some(&service_name), + )); + } + (ordered_entity(entity), companions) +} + +// --------------------------------------------------------------------------- +// v2.2 → v3 +// --------------------------------------------------------------------------- + +const KNOWN_V2_2_FIELDS: &[&str] = &[ + "schema-version", + "dd-service", + "dd-package", + "dd-datastore", + "dd-queue", + "dd-api", + "team", + "dd-team", + "description", + "display-name", + "application", + "tier", + "lifecycle", + "type", + "languages", + "ci-pipeline-fingerprints", + "contacts", + "links", + "repos", + "docs", + "tags", + "integrations", + "extensions", +]; + +fn migrate_v2_2(doc: &serde_json::Value) -> (serde_json::Value, Vec) { + let (kind, service_name) = detect_entity_kind(doc); + let team = str_field(doc, "team").or_else(|| str_field(doc, "dd-team")); + let description = str_field(doc, "description"); + let display_name = str_field(doc, "display-name"); + let application = str_field(doc, "application"); + let lifecycle = str_field(doc, "lifecycle"); + let tier = str_field(doc, "tier"); + let service_type = str_field(doc, "type"); + let languages = arr_field(doc, "languages"); + let ci_fps = arr_field(doc, "ci-pipeline-fingerprints"); + let contacts_src = arr_field(doc, "contacts"); + let links_src = arr_field(doc, "links"); + let repos_src = arr_field(doc, "repos"); + let docs_src = arr_field(doc, "docs"); + let tags = arr_field(doc, "tags"); + let integrations_src = doc.get("integrations").cloned().unwrap_or_default(); + + let mut metadata = serde_json::json!({ "name": service_name }); + if let Some(dn) = display_name { + metadata["displayName"] = serde_json::json!(dn); + } + if let Some(d) = description { + metadata["description"] = serde_json::json!(d); + } + if let Some(t) = &team { + metadata["owner"] = serde_json::json!(t); + } + if !tags.is_empty() { + metadata["tags"] = serde_json::json!(tags); + } + if !contacts_src.is_empty() { + metadata["contacts"] = serde_json::json!(contacts_src); + } + + let mut links = migrate_links(links_src); + links.extend(repos_to_links(repos_src)); + links.extend(docs_to_links(docs_src)); + if !links.is_empty() { + metadata["links"] = serde_json::json!(links); + } + + let mut spec = serde_json::Map::new(); + if let Some(lc) = lifecycle { + spec.insert("lifecycle".into(), serde_json::json!(lc)); + } + if let Some(t) = tier { + spec.insert("tier".into(), serde_json::json!(t)); + } + if let Some(st) = service_type { + spec.insert("type".into(), serde_json::json!(st)); + } + if !languages.is_empty() { + spec.insert("languages".into(), serde_json::json!(languages)); + } + if let Some(app) = &application { + spec.insert( + "componentOf".into(), + serde_json::json!([format!("system:{app}")]), + ); + } + + // ci-pipeline-fingerprints → datadog.pipelines.fingerprints + let mut datadog_out = serde_json::Map::new(); + if !ci_fps.is_empty() { + datadog_out.insert( + "pipelines".into(), + serde_json::json!({ "fingerprints": ci_fps }), + ); + } + + let mut ext_map: serde_json::Map = doc + .get("extensions") + .and_then(|v| v.as_object().cloned()) + .unwrap_or_default(); + if let Some(obj) = doc.as_object() { + for (k, v) in obj { + if !KNOWN_V2_2_FIELDS.contains(&k.as_str()) { + ext_map.insert(format!("x-migrated/{k}"), v.clone()); + } + } + } + + let (integrations, ext_map) = build_integrations(integrations_src, ext_map); + + let mut entity = serde_json::json!({ "apiVersion": "v3", "kind": kind, "metadata": metadata }); + if !spec.is_empty() { + entity["spec"] = serde_json::Value::Object(spec); + } + if !integrations.is_empty() { + entity["integrations"] = serde_json::Value::Object(integrations); + } + if !datadog_out.is_empty() { + entity["datadog"] = serde_json::Value::Object(datadog_out); + } + if !ext_map.is_empty() { + entity["extensions"] = serde_json::Value::Object(ext_map); + } + + let mut companions = Vec::new(); + if let Some(app) = &application { + eprintln!( + "{ANSI_YELLOW}WARNING: 'application' field found (value: '{app}'). \ + A companion 'kind: system' entity will be generated.{ANSI_RESET}" + ); + companions.push(make_system_entity( + app, + team.as_deref(), + Some(&service_name), + )); + } + (ordered_entity(entity), companions) +} + +// --------------------------------------------------------------------------- +// Multi-document handling +// --------------------------------------------------------------------------- + +fn migrate_document( + doc: &serde_json::Value, +) -> (Option, Vec) { + match detect_version(doc) { + "v3" => { + let name = doc + .get("metadata") + .and_then(|m| m.get("name")) + .and_then(|n| n.as_str()) + .unwrap_or("(unknown)"); + eprintln!( + "{ANSI_DIM}INFO: '{name}' is already v3, passing through unchanged.{ANSI_RESET}" + ); + (Some(doc.clone()), vec![]) + } + "v1-noncatalog" => { + let kind = doc + .get("kind") + .and_then(|v| v.as_str()) + .unwrap_or("unknown"); + eprintln!("{ANSI_YELLOW}WARNING: schema-version=v1, kind='{kind}' is not a catalog entity. Passing through.{ANSI_RESET}"); + (Some(doc.clone()), vec![]) + } + "unknown" => { + eprintln!("{ANSI_YELLOW}WARNING: Could not detect schema version. Passing document through unchanged.{ANSI_RESET}"); + (Some(doc.clone()), vec![]) + } + "v1" => { + let (e, c) = migrate_v1(doc); + (Some(e), c) + } + "v2" => { + let (e, c) = migrate_v2(doc); + (Some(e), c) + } + "v2.1" => { + let (e, c) = migrate_v2_1(doc); + (Some(e), c) + } + "v2.2" => { + let (e, c) = migrate_v2_2(doc); + (Some(e), c) + } + _ => (Some(doc.clone()), vec![]), + } +} + +fn merge_companion_systems(companions: Vec) -> Vec { + let mut order: Vec = Vec::new(); + let mut merged: std::collections::HashMap = + std::collections::HashMap::new(); + + for companion in companions { + if companion.get("kind").and_then(|v| v.as_str()) != Some("system") { + continue; + } + let name = match companion + .get("metadata") + .and_then(|m| m.get("name")) + .and_then(|n| n.as_str()) + { + Some(n) => n.to_string(), + None => continue, + }; + let new_components: Vec = companion + .get("spec") + .and_then(|s| s.get("components")) + .and_then(|c| c.as_array()) + .cloned() + .unwrap_or_default(); + + if let Some(existing) = merged.get_mut(&name) { + let existing_comps = existing["spec"]["components"].as_array_mut().unwrap(); + for c in new_components { + if !existing_comps.contains(&c) { + existing_comps.push(c); + } + } + } else { + order.push(name.clone()); + merged.insert(name, companion); + } + } + order + .into_iter() + .filter_map(|name| merged.remove(&name)) + .collect() +} + +/// Parse all YAML documents in `text`, migrate each, and return a flat list: +/// primary entities first, then deduplicated companion system entities. +pub fn migrate_all(text: &str) -> anyhow::Result> { + // Split on document separators manually; serde_norway has no multi-doc API + let segments: Vec<&str> = text.split("\n---").collect(); + let mut primary_docs: Vec = Vec::new(); + let mut all_companions: Vec = Vec::new(); + + for segment in &segments { + // Strip the leading `---` marker, then only the first newline — + // do NOT trim() all whitespace or we destroy consistent block indentation + // in files like `---\n schema-version: v2\n dd-service: …`. + let stripped = segment.trim_start_matches('-'); + let trimmed = stripped.strip_prefix('\n').unwrap_or(stripped).trim_end(); + if trimmed.trim().is_empty() { + continue; + } + let doc: serde_json::Value = serde_norway::from_str(trimmed) + .map_err(|e| anyhow::anyhow!("failed to parse YAML document: {e}"))?; + if !doc.is_object() { + eprintln!("{ANSI_YELLOW}WARNING: Skipping non-mapping YAML document.{ANSI_RESET}"); + continue; + } + let (migrated, companions) = migrate_document(&doc); + if let Some(m) = migrated { + primary_docs.push(m); + } + all_companions.extend(companions); + } + + if primary_docs.is_empty() && all_companions.is_empty() { + return Ok(vec![]); + } + + let mut result = primary_docs; + result.extend(merge_companion_systems(all_companions)); + Ok(result) +} + +// --------------------------------------------------------------------------- +// Schema validation +// --------------------------------------------------------------------------- + +const SCHEMA_BASE_URL: &str = + "https://raw.githubusercontent.com/DataDog/schema/main/service-catalog/v3/"; + +/// All v3 schema filenames to pre-fetch (used for $ref resolution) +const V3_SCHEMA_FILES: &[&str] = &[ + "api", + "application", + "custom", + "datadog_code_locations", + "datadog_events", + "datadog_logs", + "datadog_pipelines", + "datastore", + "entity", + "integration_opsgenie", + "integration_pagerduty", + "integrations", + "metadata", + "queue", + "repository", + "service", + "system", +]; + +async fn fetch_schema_by_url( + client: &reqwest::Client, + url: &str, +) -> anyhow::Result { + let resp = client + .get(url) + .timeout(std::time::Duration::from_secs(10)) + .send() + .await + .map_err(|e| anyhow::anyhow!("failed to fetch schema {url}: {e}"))?; + if !resp.status().is_success() { + anyhow::bail!("schema fetch {url} returned HTTP {}", resp.status()); + } + resp.json::() + .await + .map_err(|e| anyhow::anyhow!("failed to parse schema {url}: {e}")) +} + +/// In-memory retriever for $ref resolution — satisfies jsonschema::Retrieve +struct SchemaRegistry { + schemas: std::collections::HashMap, +} + +impl jsonschema::Retrieve for SchemaRegistry { + fn retrieve( + &self, + uri: &jsonschema::Uri, + ) -> std::result::Result> { + self.schemas + .get(uri.as_str()) + .cloned() + .ok_or_else(|| format!("schema not in registry: {uri}").into()) + } +} + +/// Pre-fetch all v3 schemas from GitHub, returning a URL → schema map. +async fn fetch_all_schemas() -> anyhow::Result> +{ + let client = reqwest::Client::new(); + let mut map = std::collections::HashMap::new(); + for name in V3_SCHEMA_FILES { + let url = format!("{SCHEMA_BASE_URL}{name}.schema.json"); + match fetch_schema_by_url(&client, &url).await { + Ok(schema) => { + map.insert(url, schema); + } + Err(e) => { + eprintln!( + "{ANSI_YELLOW}WARNING: Could not fetch schema '{name}': {e}.{ANSI_RESET}" + ); + } + } + } + Ok(map) +} + +/// Validate all v3 documents. Prints coloured per-entity results. +/// Returns all validation error strings (empty = all valid). +fn validate_docs( + docs: &[serde_json::Value], + all_schemas: &std::collections::HashMap, +) -> anyhow::Result> { + let registry = SchemaRegistry { + schemas: all_schemas.clone(), + }; + + let mut all_warnings: Vec = Vec::new(); + for doc in docs { + let name = doc + .get("metadata") + .and_then(|m| m.get("name")) + .and_then(|n| n.as_str()) + .unwrap_or("(unknown)"); + let kind = doc.get("kind").and_then(|v| v.as_str()).unwrap_or("?"); + + if doc.get("apiVersion").and_then(|v| v.as_str()) != Some("v3") { + println!("{ANSI_DIM}SKIPPED [{kind}] {name} (not v3){ANSI_RESET}"); + continue; + } + + let kind_lower = kind.to_lowercase(); + let schema_url = format!("{SCHEMA_BASE_URL}{kind_lower}.schema.json"); + let schema = match registry.schemas.get(&schema_url) { + Some(s) => s, + None => { + println!("{ANSI_DIM}SKIPPED [{kind}] {name} (no schema){ANSI_RESET}"); + continue; + } + }; + + let validator = jsonschema::options() + .with_retriever(SchemaRegistry { + schemas: registry.schemas.clone(), + }) + .build(schema) + .map_err(|e| anyhow::anyhow!("failed to compile schema for '{kind}': {e}"))?; + + let errors: Vec = validator + .iter_errors(doc) + .map(|e| format!("[{}] {}", e.instance_path(), e)) + .collect(); + + if errors.is_empty() { + println!("{ANSI_GREEN}\u{2713} VALID [{kind}] {name}{ANSI_RESET}"); + } else { + println!("{ANSI_RED}\u{2717} INVALID [{kind}] {name}{ANSI_RESET}"); + for err in &errors { + println!("{ANSI_RED} {err}{ANSI_RESET}"); + } + for err in errors { + all_warnings.push(format!("[{kind}] {name}: {err}")); + } + } + } + Ok(all_warnings) +} + +// --------------------------------------------------------------------------- +// File discovery +// --------------------------------------------------------------------------- + +pub fn discover_catalog_files(root: &std::path::Path) -> Vec { + let mut results = Vec::new(); + if let Ok(entries) = std::fs::read_dir(root) { + let mut entries: Vec<_> = entries.flatten().collect(); + entries.sort_by_key(|e| e.path()); + for entry in entries { + let path = entry.path(); + if path.is_dir() { + results.extend(discover_catalog_files(&path)); + } else if path + .file_name() + .and_then(|n| n.to_str()) + .map(|n| n.ends_with(".datadog.yaml")) + .unwrap_or(false) + { + results.push(path); + } + } + } + results +} + +// --------------------------------------------------------------------------- +// Output helpers +// --------------------------------------------------------------------------- + +fn prompt_line(msg: &str) -> anyhow::Result { + use std::io::Write; + print!("{msg}"); + std::io::stdout().flush()?; + let mut input = String::new(); + std::io::stdin().read_line(&mut input)?; + Ok(input.trim().to_string()) +} + +fn docs_to_yaml(docs: &[serde_json::Value]) -> anyhow::Result { + let mut out = String::new(); + for doc in docs { + let yaml = serde_norway::to_string(doc) + .map_err(|e| anyhow::anyhow!("failed to serialise to YAML: {e}"))?; + out.push_str("---\n"); + out.push_str(&yaml); + } + Ok(out.trim_end().to_string() + "\n") +} + +fn write_output(yaml: &str, source: &std::path::Path) -> anyhow::Result<()> { + let parent = source.parent().unwrap_or(std::path::Path::new(".")); + let entity_path = parent.join("entity.datadog.yaml"); + + println!(); + println!("{ANSI_BOLD}Where would you like to write the output?{ANSI_RESET}"); + println!( + " {ANSI_BOLD}[1]{ANSI_RESET} Write to {ANSI_GREEN}{}{ANSI_RESET} (same directory, delete original)", + entity_path.display() + ); + println!(" {ANSI_BOLD}[2]{ANSI_RESET} Specify a custom path"); + println!(" {ANSI_BOLD}[3]{ANSI_RESET} Print to stdout only"); + + let choice = prompt_line("> ")?; + + match choice.as_str() { + "1" => { + std::fs::write(&entity_path, yaml) + .map_err(|e| anyhow::anyhow!("failed to write '{}': {e}", entity_path.display()))?; + if source != entity_path { + std::fs::remove_file(source).map_err(|e| { + anyhow::anyhow!("failed to delete original '{}': {e}", source.display()) + })?; + } + println!( + "{ANSI_GREEN}{ANSI_BOLD}\u{2714} Written to {}{ANSI_RESET}", + entity_path.display() + ); + } + "2" => { + let custom = prompt_line("Path: ")?.trim().to_string(); + if custom.is_empty() { + anyhow::bail!("no path provided"); + } + let custom_path = std::path::PathBuf::from(&custom); + if let Some(p) = custom_path.parent() { + std::fs::create_dir_all(p).ok(); + } + std::fs::write(&custom_path, yaml) + .map_err(|e| anyhow::anyhow!("failed to write '{custom}': {e}"))?; + println!("{ANSI_GREEN}{ANSI_BOLD}\u{2714} Written to {custom}{ANSI_RESET}"); + } + _ => { + print!("{yaml}"); + } + } + Ok(()) +} + +// --------------------------------------------------------------------------- +// Entry point +// --------------------------------------------------------------------------- + +pub async fn migrate_schema(_cfg: &Config, file: Option) -> Result<()> { + let paths: Vec = match file { + Some(f) => { + let p = std::path::PathBuf::from(&f); + if p.is_dir() { + eprintln!("{ANSI_DIM}Searching for *.datadog.yaml files in '{}'{ANSI_RESET}", p.display()); + let found = discover_catalog_files(&p); + match found.len() { + 0 => anyhow::bail!( + "No *.datadog.yaml files found in '{}'", + p.display() + ), + 1 => { + println!("{ANSI_DIM}Using {}{ANSI_RESET}", found[0].display()); + found + } + _ => { + println!( + "{ANSI_BOLD}Found {} catalog files:{ANSI_RESET}", + found.len() + ); + for (i, path) in found.iter().enumerate() { + println!(" [{ANSI_BOLD}{}{ANSI_RESET}] {}", i + 1, path.display()); + } + let choice = prompt_line( + "Enter number to migrate one, or \"all\" to migrate all: ", + )?; + if choice.eq_ignore_ascii_case("all") { + found + } else { + let idx: usize = choice + .parse::() + .map_err(|_| anyhow::anyhow!("invalid choice '{choice}'"))?; + if idx == 0 || idx > found.len() { + anyhow::bail!("choice {idx} out of range (1–{})", found.len()); + } + vec![found[idx - 1].clone()] + } + } + } + } else { + vec![p] + } + } + None => { + let cwd = std::env::current_dir()?; + eprintln!("{ANSI_DIM}Searching for *.datadog.yaml files in '{}'{ANSI_RESET}", cwd.display()); + let found = discover_catalog_files(&cwd); + match found.len() { + 0 => anyhow::bail!( + "No *.datadog.yaml files found. Specify a path: pup idp migrate-schema " + ), + 1 => { + println!("{ANSI_DIM}Using {}{ANSI_RESET}", found[0].display()); + found + } + _ => { + println!( + "{ANSI_BOLD}Found {} catalog files:{ANSI_RESET}", + found.len() + ); + for (i, p) in found.iter().enumerate() { + println!(" [{ANSI_BOLD}{}{ANSI_RESET}] {}", i + 1, p.display()); + } + let choice = + prompt_line("Enter number to migrate one, or \"all\" to migrate all: ")?; + if choice.eq_ignore_ascii_case("all") { + found + } else { + let idx: usize = choice + .parse::() + .map_err(|_| anyhow::anyhow!("invalid choice '{choice}'"))?; + if idx == 0 || idx > found.len() { + anyhow::bail!("choice {idx} out of range (1–{})", found.len()); + } + vec![found[idx - 1].clone()] + } + } + } + } + }; + + // For multiple files: confirm once before touching anything. + if paths.len() > 1 { + let n = paths.len(); + let answer = prompt_line(&format!( + "{ANSI_BOLD}{n} files will be migrated and overwritten in place. Continue? [y/N]{ANSI_RESET} " + ))?.to_lowercase(); + if answer != "y" && answer != "yes" { + println!("{ANSI_DIM}Aborted.{ANSI_RESET}"); + return Ok(()); + } + } + + eprintln!("{ANSI_DIM}Fetching v3 schemas for validation...{ANSI_RESET}"); + let schemas = std::sync::Arc::new(fetch_all_schemas().await?); + let start = std::time::Instant::now(); + + if paths.len() > 1 { + // Process in bounded batches to avoid exhausting OS file descriptors. + const BATCH: usize = 16; + let mut outcomes: Vec = Vec::with_capacity(paths.len()); + + for chunk in paths.chunks(BATCH) { + let handles: Vec<_> = chunk + .iter() + .map(|path| { + let schemas = std::sync::Arc::clone(&schemas); + let path = path.clone(); + std::thread::spawn(move || migrate_one(&path, true, &schemas)) + }) + .collect(); + + for h in handles { + outcomes.push(h.join().unwrap_or_else(|_| MigrateOutcome { + path: std::path::PathBuf::new(), + status: MigrateStatus::Failed("thread panicked".into()), + services: 0, + systems: 0, + })); + } + } + + print_summary(&outcomes, start.elapsed()); + } else { + migrate_one(&paths[0], false, &schemas); + } + + Ok(()) +} + +fn print_summary(outcomes: &[MigrateOutcome], elapsed: std::time::Duration) { + let n_migrated = outcomes + .iter() + .filter(|o| matches!(o.status, MigrateStatus::Migrated { .. })) + .count(); + let invalid: Vec<_> = outcomes + .iter() + .filter(|o| matches!(&o.status, MigrateStatus::Migrated { warnings } if !warnings.is_empty())) + .collect(); + let failed: Vec<_> = outcomes + .iter() + .filter(|o| matches!(o.status, MigrateStatus::Failed(_))) + .collect(); + let skipped: Vec<_> = outcomes + .iter() + .filter(|o| matches!(o.status, MigrateStatus::Skipped)) + .collect(); + let total_services: usize = outcomes.iter().map(|o| o.services).sum(); + let total_systems: usize = outcomes.iter().map(|o| o.systems).sum(); + + println!(); + println!( + "{ANSI_BOLD}Migration complete{ANSI_RESET} in {:.2}s", + elapsed.as_secs_f64() + ); + println!(" {ANSI_GREEN}{ANSI_BOLD}\u{2714}{ANSI_RESET} {n_migrated} migrated"); + if !invalid.is_empty() { + println!( + " {ANSI_YELLOW}\u{26a0}{ANSI_RESET} {} written with validation warnings", + invalid.len() + ); + for o in &invalid { + println!(" {ANSI_YELLOW}{}{ANSI_RESET}", o.path.display()); + if let MigrateStatus::Migrated { warnings } = &o.status { + for w in warnings { + println!(" {ANSI_DIM}{w}{ANSI_RESET}"); + } + } + } + } + if !failed.is_empty() { + println!(" {ANSI_RED}\u{2717}{ANSI_RESET} {} failed", failed.len()); + for o in &failed { + if let MigrateStatus::Failed(msg) = &o.status { + println!(" {ANSI_RED}{}{ANSI_RESET}: {msg}", o.path.display()); + } + } + } + if !skipped.is_empty() { + println!(" {ANSI_DIM}\u{2500} {} skipped{ANSI_RESET}", skipped.len()); + } + println!( + " {ANSI_DIM}\u{21b3} {total_services} service{}, {total_systems} system{} total{ANSI_RESET}", + if total_services == 1 { "" } else { "s" }, + if total_systems == 1 { "" } else { "s" }, + ); +} + +fn migrate_one( + path: &std::path::Path, + write_in_place: bool, + schemas: &std::collections::HashMap, +) -> MigrateOutcome { + let failed = |msg: String| MigrateOutcome { + path: path.to_path_buf(), + status: MigrateStatus::Failed(msg), + services: 0, + systems: 0, + }; + + let content = match std::fs::read_to_string(path) { + Ok(c) => c, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + // Broken symlink or deleted file — skip silently + return MigrateOutcome { + path: path.to_path_buf(), + status: MigrateStatus::Skipped, + services: 0, + systems: 0, + }; + } + Err(e) => return failed(format!("cannot read file: {e}")), + }; + + let source_version = { + let first: serde_json::Value = serde_norway::from_str(&content).unwrap_or_default(); + detect_version(&first).to_string() + }; + + if !write_in_place { + println!( + "{ANSI_BOLD}Migrating{ANSI_RESET} {} {ANSI_DIM}(detected: {source_version}){ANSI_RESET}", + path.display() + ); + } + + let docs = match migrate_all(&content) { + Err(e) => return failed(e.to_string()), + Ok(d) if d.is_empty() => { + // Empty or comment-only file — nothing to migrate + return MigrateOutcome { + path: path.to_path_buf(), + status: MigrateStatus::Skipped, + services: 0, + systems: 0, + }; + } + Ok(d) => d, + }; + + let warnings = validate_docs(&docs, schemas).unwrap_or_else(|e| { + if !write_in_place { + eprintln!("{ANSI_YELLOW}WARNING: validation error: {e}{ANSI_RESET}"); + } + vec![format!("validation error: {e}")] + }); + + if !warnings.is_empty() && !write_in_place { + let choice = prompt_line("\n[1] Abort [2] Write anyway > ").unwrap_or_default(); + if choice.trim() != "2" { + println!("{ANSI_DIM}Aborted. Fix the source file and re-run.{ANSI_RESET}"); + return MigrateOutcome { + path: path.to_path_buf(), + status: MigrateStatus::Skipped, + services: 0, + systems: 0, + }; + } + } + + let yaml_out = match docs_to_yaml(&docs) { + Ok(y) => y, + Err(e) => return failed(e.to_string()), + }; + + let services = docs + .iter() + .filter(|d| d.get("kind").and_then(|v| v.as_str()) == Some("service")) + .count(); + let systems = docs + .iter() + .filter(|d| d.get("kind").and_then(|v| v.as_str()) == Some("system")) + .count(); + + if write_in_place { + let dest = path + .parent() + .unwrap_or(std::path::Path::new(".")) + .join("entity.datadog.yaml"); + if let Err(e) = std::fs::write(&dest, &yaml_out) { + return failed(format!("failed to write '{}': {e}", dest.display())); + } + if path != dest { + let _ = std::fs::remove_file(path); + } + } else { + if let Err(e) = write_output(&yaml_out, path) { + return failed(e.to_string()); + } + println!(); + println!( + " {services} service entit{}, {systems} companion system entit{}", + if services == 1 { "y" } else { "ies" }, + if systems == 1 { "y" } else { "ies" }, + ); + } + + MigrateOutcome { + path: path.to_path_buf(), + status: MigrateStatus::Migrated { warnings }, + services, + systems, + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + fn parse(yaml: &str) -> serde_json::Value { + serde_norway::from_str(yaml).expect("invalid yaml in test") + } + + // --- link type remapping --- + + #[test] + fn test_link_type_remap() { + assert_eq!(remap_link_type("wiki"), "doc"); + assert_eq!(remap_link_type("code"), "repo"); + assert_eq!(remap_link_type("url"), "other"); + assert_eq!(remap_link_type("oncall"), "other"); + assert_eq!(remap_link_type("link"), "other"); + assert_eq!(remap_link_type("doc"), "doc"); + assert_eq!(remap_link_type("repo"), "repo"); + assert_eq!(remap_link_type("dashboard"), "dashboard"); + assert_eq!(remap_link_type("runbook"), "runbook"); + assert_eq!(remap_link_type("other"), "other"); + assert_eq!(remap_link_type("custom-type"), "custom-type"); + } + + // --- file discovery --- + + #[test] + fn test_discover_single_file() { + let dir = std::env::temp_dir().join(format!("pup-test-discover-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let file = dir.join("service.datadog.yaml"); + std::fs::write(&file, "dd-service: foo\n").unwrap(); + let found = discover_catalog_files(&dir); + assert_eq!(found.len(), 1); + assert_eq!(found[0], file); + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn test_discover_no_files() { + let dir = + std::env::temp_dir().join(format!("pup-test-discover-empty-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let found = discover_catalog_files(&dir); + assert!(found.is_empty()); + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn test_discover_recursive() { + let dir = + std::env::temp_dir().join(format!("pup-test-discover-rec-{}", std::process::id())); + let sub = dir.join("subdir"); + std::fs::create_dir_all(&sub).unwrap(); + std::fs::write(dir.join("a.datadog.yaml"), "x: 1").unwrap(); + std::fs::write(sub.join("b.datadog.yaml"), "x: 2").unwrap(); + std::fs::write(sub.join("not-a-match.yaml"), "x: 3").unwrap(); + let found = discover_catalog_files(&dir); + assert_eq!(found.len(), 2); + assert!(found + .iter() + .any(|f| f.file_name().unwrap() == "a.datadog.yaml")); + assert!(found + .iter() + .any(|f| f.file_name().unwrap() == "b.datadog.yaml")); + std::fs::remove_dir_all(&dir).unwrap(); + } + + // --- multi-document handling --- + + #[test] + fn test_v3_passthrough() { + let input = "apiVersion: v3\nkind: service\nmetadata:\n name: already-v3-service\n"; + let docs = migrate_all(input).unwrap(); + assert_eq!(docs.len(), 1); + assert_eq!(docs[0]["metadata"]["name"], "already-v3-service"); + assert_eq!(docs[0]["apiVersion"], "v3"); + } + + #[test] + fn test_migrate_all_single_doc() { + let input = "schema-version: v2\ndd-service: my-svc\nteam: my-team\n"; + let docs = migrate_all(input).unwrap(); + assert_eq!(docs.len(), 1); + assert_eq!(docs[0]["metadata"]["name"], "my-svc"); + } + + #[test] + fn test_companion_deduplication() { + let input = concat!( + "---\nschema-version: v2.1\ndd-service: buildbarn-frontend\nteam: ci-team\napplication: Buildbarn\n", + "---\nschema-version: v2.1\ndd-service: buildbarn-storage\nteam: ci-team\napplication: Buildbarn\n", + ); + let docs = migrate_all(input).unwrap(); + // 2 service entities + 1 merged system entity + assert_eq!(docs.len(), 3); + let system = docs.iter().find(|d| d["kind"] == "system").unwrap(); + assert_eq!(system["metadata"]["name"], "Buildbarn"); + let components = system["spec"]["components"].as_array().unwrap(); + assert_eq!(components.len(), 2); + assert!(components + .iter() + .any(|c| c.as_str().unwrap_or("").contains("buildbarn-frontend"))); + assert!(components + .iter() + .any(|c| c.as_str().unwrap_or("").contains("buildbarn-storage"))); + } + + #[test] + fn test_unknown_fields_in_extensions() { + let input = "schema-version: v2\ndd-service: svc\nteam: t\ncustom-field: hello\n"; + let docs = migrate_all(input).unwrap(); + assert_eq!(docs[0]["extensions"]["x-migrated/custom-field"], "hello"); + } + + // --- v1 migration --- + + #[test] + fn test_migrate_v1_full() { + let input = r##" +schema-version: v1 +info: + dd-service: payment-service + display-name: Payment Service + description: Handles payment processing + service-tier: Tier 1 +org: + team: payments-team + application: checkout-platform +contact: + email: payments@example.com + slack: "#payments-oncall" +tags: + - "env:production" + - "team:payments" +external-resources: + - name: Runbook + type: wiki + url: https://wiki.example.com/payments/runbook + - name: Source Code + type: code + url: https://github.com/example/payment-service +integrations: + pagerduty: https://events.pagerduty.com/integration/abc123/enqueue + github: https://github.com/example/payment-service +extensions: + datadoghq.com/slo: + - id: slo-payment-availability +"##; + let doc = parse(input); + let (entity, companions) = migrate_v1(&doc); + assert_eq!(entity["apiVersion"], "v3"); + assert_eq!(entity["kind"], "service"); + assert_eq!(entity["metadata"]["name"], "payment-service"); + assert_eq!(entity["metadata"]["displayName"], "Payment Service"); + assert_eq!( + entity["metadata"]["description"], + "Handles payment processing" + ); + assert_eq!(entity["metadata"]["owner"], "payments-team"); + let tags = entity["metadata"]["tags"].as_array().unwrap(); + assert!(tags.contains(&serde_json::json!("env:production"))); + let contacts = entity["metadata"]["contacts"].as_array().unwrap(); + assert!(contacts + .iter() + .any(|c| c["type"] == "email" && c["contact"] == "payments@example.com")); + assert!(contacts.iter().any(|c| c["type"] == "slack")); + let links = entity["metadata"]["links"].as_array().unwrap(); + let runbook = links.iter().find(|l| l["name"] == "Runbook").unwrap(); + assert_eq!(runbook["type"], "doc"); // wiki → doc + let code = links.iter().find(|l| l["name"] == "Source Code").unwrap(); + assert_eq!(code["type"], "repo"); // code → repo + assert!(links + .iter() + .any(|l| l["name"] == "GitHub" && l["type"] == "repo")); + assert_eq!(entity["spec"]["tier"], "Tier 1"); + assert_eq!(entity["spec"]["componentOf"][0], "system:checkout-platform"); + assert_eq!( + entity["integrations"]["pagerduty"]["serviceURL"], + "https://events.pagerduty.com/integration/abc123/enqueue" + ); + assert!(entity["extensions"]["datadoghq.com/slo"].is_array()); + assert_eq!(companions.len(), 1); + assert_eq!(companions[0]["kind"], "system"); + assert_eq!(companions[0]["metadata"]["name"], "checkout-platform"); + assert_eq!( + companions[0]["spec"]["components"][0], + "service:payment-service" + ); + } + + #[test] + fn test_migrate_v1_minimal() { + let doc = parse("schema-version: v1\ninfo:\n dd-service: my-minimal-service\n"); + let (entity, companions) = migrate_v1(&doc); + assert_eq!(entity["apiVersion"], "v3"); + assert_eq!(entity["kind"], "service"); + assert_eq!(entity["metadata"]["name"], "my-minimal-service"); + assert!( + entity.get("spec").is_none() + || entity["spec"] + .as_object() + .map(|m| m.is_empty()) + .unwrap_or(true) + ); + assert!(companions.is_empty()); + } + + // --- v2 migration --- + + #[test] + fn test_migrate_v2_minimal() { + let doc = parse("schema-version: v2\ndd-service: my-v2-service\nteam: my-team\n"); + let (entity, companions) = migrate_v2(&doc); + assert_eq!(entity["apiVersion"], "v3"); + assert_eq!(entity["metadata"]["name"], "my-v2-service"); + assert_eq!(entity["metadata"]["owner"], "my-team"); + assert!(companions.is_empty()); + } + + #[test] + fn test_migrate_v2_full() { + let input = r##" +schema-version: v2 +dd-service: inventory-service +team: inventory-team +contacts: + - name: On-Call + type: slack + contact: "#inventory-oncall" +links: + - name: Dashboard + type: dashboard + url: https://app.datadoghq.com/dashboard/abc-123 + - name: Old Wiki + type: wiki + url: https://wiki.example.com/inventory +repos: + - name: Main Repo + provider: Github + url: https://github.com/example/inventory-service +docs: + - name: API Docs + provider: Confluence + url: https://confluence.example.com/inventory/api +tags: + - "env:production" +integrations: + pagerduty: https://events.pagerduty.com/integration/def456/enqueue + opsgenie: + service-url: https://api.opsgenie.com/v2/alerts/inventory + region: US +extensions: + datadoghq.com/feature-flags: + enabled: true +"##; + let doc = parse(input); + let (entity, companions) = migrate_v2(&doc); + assert_eq!(entity["metadata"]["name"], "inventory-service"); + let links = entity["metadata"]["links"].as_array().unwrap(); + assert!(links + .iter() + .any(|l| l["name"] == "Old Wiki" && l["type"] == "doc")); + assert!(links + .iter() + .any(|l| l["type"] == "repo" + && l["url"] == "https://github.com/example/inventory-service")); + assert!(links + .iter() + .any(|l| l["type"] == "doc" + && l["url"] == "https://confluence.example.com/inventory/api")); + assert_eq!( + entity["integrations"]["pagerduty"]["serviceURL"], + "https://events.pagerduty.com/integration/def456/enqueue" + ); + assert_eq!( + entity["integrations"]["opsgenie"]["serviceURL"], + "https://api.opsgenie.com/v2/alerts/inventory" + ); + assert_eq!(entity["integrations"]["opsgenie"]["region"], "US"); + assert_eq!( + entity["extensions"]["datadoghq.com/feature-flags"]["enabled"], + true + ); + assert!(companions.is_empty()); + } + + #[test] + fn test_migrate_v2_link_types() { + let input = "schema-version: v2\ndd-service: svc\nlinks:\n - { name: A, type: wiki, url: http://a }\n - { name: B, type: code, url: http://b }\n - { name: C, type: url, url: http://c }\n - { name: D, type: oncall, url: http://d }\n - { name: E, type: runbook, url: http://e }\n"; + let doc = parse(input); + let (entity, _) = migrate_v2(&doc); + let links = entity["metadata"]["links"].as_array().unwrap(); + let t = |name: &str| { + links.iter().find(|l| l["name"] == name).unwrap()["type"] + .as_str() + .unwrap() + .to_string() + }; + assert_eq!(t("A"), "doc"); + assert_eq!(t("B"), "repo"); + assert_eq!(t("C"), "other"); + assert_eq!(t("D"), "other"); + assert_eq!(t("E"), "runbook"); + } + + // --- v2.1 migration --- + + #[test] + fn test_migrate_v2_1_full() { + let input = r##" +schema-version: v2.1 +dd-service: order-service +team: orders-team +description: Manages order lifecycle +application: commerce-platform +tier: High +lifecycle: production +contacts: + - name: Orders On-Call + type: slack + contact: "#orders-oncall" +repos: + - name: Orders Service + provider: Github + url: https://github.com/example/order-service +integrations: + pagerduty: + service-url: https://events.pagerduty.com/integration/ghi789/enqueue + opsgenie: + service-url: https://api.opsgenie.com/v2/alerts/orders + region: EU +"##; + let doc = parse(input); + let (entity, companions) = migrate_v2_1(&doc); + assert_eq!(entity["metadata"]["name"], "order-service"); + assert_eq!(entity["metadata"]["owner"], "orders-team"); + assert_eq!(entity["metadata"]["description"], "Manages order lifecycle"); + assert_eq!(entity["spec"]["lifecycle"], "production"); + assert_eq!(entity["spec"]["tier"], "High"); + assert_eq!(entity["spec"]["componentOf"][0], "system:commerce-platform"); + assert_eq!( + entity["integrations"]["pagerduty"]["serviceURL"], + "https://events.pagerduty.com/integration/ghi789/enqueue" + ); + assert_eq!(entity["integrations"]["opsgenie"]["region"], "EU"); + let links = entity["metadata"]["links"].as_array().unwrap(); + assert!( + links + .iter() + .any(|l| l["type"] == "repo" + && l["url"] == "https://github.com/example/order-service") + ); + assert_eq!(companions.len(), 1); + assert_eq!(companions[0]["metadata"]["name"], "commerce-platform"); + } + + #[test] + fn test_migrate_v2_1_companion() { + let doc = parse("schema-version: v2.1\ndd-service: buildbarn-frontend\nteam: ci-team\napplication: Buildbarn\n"); + let (entity, companions) = migrate_v2_1(&doc); + assert_eq!(entity["spec"]["componentOf"][0], "system:Buildbarn"); + assert_eq!(companions[0]["metadata"]["name"], "Buildbarn"); + } + + // --- v2.2 migration --- + + #[test] + fn test_migrate_v2_2_full() { + let input = r#" +schema-version: v2.2 +dd-service: shipping-service +team: logistics-team +description: Manages shipping operations +application: fulfillment-platform +tier: High +lifecycle: production +type: web +languages: + - go + - python +ci-pipeline-fingerprints: + - abc123def456 +links: + - name: Internal Wiki + type: wiki + url: https://wiki.example.com/shipping + - name: Oncall Docs + type: oncall + url: https://oncall.example.com/shipping +integrations: + pagerduty: + service-url: https://events.pagerduty.com/integration/jkl012/enqueue +"#; + let doc = parse(input); + let (entity, companions) = migrate_v2_2(&doc); + assert_eq!(entity["metadata"]["name"], "shipping-service"); + assert_eq!(entity["spec"]["type"], "web"); + assert_eq!(entity["spec"]["lifecycle"], "production"); + let langs = entity["spec"]["languages"].as_array().unwrap(); + assert!(langs.contains(&serde_json::json!("go"))); + assert_eq!( + entity["datadog"]["pipelines"]["fingerprints"][0], + "abc123def456" + ); + let links = entity["metadata"]["links"].as_array().unwrap(); + assert!(links + .iter() + .any(|l| l["name"] == "Internal Wiki" && l["type"] == "doc")); + assert!(links + .iter() + .any(|l| l["name"] == "Oncall Docs" && l["type"] == "other")); + assert_eq!( + entity["integrations"]["pagerduty"]["serviceURL"], + "https://events.pagerduty.com/integration/jkl012/enqueue" + ); + assert_eq!(companions.len(), 1); + assert_eq!(companions[0]["metadata"]["name"], "fulfillment-platform"); + } + + #[test] + fn test_migrate_v2_2_unknown_fields_in_extensions() { + let doc = parse("schema-version: v2.2\ndd-service: svc\nteam: t\ncustom-field: hello\n"); + let (entity, _) = migrate_v2_2(&doc); + assert_eq!(entity["extensions"]["x-migrated/custom-field"], "hello"); + } + + // --- version detection --- + + #[test] + fn test_detect_v3() { + let doc = parse("apiVersion: v3\nkind: service\nmetadata:\n name: foo\n"); + assert_eq!(detect_version(&doc), "v3"); + } + + #[test] + fn test_detect_v2_2() { + let doc = parse("schema-version: v2.2\ndd-service: foo\n"); + assert_eq!(detect_version(&doc), "v2.2"); + } + + #[test] + fn test_detect_v2_1() { + let doc = parse("schema-version: v2.1\ndd-service: foo\n"); + assert_eq!(detect_version(&doc), "v2.1"); + } + + #[test] + fn test_detect_v2() { + let doc = parse("schema-version: v2\ndd-service: foo\n"); + assert_eq!(detect_version(&doc), "v2"); + } + + #[test] + fn test_detect_v1_explicit() { + let doc = parse("schema-version: v1\nkind: service\ninfo:\n dd-service: foo\n"); + assert_eq!(detect_version(&doc), "v1"); + } + + #[test] + fn test_detect_v1_implicit() { + let doc = parse("info:\n dd-service: my-service\n"); + assert_eq!(detect_version(&doc), "v1"); + } + + #[test] + fn test_detect_v1_noncatalog() { + let doc = parse("schema-version: v1\nkind: mergequeue\nname: foo\n"); + assert_eq!(detect_version(&doc), "v1-noncatalog"); + } + + #[test] + fn test_detect_unknown() { + let doc = parse("schema-version: v99\ndd-service: foo\n"); + assert_eq!(detect_version(&doc), "unknown"); + } + + #[test] + fn test_detect_no_version_no_info() { + let doc = parse("some-key: some-value\n"); + assert_eq!(detect_version(&doc), "unknown"); + } +} diff --git a/src/commands/idp/mod.rs b/src/commands/idp/mod.rs new file mode 100644 index 0000000..58f39e6 --- /dev/null +++ b/src/commands/idp/mod.rs @@ -0,0 +1,675 @@ +use anyhow::Result; +use serde::Serialize; + +use crate::client; +use crate::config::Config; +use crate::formatter; +use crate::util; + +mod migrate; +pub use migrate::migrate_schema; + +// --------------------------------------------------------------------------- +// Response types +// --------------------------------------------------------------------------- + +#[derive(Serialize)] +struct AssistResponse { + entity: EntitySummary, + #[serde(skip_serializing_if = "Option::is_none")] + owner: Option, + #[serde(skip_serializing_if = "Option::is_none")] + on_call: Option, + health: HealthSummary, + dependencies: DependencySummary, + metadata_gaps: Vec, + links: Vec, + suggested_next_actions: Vec, +} + +#[derive(Serialize)] +struct EntitySummary { + #[serde(rename = "ref")] + entity_ref: String, + name: String, + kind: String, + #[serde(skip_serializing_if = "Option::is_none")] + description: Option, + #[serde(skip_serializing_if = "Option::is_none")] + lifecycle: Option, + #[serde(skip_serializing_if = "Option::is_none")] + tier: Option, + #[serde(skip_serializing_if = "Option::is_none")] + owner: Option, + #[serde(skip_serializing_if = "Option::is_none")] + definition_github_url: Option, +} + +#[derive(Serialize, Clone)] +struct LinkEntry { + name: String, + #[serde(rename = "type")] + link_type: String, + url: String, +} + +#[derive(Serialize)] +struct OwnerInfo { + team_handle: String, + #[serde(skip_serializing_if = "Option::is_none")] + team_name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + description: Option, + member_count: i64, + #[serde(skip_serializing_if = "Vec::is_empty")] + contacts: Vec, +} + +#[derive(Serialize)] +struct ContactEntry { + name: String, + #[serde(rename = "type")] + contact_type: String, + contact: String, +} + +#[derive(Serialize)] +struct OnCallInfo { + responders: Vec, +} + +#[derive(Serialize)] +struct OnCallResponder { + name: String, + email: String, + #[serde(skip_serializing_if = "Option::is_none")] + level: Option, +} + +#[derive(Serialize)] +struct HealthSummary { + status: String, + monitors: MonitorCounts, + incidents: IncidentCounts, + slos: SloCounts, +} + +#[derive(Serialize)] +struct MonitorCounts { + ok: i64, + alert: i64, + warn: i64, + no_data: i64, +} + +#[derive(Serialize)] +struct IncidentCounts { + active: i64, + stable: i64, +} + +#[derive(Serialize)] +struct SloCounts { + ok: i64, + breached: i64, + warning: i64, + no_data: i64, +} + +#[derive(Serialize)] +struct DependencySummary { + upstream: Vec, + downstream: Vec, +} + +// --------------------------------------------------------------------------- +// Helpers to extract fields from the UEG JSON:API response +// --------------------------------------------------------------------------- + +fn str_attr(attrs: &serde_json::Value, key: &str) -> Option { + attrs.get(key).and_then(|v| v.as_str()).map(String::from) +} + +fn i64_attr(attrs: &serde_json::Value, key: &str) -> i64 { + attrs.get(key).and_then(|v| v.as_i64()).unwrap_or(0) +} + +fn extract_entity_summary(entity: &serde_json::Value) -> EntitySummary { + let attrs = &entity["attributes"]; + let kind = entity + .get("type") + .and_then(|v| v.as_str()) + .unwrap_or("service"); + let name = str_attr(attrs, "name") + .or_else(|| str_attr(attrs, "display_name")) + .unwrap_or_default(); + + EntitySummary { + entity_ref: format!("{kind}:{name}"), + name, + kind: kind.to_string(), + description: str_attr(attrs, "description"), + lifecycle: str_attr(attrs, "lifecycle"), + tier: str_attr(attrs, "tier"), + owner: str_attr(attrs, "owner"), + definition_github_url: str_attr(attrs, "definition_github_url"), + } +} + +fn extract_contacts(attrs: &serde_json::Value) -> Vec { + attrs + .get("contacts") + .and_then(|c| c.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|c| { + Some(ContactEntry { + name: str_attr(c, "name")?, + contact_type: str_attr(c, "type")?, + contact: str_attr(c, "contact")?, + }) + }) + .collect() + }) + .unwrap_or_default() +} + +fn extract_owner( + included: &serde_json::Value, + entity_contacts: Vec, +) -> Option { + let arr = included.as_array()?; + let team = arr + .iter() + .find(|inc| inc.get("type").and_then(|t| t.as_str()) == Some("team"))?; + let attrs = &team["attributes"]; + Some(OwnerInfo { + team_handle: str_attr(attrs, "handle").unwrap_or_default(), + team_name: str_attr(attrs, "name"), + description: str_attr(attrs, "summary").or_else(|| str_attr(attrs, "description")), + member_count: i64_attr(attrs, "user_count"), + contacts: entity_contacts, + }) +} + +/// Extract team ID from the entity graph `included` array. +fn extract_team_id(included: &serde_json::Value) -> Option { + let arr = included.as_array()?; + let team = arr + .iter() + .find(|inc| inc.get("type").and_then(|t| t.as_str()) == Some("team"))?; + str_attr(&team["attributes"], "id") +} + +/// Fetch on-call responders from the on-call API for a given team ID. +async fn fetch_on_call(cfg: &Config, team_id: &str) -> Option { + let path = format!( + "/api/v2/on-call/teams/{team_id}/on-call?include=responders,escalations.responders" + ); + let data = client::raw_get(cfg, &path, &[]).await.ok()?; + let included = data.get("included")?.as_array()?; + + // Primary responders come from data.relationships.responders + let primary_ids: Vec = data + .get("data") + .and_then(|d| d.get("relationships")) + .and_then(|r| r.get("responders")) + .and_then(|r| r.get("data")) + .and_then(|d| d.as_array()) + .map(|arr| arr.iter().filter_map(|r| str_attr(r, "id")).collect()) + .unwrap_or_default(); + + // Escalation responders (secondary, tertiary, etc.) + let escalation_ids: Vec = data + .get("data") + .and_then(|d| d.get("relationships")) + .and_then(|r| r.get("escalations")) + .and_then(|r| r.get("data")) + .and_then(|d| d.as_array()) + .into_iter() + .flatten() + .filter_map(|step| { + let step_id = str_attr(step, "id")?; + // Find this step in included to get its responders + included.iter().find_map(|inc| { + if str_attr(inc, "id")? == step_id { + inc.get("relationships")? + .get("responders")? + .get("data")? + .as_array() + .map(|arr| { + arr.iter() + .filter_map(|r| str_attr(r, "id")) + .collect::>() + }) + } else { + None + } + }) + }) + .flatten() + .collect(); + + // Resolve user IDs to names/emails from included users + let users: Vec<&serde_json::Value> = included + .iter() + .filter(|inc| inc.get("type").and_then(|t| t.as_str()) == Some("users")) + .collect(); + + let mut responders = Vec::new(); + + // Primary responders + for uid in &primary_ids { + if let Some(user) = users + .iter() + .find(|u| str_attr(u, "id").as_deref() == Some(uid)) + { + let attrs = &user["attributes"]; + responders.push(OnCallResponder { + name: str_attr(attrs, "name").unwrap_or_default(), + email: str_attr(attrs, "email").unwrap_or_default(), + level: Some("primary".to_string()), + }); + } + } + + // Escalation responders (secondary) + for uid in &escalation_ids { + if primary_ids.contains(uid) { + continue; // already listed as primary + } + if let Some(user) = users + .iter() + .find(|u| str_attr(u, "id").as_deref() == Some(uid)) + { + let attrs = &user["attributes"]; + responders.push(OnCallResponder { + name: str_attr(attrs, "name").unwrap_or_default(), + email: str_attr(attrs, "email").unwrap_or_default(), + level: Some("escalation".to_string()), + }); + } + } + + if responders.is_empty() { + None + } else { + Some(OnCallInfo { responders }) + } +} + +fn extract_health(attrs: &serde_json::Value) -> HealthSummary { + let status = str_attr(attrs, "service_health_status").unwrap_or_else(|| "unknown".to_string()); + HealthSummary { + status, + monitors: MonitorCounts { + ok: i64_attr(attrs, "ok_monitors_count"), + alert: i64_attr(attrs, "alert_monitors_count"), + warn: i64_attr(attrs, "warning_monitors_count"), + no_data: i64_attr(attrs, "no_data_monitors_count"), + }, + incidents: IncidentCounts { + active: i64_attr(attrs, "active_incidents_count"), + stable: i64_attr(attrs, "stable_incidents_count"), + }, + slos: SloCounts { + ok: i64_attr(attrs, "ok_slos_count"), + breached: i64_attr(attrs, "breached_slos_count"), + warning: i64_attr(attrs, "warning_slos_count"), + no_data: i64_attr(attrs, "no_data_slos_count"), + }, + } +} + +fn extract_links(attrs: &serde_json::Value) -> Vec { + attrs + .get("links") + .and_then(|l| l.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|link| { + Some(LinkEntry { + name: str_attr(link, "name")?, + link_type: str_attr(link, "type")?, + url: str_attr(link, "url")?, + }) + }) + .collect() + }) + .unwrap_or_default() +} + +fn has_link_type(links: &[LinkEntry], link_type: &str) -> bool { + links.iter().any(|l| l.link_type == link_type) +} + +fn compute_metadata_gaps( + entity: &EntitySummary, + _attrs: &serde_json::Value, + links: &[LinkEntry], +) -> Vec { + let mut gaps = Vec::new(); + if entity.description.is_none() { + gaps.push("description not set".into()); + } + if entity.lifecycle.is_none() { + gaps.push("lifecycle not set".into()); + } + if entity.tier.is_none() { + gaps.push("tier not set".into()); + } + if !has_link_type(links, "runbook") { + gaps.push("no runbook link".into()); + } + if !has_link_type(links, "doc") && !has_link_type(links, "docs") { + gaps.push("no documentation link".into()); + } + gaps +} + +fn compute_next_actions(entity_name: &str, health: &HealthSummary, gaps: &[String]) -> Vec { + let mut actions = Vec::new(); + + if health.monitors.alert > 0 || health.incidents.active > 0 { + actions.push(format!( + "Investigate active alerts: `pup monitors list --tag=\"service:{entity_name}\"`" + )); + } + if health.slos.breached > 0 { + actions.push(format!( + "Review breached SLOs: `pup slos list` and filter for {entity_name}" + )); + } + if !gaps.is_empty() { + let gap_list = gaps.iter().take(3).cloned().collect::>().join(", "); + actions.push(format!("Fill metadata gaps: {gap_list}")); + } + actions.push(format!("View dependencies: `pup idp deps {entity_name}`")); + actions.push(format!("Get owner details: `pup idp owner {entity_name}`")); + + actions +} + +// --------------------------------------------------------------------------- +// Parse dependencies from /api/v1/service_dependencies response +// Format: { "service_name": { "calls": ["dep1", "dep2"] }, ... } +// --------------------------------------------------------------------------- + +fn parse_dependencies(deps_data: &serde_json::Value, entity: &str) -> (Vec, Vec) { + let mut upstream = Vec::new(); + let mut downstream = Vec::new(); + + if let Some(deps_map) = deps_data.as_object() { + // Downstream: services this entity calls + if let Some(calls) = deps_map + .get(entity) + .and_then(|v| v.get("calls")) + .and_then(|v| v.as_array()) + { + downstream = calls + .iter() + .filter_map(|v| v.as_str().map(String::from)) + .collect(); + } + // Upstream: services that call this entity + for (svc, entry) in deps_map { + if svc == entity { + continue; + } + if let Some(calls) = entry.get("calls").and_then(|v| v.as_array()) { + if calls.iter().any(|d| d.as_str() == Some(entity)) { + upstream.push(svc.clone()); + } + } + } + } + + (upstream, downstream) +} + +// --------------------------------------------------------------------------- +// Build the UEG query URL for a service entity by name +// --------------------------------------------------------------------------- + +fn entity_query_url(entity: &str, include: &str) -> String { + let query = util::percent_encode(&format!("kind:service AND name:{entity}")); + let mut url = format!("/api/v2/idp/entity_graph/entities?query={query}&page%5Blimit%5D=1"); + if !include.is_empty() { + url.push_str(&format!("&include={include}")); + } + url +} + +// --------------------------------------------------------------------------- +// Commands +// --------------------------------------------------------------------------- + +/// Flagship command: returns concise entity context + suggested next actions. +pub async fn assist(cfg: &Config, entity: &str) -> Result<()> { + // Fan out: entity graph + dependencies in parallel + let entity_path = entity_query_url(entity, "owner_teams"); + let deps_path = "/api/v1/service_dependencies?env=prod"; + + let (entity_res, deps_res) = tokio::join!( + client::raw_get(cfg, &entity_path, &[]), + client::raw_get(cfg, deps_path, &[]), + ); + + let entity_data = entity_res?; + + // Parse entity from UEG response (JSON:API format: { data: [...], included: [...] }) + let entities = entity_data + .get("data") + .and_then(|d| d.as_array()) + .ok_or_else(|| anyhow::anyhow!("no entities found matching '{entity}'"))?; + + if entities.is_empty() { + anyhow::bail!("no entity found matching '{entity}'"); + } + + let primary = &entities[0]; + let attrs = &primary["attributes"]; + let included = entity_data + .get("included") + .cloned() + .unwrap_or(serde_json::json!([])); + + let summary = extract_entity_summary(primary); + let contacts = extract_contacts(attrs); + let owner = extract_owner(&included, contacts); + let health = extract_health(attrs); + + // Fetch on-call using team ID from the entity graph response + let on_call = match extract_team_id(&included) { + Some(team_id) => fetch_on_call(cfg, &team_id).await, + None => None, + }; + let links = extract_links(attrs); + let gaps = compute_metadata_gaps(&summary, attrs, &links); + let next_actions = compute_next_actions(&summary.name, &health, &gaps); + + // Parse dependencies + let (upstream, downstream) = match deps_res { + Ok(ref deps_data) => parse_dependencies(deps_data, entity), + Err(_) => (vec![], vec![]), + }; + + let response = AssistResponse { + entity: summary, + owner, + on_call, + health, + dependencies: DependencySummary { + upstream, + downstream, + }, + metadata_gaps: gaps, + links, + suggested_next_actions: next_actions, + }; + + let meta = formatter::Metadata { + count: Some(1), + truncated: false, + command: Some(format!("idp assist {entity}")), + next_action: Some(format!( + "Use `pup idp owner {entity}` for full ownership details, or `pup idp deps {entity}` for dependency graph" + )), + }; + + formatter::format_and_print(&response, &cfg.output_format, cfg.agent_mode, Some(&meta)) +} + +/// Find entities matching a query. +pub async fn find(cfg: &Config, query: &str) -> Result<()> { + // The UEG API requires kind in the query. If the user didn't specify one, default to service. + let full_query = if query.contains("kind:") { + query.to_string() + } else { + format!("kind:service AND name:*{query}*") + }; + let encoded = util::percent_encode(&full_query); + let path = format!("/api/v2/idp/entity_graph/entities?query={encoded}&page%5Blimit%5D=10"); + let data = client::raw_get(cfg, &path, &[]).await?; + + let meta = formatter::Metadata { + count: data.get("data").and_then(|d| d.as_array()).map(|a| a.len()), + truncated: false, + command: Some(format!("idp find {query}")), + next_action: Some( + "Use `pup idp assist ` for full context on a specific entity".into(), + ), + }; + + formatter::format_and_print(&data, &cfg.output_format, cfg.agent_mode, Some(&meta)) +} + +/// Resolve owner, team, and on-call context for an entity. +pub async fn owner(cfg: &Config, entity: &str) -> Result<()> { + let path = entity_query_url(entity, "owner_teams"); + let data = client::raw_get(cfg, &path, &[]).await?; + + let entities = data + .get("data") + .and_then(|d| d.as_array()) + .ok_or_else(|| anyhow::anyhow!("no entities found matching '{entity}'"))?; + + if entities.is_empty() { + anyhow::bail!("no entity found matching '{entity}'"); + } + + let primary = &entities[0]; + let included = data + .get("included") + .cloned() + .unwrap_or(serde_json::json!([])); + let contacts = extract_contacts(&primary["attributes"]); + let owner_info = extract_owner(&included, contacts); + let on_call = match extract_team_id(&included) { + Some(team_id) => fetch_on_call(cfg, &team_id).await, + None => None, + }; + + let mut response = serde_json::json!({ + "entity": entity, + }); + if let Some(o) = &owner_info { + response["owner"] = serde_json::to_value(o)?; + } + if let Some(oc) = &on_call { + response["on_call"] = serde_json::to_value(oc)?; + } + + let meta = formatter::Metadata { + count: Some(1), + truncated: false, + command: Some(format!("idp owner {entity}")), + next_action: None, + }; + + formatter::format_and_print(&response, &cfg.output_format, cfg.agent_mode, Some(&meta)) +} + +/// Show dependency and relationship context for an entity. +pub async fn deps(cfg: &Config, entity: &str) -> Result<()> { + let deps_path = "/api/v1/service_dependencies?env=prod"; + let deps_data = client::raw_get(cfg, deps_path, &[]).await?; + let (upstream, downstream) = parse_dependencies(&deps_data, entity); + + let response = serde_json::json!({ + "entity": entity, + "dependencies": { + "upstream": upstream, + "downstream": downstream, + } + }); + + let meta = formatter::Metadata { + count: Some(upstream.len() + downstream.len()), + truncated: false, + command: Some(format!("idp deps {entity}")), + next_action: Some("Use `pup idp assist ` to inspect any dependency".to_string()), + }; + + formatter::format_and_print(&response, &cfg.output_format, cfg.agent_mode, Some(&meta)) +} + +/// Register a service definition from a YAML file. +pub async fn register(cfg: &Config, file: &str) -> Result<()> { + let content = + std::fs::read_to_string(file).map_err(|e| anyhow::anyhow!("failed to read {file}: {e}"))?; + + // Parse YAML to JSON for the API + let yaml_value: serde_json::Value = serde_norway::from_str(&content) + .map_err(|e| anyhow::anyhow!("failed to parse YAML in {file}: {e}"))?; + + let data = client::raw_post(cfg, "/api/v2/services/definitions", yaml_value).await?; + + let service_name = content + .lines() + .find(|l| l.starts_with("dd-service:")) + .and_then(|l| l.strip_prefix("dd-service:")) + .map(|s| s.trim().to_string()) + .unwrap_or_else(|| file.to_string()); + + let meta = formatter::Metadata { + count: Some(1), + truncated: false, + command: Some(format!("idp register {file}")), + next_action: Some(format!( + "Use `pup idp assist {service_name}` to verify the registered service" + )), + }; + + formatter::format_and_print(&data, &cfg.output_format, cfg.agent_mode, Some(&meta)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_entity_query_url_encodes_special_chars() { + // Colons, spaces, and other characters in entity names and the query + // syntax must be percent-encoded so the URL is well-formed. + let url = entity_query_url("my service", ""); + assert!( + url.contains("kind%3Aservice"), + "colon should be encoded: {url}" + ); + assert!( + url.contains("my%20service"), + "space should be encoded: {url}" + ); + assert!(!url.contains("include="), "empty include should be omitted"); + } + + #[test] + fn test_entity_query_url_appends_include() { + let url = entity_query_url("svc", "owner_teams"); + assert!( + url.contains("&include=owner_teams"), + "include param missing: {url}" + ); + } +} diff --git a/src/main.rs b/src/main.rs index 0e2d7db..fcc03e9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1404,6 +1404,7 @@ enum Commands { /// • Resolve ownership and on-call (owner) /// • Show upstream/downstream dependencies (deps) /// • Register a service definition from YAML (register) + /// • Migrate service catalog YAML to v3 schema (migrate-schema) /// /// EXAMPLES: /// # Get full context for a service @@ -1421,6 +1422,9 @@ enum Commands { /// # Register a service definition /// pup idp register service.datadog.yaml /// + /// # Migrate a catalog file to v3 + /// pup idp migrate-schema service.datadog.yaml + /// /// AUTHENTICATION: /// Requires either OAuth2 authentication (pup auth login) or API keys /// (DD_API_KEY and DD_APP_KEY environment variables). @@ -4492,6 +4496,24 @@ enum IdpActions { /// Path to the service.datadog.yaml file file: String, }, + /// Migrate a service catalog YAML file to v3 schema + /// + /// Detects the current schema version (v1, v2, v2.1, v2.2) and converts + /// it to v3 format. Tries the Datadog convert API first; falls back to + /// local migration if the API fails or rejects the input. + /// + /// After migration, validates the output against the official v3 JSON schemas + /// fetched from GitHub, then prompts where to write the result. + /// + /// EXAMPLES: + /// pup idp migrate-schema service.datadog.yaml + /// pup idp migrate-schema ./catalog/checkout-api.yaml + /// pup idp migrate-schema + #[command(verbatim_doc_comment)] + MigrateSchema { + /// Path to the YAML file (optional — auto-discovers *.datadog.yaml if omitted) + file: Option, + }, } // ---- Audit Logs ---- @@ -11843,26 +11865,31 @@ async fn main_inner() -> anyhow::Result<()> { } } // --- IDP (Internal Developer Portal) --- - Commands::Idp { action } => { - cfg.validate_auth()?; - match action { - IdpActions::Assist { entity } => { - commands::idp::assist(&cfg, &entity).await?; - } - IdpActions::Find { query } => { - commands::idp::find(&cfg, &query).await?; - } - IdpActions::Owner { entity } => { - commands::idp::owner(&cfg, &entity).await?; - } - IdpActions::Deps { entity } => { - commands::idp::deps(&cfg, &entity).await?; - } - IdpActions::Register { file } => { - commands::idp::register(&cfg, &file).await?; - } + Commands::Idp { action } => match action { + IdpActions::Assist { entity } => { + cfg.validate_auth()?; + commands::idp::assist(&cfg, &entity).await?; } - } + IdpActions::Find { query } => { + cfg.validate_auth()?; + commands::idp::find(&cfg, &query).await?; + } + IdpActions::Owner { entity } => { + cfg.validate_auth()?; + commands::idp::owner(&cfg, &entity).await?; + } + IdpActions::Deps { entity } => { + cfg.validate_auth()?; + commands::idp::deps(&cfg, &entity).await?; + } + IdpActions::Register { file } => { + cfg.validate_auth()?; + commands::idp::register(&cfg, &file).await?; + } + IdpActions::MigrateSchema { file } => { + commands::idp::migrate_schema(&cfg, file).await?; + } + }, // --- Audit Logs --- Commands::AuditLogs { action } => { cfg.validate_auth()?;