Skip to main content

rigg_core/
registry.rs

1//! Declarative per-kind metadata registry.
2//!
3//! This is the single place that encodes what Rigg knows *about* each resource
4//! kind: where it lives in the API, which api-version channel it needs, which
5//! fields are volatile / read-only / secret, how it references other
6//! resources, and which values are valid per channel. Resources themselves
7//! remain schema-light `serde_json::Value` passthrough documents.
8//!
9//! When Azure ships a new API version, updating Rigg should mostly mean
10//! editing this file (`rigg dev api-check` watches for that).
11
12use serde_json::Value;
13
14use crate::resources::traits::ResourceKind;
15
16/// Default data-plane api-versions. Overridable per connection in `rigg.yaml`.
17pub const SEARCH_STABLE_API_VERSION: &str = "2026-04-01";
18pub const SEARCH_PREVIEW_API_VERSION: &str = "2026-05-01-preview";
19pub const FOUNDRY_API_VERSION: &str = "v1";
20/// ARM api-version for Microsoft.CognitiveServices (deployments, connections, RAI policies).
21pub const ARM_COGNITIVE_API_VERSION: &str = "2026-05-01";
22
23/// Which service/plane a kind is managed through.
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub enum Domain {
26    /// Azure AI Search data plane.
27    Search,
28    /// Microsoft Foundry project data plane (`api-version=v1`).
29    FoundryData,
30    /// ARM control plane under Microsoft.CognitiveServices.
31    FoundryArm,
32}
33
34/// API version channel a kind (or capability) requires.
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub enum Channel {
37    Stable,
38    Preview,
39}
40
41/// A reference-bearing field: `path` addresses a string (or array of strings)
42/// naming resources of kind `to`.
43///
44/// Path syntax: dot-separated keys; `[]` after a key descends into each array
45/// element. Examples: `"dataSourceName"`, `"knowledgeSources[].name"`,
46/// `"indexes[].name"`.
47#[derive(Debug, Clone, Copy)]
48pub struct RefField {
49    pub path: &'static str,
50    pub to: ResourceKind,
51}
52
53/// Declarative metadata for one resource kind.
54#[derive(Debug, Clone, Copy)]
55pub struct KindMeta {
56    pub kind: ResourceKind,
57    pub domain: Domain,
58    /// API collection path (exact casing as the REST API expects).
59    pub collection_path: &'static str,
60    /// Directory name on disk, relative to the project's `search/` or `foundry/` dir.
61    pub dir_name: &'static str,
62    /// Minimum channel required for the kind itself.
63    pub channel: Channel,
64    /// Stripped on pull and ignored in diff (dot paths, applied at any depth
65    /// for `@odata.*`; top-level otherwise).
66    pub volatile_fields: &'static [&'static str],
67    /// Returned by GET but rejected by PUT — never written to files.
68    pub read_only_fields: &'static [&'static str],
69    /// Paths that may carry key material — validation rejects files where
70    /// these contain anything but identity-based placeholders.
71    pub secret_fields: &'static [&'static str],
72    /// String fields extracted to Markdown sidecars on pull by default.
73    pub sidecar_fields: &'static [&'static str],
74    /// How this kind references other resources.
75    pub reference_fields: &'static [RefField],
76}
77
78const COMMON_VOLATILE: &[&str] = &["@odata.etag", "@odata.context", "e_tag", "etag"];
79
80static KINDS: &[KindMeta] = &[
81    KindMeta {
82        kind: ResourceKind::DataSource,
83        domain: Domain::Search,
84        collection_path: "datasources",
85        dir_name: "data-sources",
86        channel: Channel::Stable,
87        volatile_fields: COMMON_VOLATILE,
88        read_only_fields: &[],
89        secret_fields: &["credentials.connectionString"],
90        sidecar_fields: &[],
91        reference_fields: &[],
92    },
93    KindMeta {
94        kind: ResourceKind::Index,
95        domain: Domain::Search,
96        collection_path: "indexes",
97        dir_name: "indexes",
98        channel: Channel::Stable,
99        volatile_fields: COMMON_VOLATILE,
100        read_only_fields: &[],
101        secret_fields: &[
102            "encryptionKey.accessCredentials.applicationSecret",
103            "vectorSearch.vectorizers[].azureOpenAIParameters.apiKey",
104        ],
105        sidecar_fields: &[],
106        reference_fields: &[],
107    },
108    KindMeta {
109        kind: ResourceKind::Skillset,
110        domain: Domain::Search,
111        collection_path: "skillsets",
112        dir_name: "skillsets",
113        channel: Channel::Stable,
114        volatile_fields: COMMON_VOLATILE,
115        read_only_fields: &[],
116        secret_fields: &[
117            "cognitiveServices.key",
118            "skills[].apiKey",
119            "encryptionKey.accessCredentials.applicationSecret",
120        ],
121        sidecar_fields: &[],
122        reference_fields: &[
123            // SearchIndexKnowledgeStore / index projections target the index by name.
124            RefField {
125                path: "knowledgeStore.projections[].objects[].storageContainer",
126                to: ResourceKind::Index,
127            },
128        ],
129    },
130    KindMeta {
131        kind: ResourceKind::Indexer,
132        domain: Domain::Search,
133        collection_path: "indexers",
134        dir_name: "indexers",
135        channel: Channel::Stable,
136        volatile_fields: COMMON_VOLATILE,
137        read_only_fields: &["status", "lastResult", "executionHistory", "limits"],
138        secret_fields: &[],
139        sidecar_fields: &[],
140        reference_fields: &[
141            RefField {
142                path: "dataSourceName",
143                to: ResourceKind::DataSource,
144            },
145            RefField {
146                path: "targetIndexName",
147                to: ResourceKind::Index,
148            },
149            RefField {
150                path: "skillsetName",
151                to: ResourceKind::Skillset,
152            },
153        ],
154    },
155    KindMeta {
156        kind: ResourceKind::SynonymMap,
157        domain: Domain::Search,
158        collection_path: "synonymmaps",
159        dir_name: "synonym-maps",
160        channel: Channel::Stable,
161        volatile_fields: COMMON_VOLATILE,
162        read_only_fields: &[],
163        secret_fields: &["encryptionKey.accessCredentials.applicationSecret"],
164        sidecar_fields: &[],
165        reference_fields: &[],
166    },
167    KindMeta {
168        kind: ResourceKind::Alias,
169        domain: Domain::Search,
170        collection_path: "aliases",
171        dir_name: "aliases",
172        channel: Channel::Stable,
173        volatile_fields: COMMON_VOLATILE,
174        read_only_fields: &[],
175        secret_fields: &[],
176        sidecar_fields: &[],
177        reference_fields: &[RefField {
178            path: "indexes[]",
179            to: ResourceKind::Index,
180        }],
181    },
182    KindMeta {
183        kind: ResourceKind::KnowledgeSource,
184        domain: Domain::Search,
185        collection_path: "knowledgeSources",
186        dir_name: "knowledge-sources",
187        channel: Channel::Stable,
188        volatile_fields: COMMON_VOLATILE,
189        // Explicit-only model: Rigg never manages Azure-created sub-resources.
190        read_only_fields: &["createdResources", "ingestionPermissionOptions"],
191        secret_fields: &["searchIndexParameters.apiKey"],
192        sidecar_fields: &[],
193        reference_fields: &[RefField {
194            path: "searchIndexParameters.searchIndexName",
195            to: ResourceKind::Index,
196        }],
197    },
198    KindMeta {
199        kind: ResourceKind::KnowledgeBase,
200        domain: Domain::Search,
201        collection_path: "knowledgeBases",
202        dir_name: "knowledge-bases",
203        channel: Channel::Stable,
204        volatile_fields: COMMON_VOLATILE,
205        read_only_fields: &[],
206        secret_fields: &["models[].apiKey", "models[].azureOpenAIParameters.apiKey"],
207        sidecar_fields: &[],
208        reference_fields: &[RefField {
209            path: "knowledgeSources[].name",
210            to: ResourceKind::KnowledgeSource,
211        }],
212    },
213    KindMeta {
214        kind: ResourceKind::Agent,
215        domain: Domain::FoundryData,
216        collection_path: "agents",
217        dir_name: "agents",
218        channel: Channel::Stable,
219        volatile_fields: &[
220            "@odata.etag",
221            "@odata.context",
222            "id",
223            "object",
224            "created_at",
225            "updated_at",
226            "version",
227        ],
228        read_only_fields: &[],
229        secret_fields: &[],
230        sidecar_fields: &["instructions"],
231        reference_fields: &[RefField {
232            path: "model",
233            to: ResourceKind::Deployment,
234        }],
235    },
236    KindMeta {
237        kind: ResourceKind::Deployment,
238        domain: Domain::FoundryArm,
239        collection_path: "deployments",
240        dir_name: "deployments",
241        channel: Channel::Stable,
242        volatile_fields: &[
243            "id",
244            "type",
245            "systemData",
246            "etag",
247            "properties.provisioningState",
248            "properties.capabilities",
249            "properties.rateLimits",
250            "properties.model.callRateLimit",
251        ],
252        read_only_fields: &[],
253        secret_fields: &[],
254        sidecar_fields: &[],
255        reference_fields: &[RefField {
256            path: "properties.raiPolicyName",
257            to: ResourceKind::Guardrail,
258        }],
259    },
260    KindMeta {
261        kind: ResourceKind::Connection,
262        domain: Domain::FoundryArm,
263        collection_path: "connections",
264        dir_name: "connections",
265        channel: Channel::Stable,
266        volatile_fields: &[
267            "id",
268            "type",
269            "systemData",
270            "etag",
271            "properties.provisioningState",
272        ],
273        read_only_fields: &[],
274        // Identity-based auth only — any credential payload is rejected.
275        secret_fields: &[
276            "properties.credentials.key",
277            "properties.credentials.keys",
278            "properties.credentials.secret",
279            "properties.credentials.clientSecret",
280            "properties.credentials.pat",
281            "properties.credentials.sas",
282        ],
283        sidecar_fields: &[],
284        reference_fields: &[],
285    },
286    KindMeta {
287        kind: ResourceKind::Guardrail,
288        domain: Domain::FoundryArm,
289        collection_path: "raiPolicies",
290        dir_name: "guardrails",
291        channel: Channel::Stable,
292        volatile_fields: &["id", "type", "systemData", "etag"],
293        read_only_fields: &[],
294        secret_fields: &[],
295        sidecar_fields: &[],
296        reference_fields: &[],
297    },
298];
299
300/// All kinds, in push-friendly declaration order.
301pub fn all_kinds() -> &'static [ResourceKind] {
302    static ORDER: &[ResourceKind] = &[
303        ResourceKind::DataSource,
304        ResourceKind::Index,
305        ResourceKind::Skillset,
306        ResourceKind::Indexer,
307        ResourceKind::SynonymMap,
308        ResourceKind::Alias,
309        ResourceKind::KnowledgeSource,
310        ResourceKind::KnowledgeBase,
311        ResourceKind::Agent,
312        ResourceKind::Deployment,
313        ResourceKind::Connection,
314        ResourceKind::Guardrail,
315    ];
316    ORDER
317}
318
319/// Metadata for a kind. Total over all kinds.
320pub fn meta(kind: ResourceKind) -> &'static KindMeta {
321    KINDS
322        .iter()
323        .find(|m| m.kind == kind)
324        .expect("registry entry exists for every ResourceKind")
325}
326
327/// Valid `type` strings for Azure AI Search data sources per channel.
328///
329/// Note Azure's own inconsistency: the stable reference spells Azure Files
330/// `azurefile`, the preview reference `azurefiles`. Both are accepted (and
331/// validation warns to double-check against the pinned api-version).
332pub fn valid_datasource_types(channel: Channel) -> &'static [&'static str] {
333    const GA: &[&str] = &[
334        "azureblob",
335        "adlsgen2",
336        "azuretable",
337        "azuresql",
338        "cosmosdb",
339        "onelake",
340    ];
341    const PREVIEW: &[&str] = &[
342        "azureblob",
343        "adlsgen2",
344        "azuretable",
345        "azuresql",
346        "cosmosdb",
347        "onelake",
348        "mysql",
349        "sharepoint",
350        "azurefile",
351        "azurefiles",
352    ];
353    match channel {
354        Channel::Stable => GA,
355        Channel::Preview => PREVIEW,
356    }
357}
358
359/// Data source types that are preview-only (or preview-spelled).
360pub fn preview_only_datasource_types() -> &'static [&'static str] {
361    &["mysql", "sharepoint", "azurefile", "azurefiles"]
362}
363
364/// The key used by Rigg-local cross-service references
365/// (e.g. an agent tool pointing at a knowledge base by name).
366pub const X_RIGG_REF: &str = "x-rigg-ref";
367/// The key linking a WebApiSkill to an OpenAPI spec in `apis/`.
368pub const X_RIGG_API: &str = "x-rigg-api";
369
370/// Extract all references from `body` per the kind's `reference_fields`,
371/// plus any `x-rigg-ref` values (`"<dir-name>/<name>"`) found at any depth.
372pub fn extract_references(kind: ResourceKind, body: &Value) -> Vec<(ResourceKind, String)> {
373    let mut out = Vec::new();
374    for rf in meta(kind).reference_fields {
375        collect_path(body, rf.path, &mut |v| {
376            if let Some(s) = v.as_str() {
377                if !s.is_empty() {
378                    out.push((rf.to, s.to_string()));
379                }
380            }
381        });
382    }
383    collect_x_rigg_refs(body, &mut out);
384    out.sort();
385    out.dedup();
386    out
387}
388
389fn collect_x_rigg_refs(v: &Value, out: &mut Vec<(ResourceKind, String)>) {
390    match v {
391        Value::Object(map) => {
392            for (k, val) in map {
393                if k == X_RIGG_REF {
394                    if let Some(s) = val.as_str() {
395                        if let Some((dir, name)) = s.split_once('/') {
396                            if let Some(kind) = ResourceKind::from_directory_name(dir) {
397                                out.push((kind, name.to_string()));
398                            }
399                        }
400                    }
401                } else {
402                    collect_x_rigg_refs(val, out);
403                }
404            }
405        }
406        Value::Array(arr) => {
407            for item in arr {
408                collect_x_rigg_refs(item, out);
409            }
410        }
411        _ => {}
412    }
413}
414
415/// Walk a registry path (`a.b`, `arr[].field`, `arr[]`) and invoke `f` on each
416/// matched terminal value.
417pub fn collect_path(v: &Value, path: &str, f: &mut dyn FnMut(&Value)) {
418    fn walk(v: &Value, segments: &[&str], f: &mut dyn FnMut(&Value)) {
419        let Some((head, rest)) = segments.split_first() else {
420            f(v);
421            return;
422        };
423        if let Some(key) = head.strip_suffix("[]") {
424            let target = if key.is_empty() { Some(v) } else { v.get(key) };
425            if let Some(Value::Array(arr)) = target {
426                for item in arr {
427                    walk(item, rest, f);
428                }
429            }
430        } else if let Some(next) = v.get(*head) {
431            walk(next, rest, f);
432        }
433    }
434    let segments: Vec<&str> = path.split('.').collect();
435    walk(v, &segments, f);
436}
437
438#[cfg(test)]
439mod tests {
440    use super::*;
441    use serde_json::json;
442
443    #[test]
444    fn meta_is_total_and_consistent() {
445        for kind in all_kinds() {
446            let m = meta(*kind);
447            assert_eq!(m.kind, *kind);
448            assert!(!m.collection_path.is_empty());
449            assert!(!m.dir_name.is_empty());
450        }
451        assert_eq!(all_kinds().len(), 12);
452    }
453
454    #[test]
455    fn dir_names_unique() {
456        let mut dirs: Vec<_> = all_kinds().iter().map(|k| meta(*k).dir_name).collect();
457        dirs.sort();
458        dirs.dedup();
459        assert_eq!(dirs.len(), 12);
460    }
461
462    #[test]
463    fn indexer_references() {
464        let indexer = json!({
465            "name": "idxr",
466            "dataSourceName": "my-ds",
467            "targetIndexName": "my-index",
468            "skillsetName": "my-skills"
469        });
470        let refs = extract_references(ResourceKind::Indexer, &indexer);
471        assert!(refs.contains(&(ResourceKind::DataSource, "my-ds".into())));
472        assert!(refs.contains(&(ResourceKind::Index, "my-index".into())));
473        assert!(refs.contains(&(ResourceKind::Skillset, "my-skills".into())));
474    }
475
476    #[test]
477    fn knowledge_base_and_alias_references() {
478        let kb = json!({
479            "name": "kb",
480            "knowledgeSources": [{"name": "ks-a"}, {"name": "ks-b"}]
481        });
482        let refs = extract_references(ResourceKind::KnowledgeBase, &kb);
483        assert_eq!(
484            refs,
485            vec![
486                (ResourceKind::KnowledgeSource, "ks-a".to_string()),
487                (ResourceKind::KnowledgeSource, "ks-b".to_string()),
488            ]
489        );
490
491        let alias = json!({"name": "a", "indexes": ["i1"]});
492        let refs = extract_references(ResourceKind::Alias, &alias);
493        assert_eq!(refs, vec![(ResourceKind::Index, "i1".to_string())]);
494    }
495
496    #[test]
497    fn x_rigg_ref_extracted_at_depth() {
498        let agent = json!({
499            "name": "agent",
500            "model": "gpt-5-mini",
501            "tools": [
502                {"type": "mcp", "x-rigg-ref": "knowledge-bases/support-kb", "server_url": ""}
503            ]
504        });
505        let refs = extract_references(ResourceKind::Agent, &agent);
506        assert!(refs.contains(&(ResourceKind::KnowledgeBase, "support-kb".into())));
507        assert!(refs.contains(&(ResourceKind::Deployment, "gpt-5-mini".into())));
508    }
509
510    #[test]
511    fn datasource_types_per_channel() {
512        assert!(valid_datasource_types(Channel::Stable).contains(&"cosmosdb"));
513        assert!(valid_datasource_types(Channel::Stable).contains(&"onelake"));
514        assert!(!valid_datasource_types(Channel::Stable).contains(&"sharepoint"));
515        assert!(valid_datasource_types(Channel::Preview).contains(&"sharepoint"));
516        // Azure's own spelling inconsistency: both accepted in preview.
517        assert!(valid_datasource_types(Channel::Preview).contains(&"azurefile"));
518        assert!(valid_datasource_types(Channel::Preview).contains(&"azurefiles"));
519    }
520
521    #[test]
522    fn ks_points_at_index() {
523        let ks = json!({
524            "name": "ks",
525            "kind": "searchIndex",
526            "searchIndexParameters": {"searchIndexName": "docs"}
527        });
528        let refs = extract_references(ResourceKind::KnowledgeSource, &ks);
529        assert_eq!(refs, vec![(ResourceKind::Index, "docs".to_string())]);
530    }
531}