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    /// Fields the server accepts on PUT but never returns on GET (redacted).
73    /// Kept in local files, sent on push, excluded from comparisons.
74    pub write_only_fields: &'static [&'static str],
75    /// String fields extracted to Markdown sidecars on pull by default.
76    pub sidecar_fields: &'static [&'static str],
77    /// How this kind references other resources.
78    pub reference_fields: &'static [RefField],
79}
80
81const COMMON_VOLATILE: &[&str] = &["@odata.etag", "@odata.context", "e_tag", "etag"];
82
83static KINDS: &[KindMeta] = &[
84    KindMeta {
85        kind: ResourceKind::DataSource,
86        domain: Domain::Search,
87        collection_path: "datasources",
88        dir_name: "data-sources",
89        channel: Channel::Stable,
90        volatile_fields: COMMON_VOLATILE,
91        read_only_fields: &[],
92        secret_fields: &["credentials.connectionString"],
93        write_only_fields: &["credentials.connectionString"],
94        sidecar_fields: &[],
95        reference_fields: &[],
96    },
97    KindMeta {
98        kind: ResourceKind::Index,
99        domain: Domain::Search,
100        collection_path: "indexes",
101        dir_name: "indexes",
102        channel: Channel::Stable,
103        volatile_fields: COMMON_VOLATILE,
104        read_only_fields: &[],
105        secret_fields: &[
106            "encryptionKey.accessCredentials.applicationSecret",
107            "vectorSearch.vectorizers[].azureOpenAIParameters.apiKey",
108        ],
109        write_only_fields: &[],
110        sidecar_fields: &[],
111        reference_fields: &[],
112    },
113    KindMeta {
114        kind: ResourceKind::Skillset,
115        domain: Domain::Search,
116        collection_path: "skillsets",
117        dir_name: "skillsets",
118        channel: Channel::Stable,
119        volatile_fields: COMMON_VOLATILE,
120        read_only_fields: &[],
121        secret_fields: &[
122            "cognitiveServices.key",
123            "skills[].apiKey",
124            "encryptionKey.accessCredentials.applicationSecret",
125        ],
126        write_only_fields: &[],
127        sidecar_fields: &[],
128        reference_fields: &[
129            // SearchIndexKnowledgeStore / index projections target the index by name.
130            RefField {
131                path: "knowledgeStore.projections[].objects[].storageContainer",
132                to: ResourceKind::Index,
133            },
134        ],
135    },
136    KindMeta {
137        kind: ResourceKind::Indexer,
138        domain: Domain::Search,
139        collection_path: "indexers",
140        dir_name: "indexers",
141        channel: Channel::Stable,
142        volatile_fields: COMMON_VOLATILE,
143        read_only_fields: &["status", "lastResult", "executionHistory", "limits"],
144        secret_fields: &[],
145        write_only_fields: &[],
146        sidecar_fields: &[],
147        reference_fields: &[
148            RefField {
149                path: "dataSourceName",
150                to: ResourceKind::DataSource,
151            },
152            RefField {
153                path: "targetIndexName",
154                to: ResourceKind::Index,
155            },
156            RefField {
157                path: "skillsetName",
158                to: ResourceKind::Skillset,
159            },
160        ],
161    },
162    KindMeta {
163        kind: ResourceKind::SynonymMap,
164        domain: Domain::Search,
165        collection_path: "synonymmaps",
166        dir_name: "synonym-maps",
167        channel: Channel::Stable,
168        volatile_fields: COMMON_VOLATILE,
169        read_only_fields: &[],
170        secret_fields: &["encryptionKey.accessCredentials.applicationSecret"],
171        write_only_fields: &[],
172        sidecar_fields: &[],
173        reference_fields: &[],
174    },
175    KindMeta {
176        kind: ResourceKind::Alias,
177        domain: Domain::Search,
178        collection_path: "aliases",
179        dir_name: "aliases",
180        channel: Channel::Stable,
181        volatile_fields: COMMON_VOLATILE,
182        read_only_fields: &[],
183        secret_fields: &[],
184        write_only_fields: &[],
185        sidecar_fields: &[],
186        reference_fields: &[RefField {
187            path: "indexes[]",
188            to: ResourceKind::Index,
189        }],
190    },
191    KindMeta {
192        kind: ResourceKind::KnowledgeSource,
193        domain: Domain::Search,
194        collection_path: "knowledgeSources",
195        dir_name: "knowledge-sources",
196        channel: Channel::Stable,
197        volatile_fields: COMMON_VOLATILE,
198        // Explicit-only model: Rigg never manages Azure-created sub-resources.
199        read_only_fields: &["createdResources", "ingestionPermissionOptions"],
200        secret_fields: &["searchIndexParameters.apiKey"],
201        write_only_fields: &[],
202        sidecar_fields: &[],
203        reference_fields: &[RefField {
204            path: "searchIndexParameters.searchIndexName",
205            to: ResourceKind::Index,
206        }],
207    },
208    KindMeta {
209        kind: ResourceKind::KnowledgeBase,
210        domain: Domain::Search,
211        collection_path: "knowledgeBases",
212        dir_name: "knowledge-bases",
213        channel: Channel::Stable,
214        volatile_fields: COMMON_VOLATILE,
215        read_only_fields: &[],
216        secret_fields: &["models[].apiKey", "models[].azureOpenAIParameters.apiKey"],
217        write_only_fields: &[],
218        sidecar_fields: &[],
219        reference_fields: &[RefField {
220            path: "knowledgeSources[].name",
221            to: ResourceKind::KnowledgeSource,
222        }],
223    },
224    KindMeta {
225        kind: ResourceKind::Agent,
226        domain: Domain::FoundryData,
227        collection_path: "agents",
228        dir_name: "agents",
229        channel: Channel::Stable,
230        volatile_fields: &[
231            "@odata.etag",
232            "@odata.context",
233            "id",
234            "object",
235            "created_at",
236            "updated_at",
237            "version",
238        ],
239        read_only_fields: &[],
240        secret_fields: &[],
241        write_only_fields: &[],
242        sidecar_fields: &["instructions"],
243        reference_fields: &[RefField {
244            path: "model",
245            to: ResourceKind::Deployment,
246        }],
247    },
248    KindMeta {
249        kind: ResourceKind::Deployment,
250        domain: Domain::FoundryArm,
251        collection_path: "deployments",
252        dir_name: "deployments",
253        channel: Channel::Stable,
254        volatile_fields: &[
255            "id",
256            "type",
257            "systemData",
258            "etag",
259            "properties.provisioningState",
260            "properties.capabilities",
261            "properties.rateLimits",
262            "properties.model.callRateLimit",
263        ],
264        read_only_fields: &[],
265        secret_fields: &[],
266        write_only_fields: &[],
267        sidecar_fields: &[],
268        reference_fields: &[RefField {
269            path: "properties.raiPolicyName",
270            to: ResourceKind::Guardrail,
271        }],
272    },
273    KindMeta {
274        kind: ResourceKind::Connection,
275        domain: Domain::FoundryArm,
276        collection_path: "connections",
277        dir_name: "connections",
278        channel: Channel::Stable,
279        volatile_fields: &[
280            "id",
281            "type",
282            "systemData",
283            "etag",
284            "properties.provisioningState",
285        ],
286        read_only_fields: &[],
287        // Identity-based auth only — any credential payload is rejected.
288        secret_fields: &[
289            "properties.credentials.key",
290            "properties.credentials.keys",
291            "properties.credentials.secret",
292            "properties.credentials.clientSecret",
293            "properties.credentials.pat",
294            "properties.credentials.sas",
295        ],
296        write_only_fields: &[],
297        sidecar_fields: &[],
298        reference_fields: &[],
299    },
300    KindMeta {
301        kind: ResourceKind::Guardrail,
302        domain: Domain::FoundryArm,
303        collection_path: "raiPolicies",
304        dir_name: "guardrails",
305        channel: Channel::Stable,
306        volatile_fields: &["id", "type", "systemData", "etag"],
307        read_only_fields: &[],
308        secret_fields: &[],
309        write_only_fields: &[],
310        sidecar_fields: &[],
311        reference_fields: &[],
312    },
313];
314
315/// All kinds, in push-friendly declaration order.
316pub fn all_kinds() -> &'static [ResourceKind] {
317    static ORDER: &[ResourceKind] = &[
318        ResourceKind::DataSource,
319        ResourceKind::Index,
320        ResourceKind::Skillset,
321        ResourceKind::Indexer,
322        ResourceKind::SynonymMap,
323        ResourceKind::Alias,
324        ResourceKind::KnowledgeSource,
325        ResourceKind::KnowledgeBase,
326        ResourceKind::Agent,
327        ResourceKind::Deployment,
328        ResourceKind::Connection,
329        ResourceKind::Guardrail,
330    ];
331    ORDER
332}
333
334/// Metadata for a kind. Total over all kinds.
335pub fn meta(kind: ResourceKind) -> &'static KindMeta {
336    KINDS
337        .iter()
338        .find(|m| m.kind == kind)
339        .expect("registry entry exists for every ResourceKind")
340}
341
342/// Valid `type` strings for Azure AI Search data sources per channel.
343///
344/// Note Azure's own inconsistency: the stable reference spells Azure Files
345/// `azurefile`, the preview reference `azurefiles`. Both are accepted (and
346/// validation warns to double-check against the pinned api-version).
347pub fn valid_datasource_types(channel: Channel) -> &'static [&'static str] {
348    const GA: &[&str] = &[
349        "azureblob",
350        "adlsgen2",
351        "azuretable",
352        "azuresql",
353        "cosmosdb",
354        "onelake",
355    ];
356    const PREVIEW: &[&str] = &[
357        "azureblob",
358        "adlsgen2",
359        "azuretable",
360        "azuresql",
361        "cosmosdb",
362        "onelake",
363        "mysql",
364        "sharepoint",
365        "azurefile",
366        "azurefiles",
367    ];
368    match channel {
369        Channel::Stable => GA,
370        Channel::Preview => PREVIEW,
371    }
372}
373
374/// Data source types that are preview-only (or preview-spelled).
375pub fn preview_only_datasource_types() -> &'static [&'static str] {
376    &["mysql", "sharepoint", "azurefile", "azurefiles"]
377}
378
379/// The key used by Rigg-local cross-service references
380/// (e.g. an agent tool pointing at a knowledge base by name).
381pub const X_RIGG_REF: &str = "x-rigg-ref";
382/// The key linking a WebApiSkill to an OpenAPI spec in `apis/`.
383pub const X_RIGG_API: &str = "x-rigg-api";
384
385/// Extract all references from `body` per the kind's `reference_fields`,
386/// plus any `x-rigg-ref` values (`"<dir-name>/<name>"`) found at any depth.
387pub fn extract_references(kind: ResourceKind, body: &Value) -> Vec<(ResourceKind, String)> {
388    let mut out = Vec::new();
389    for rf in meta(kind).reference_fields {
390        collect_path(body, rf.path, &mut |v| {
391            if let Some(s) = v.as_str() {
392                if !s.is_empty() {
393                    out.push((rf.to, s.to_string()));
394                }
395            }
396        });
397    }
398    collect_x_rigg_refs(body, &mut out);
399    out.sort();
400    out.dedup();
401    out
402}
403
404fn collect_x_rigg_refs(v: &Value, out: &mut Vec<(ResourceKind, String)>) {
405    match v {
406        Value::Object(map) => {
407            for (k, val) in map {
408                if k == X_RIGG_REF {
409                    if let Some(s) = val.as_str() {
410                        if let Some((dir, name)) = s.split_once('/') {
411                            if let Some(kind) = ResourceKind::from_directory_name(dir) {
412                                out.push((kind, name.to_string()));
413                            }
414                        }
415                    }
416                } else {
417                    collect_x_rigg_refs(val, out);
418                }
419            }
420        }
421        Value::Array(arr) => {
422            for item in arr {
423                collect_x_rigg_refs(item, out);
424            }
425        }
426        _ => {}
427    }
428}
429
430/// Walk a registry path (`a.b`, `arr[].field`, `arr[]`) and invoke `f` on each
431/// matched terminal value.
432pub fn collect_path(v: &Value, path: &str, f: &mut dyn FnMut(&Value)) {
433    fn walk(v: &Value, segments: &[&str], f: &mut dyn FnMut(&Value)) {
434        let Some((head, rest)) = segments.split_first() else {
435            f(v);
436            return;
437        };
438        if let Some(key) = head.strip_suffix("[]") {
439            let target = if key.is_empty() { Some(v) } else { v.get(key) };
440            if let Some(Value::Array(arr)) = target {
441                for item in arr {
442                    walk(item, rest, f);
443                }
444            }
445        } else if let Some(next) = v.get(*head) {
446            walk(next, rest, f);
447        }
448    }
449    let segments: Vec<&str> = path.split('.').collect();
450    walk(v, &segments, f);
451}
452
453#[cfg(test)]
454mod tests {
455    use super::*;
456    use serde_json::json;
457
458    #[test]
459    fn meta_is_total_and_consistent() {
460        for kind in all_kinds() {
461            let m = meta(*kind);
462            assert_eq!(m.kind, *kind);
463            assert!(!m.collection_path.is_empty());
464            assert!(!m.dir_name.is_empty());
465        }
466        assert_eq!(all_kinds().len(), 12);
467    }
468
469    #[test]
470    fn dir_names_unique() {
471        let mut dirs: Vec<_> = all_kinds().iter().map(|k| meta(*k).dir_name).collect();
472        dirs.sort();
473        dirs.dedup();
474        assert_eq!(dirs.len(), 12);
475    }
476
477    #[test]
478    fn indexer_references() {
479        let indexer = json!({
480            "name": "idxr",
481            "dataSourceName": "my-ds",
482            "targetIndexName": "my-index",
483            "skillsetName": "my-skills"
484        });
485        let refs = extract_references(ResourceKind::Indexer, &indexer);
486        assert!(refs.contains(&(ResourceKind::DataSource, "my-ds".into())));
487        assert!(refs.contains(&(ResourceKind::Index, "my-index".into())));
488        assert!(refs.contains(&(ResourceKind::Skillset, "my-skills".into())));
489    }
490
491    #[test]
492    fn knowledge_base_and_alias_references() {
493        let kb = json!({
494            "name": "kb",
495            "knowledgeSources": [{"name": "ks-a"}, {"name": "ks-b"}]
496        });
497        let refs = extract_references(ResourceKind::KnowledgeBase, &kb);
498        assert_eq!(
499            refs,
500            vec![
501                (ResourceKind::KnowledgeSource, "ks-a".to_string()),
502                (ResourceKind::KnowledgeSource, "ks-b".to_string()),
503            ]
504        );
505
506        let alias = json!({"name": "a", "indexes": ["i1"]});
507        let refs = extract_references(ResourceKind::Alias, &alias);
508        assert_eq!(refs, vec![(ResourceKind::Index, "i1".to_string())]);
509    }
510
511    #[test]
512    fn x_rigg_ref_extracted_at_depth() {
513        let agent = json!({
514            "name": "agent",
515            "model": "gpt-5-mini",
516            "tools": [
517                {"type": "mcp", "x-rigg-ref": "knowledge-bases/support-kb", "server_url": ""}
518            ]
519        });
520        let refs = extract_references(ResourceKind::Agent, &agent);
521        assert!(refs.contains(&(ResourceKind::KnowledgeBase, "support-kb".into())));
522        assert!(refs.contains(&(ResourceKind::Deployment, "gpt-5-mini".into())));
523    }
524
525    #[test]
526    fn datasource_types_per_channel() {
527        assert!(valid_datasource_types(Channel::Stable).contains(&"cosmosdb"));
528        assert!(valid_datasource_types(Channel::Stable).contains(&"onelake"));
529        assert!(!valid_datasource_types(Channel::Stable).contains(&"sharepoint"));
530        assert!(valid_datasource_types(Channel::Preview).contains(&"sharepoint"));
531        // Azure's own spelling inconsistency: both accepted in preview.
532        assert!(valid_datasource_types(Channel::Preview).contains(&"azurefile"));
533        assert!(valid_datasource_types(Channel::Preview).contains(&"azurefiles"));
534    }
535
536    #[test]
537    fn ks_points_at_index() {
538        let ks = json!({
539            "name": "ks",
540            "kind": "searchIndex",
541            "searchIndexParameters": {"searchIndexName": "docs"}
542        });
543        let refs = extract_references(ResourceKind::KnowledgeSource, &ks);
544        assert_eq!(refs, vec![(ResourceKind::Index, "docs".to_string())]);
545    }
546}