Skip to main content

greentic_operator/
static_routes.rs

1use std::collections::{BTreeMap, BTreeSet};
2use std::io::Read;
3use std::path::{Component, Path, PathBuf};
4
5use anyhow::Context;
6use greentic_types::{ExtensionInline, decode_pack_manifest};
7use serde::Deserialize;
8use zip::ZipArchive;
9
10use crate::domains::{self, Domain};
11
12pub const EXT_STATIC_ROUTES_V1: &str = "greentic.static-routes.v1";
13
14#[derive(Clone, Debug, PartialEq, Eq)]
15pub enum RouteScopeSegment {
16    Literal(String),
17    Tenant,
18    Team,
19}
20
21#[derive(Clone, Debug, PartialEq, Eq)]
22pub enum CacheStrategy {
23    None,
24    PublicMaxAge { max_age_seconds: u64 },
25}
26
27#[derive(Clone, Debug, PartialEq, Eq)]
28pub struct StaticRouteDescriptor {
29    pub route_id: String,
30    pub pack_id: String,
31    pub pack_path: PathBuf,
32    pub public_path: String,
33    pub source_root: String,
34    pub index_file: Option<String>,
35    pub spa_fallback: Option<String>,
36    pub tenant_scoped: bool,
37    pub team_scoped: bool,
38    pub cache_strategy: CacheStrategy,
39    pub route_segments: Vec<RouteScopeSegment>,
40}
41
42#[derive(Clone, Debug, Default, PartialEq, Eq)]
43pub struct StaticRoutePlan {
44    pub routes: Vec<StaticRouteDescriptor>,
45    pub warnings: Vec<String>,
46    pub blocking_failures: Vec<String>,
47}
48
49#[derive(Clone, Debug, Default, PartialEq, Eq)]
50pub struct ReservedRouteSet {
51    exact_paths: BTreeSet<String>,
52    prefix_paths: BTreeSet<String>,
53}
54
55impl ReservedRouteSet {
56    pub fn operator_defaults() -> Self {
57        let mut reserved = Self::default();
58        for path in [
59            "/healthz",
60            "/readyz",
61            "/status",
62            "/runtime/drain",
63            "/runtime/resume",
64            "/runtime/shutdown",
65            "/deployments/stage",
66            "/deployments/warm",
67            "/deployments/activate",
68            "/deployments/rollback",
69            "/deployments/complete-drain",
70            "/config/publish",
71            "/cache/invalidate",
72            "/observability/log-level",
73        ] {
74            reserved.insert_exact(path);
75        }
76        reserved.insert_prefix("/api/onboard");
77        reserved.insert_prefix("/runtime");
78        reserved.insert_prefix("/deployments");
79        reserved.insert_prefix("/config");
80        reserved.insert_prefix("/cache");
81        reserved.insert_prefix("/observability");
82        for domain in [
83            Domain::Messaging,
84            Domain::Events,
85            Domain::Secrets,
86            Domain::OAuth,
87        ] {
88            let name = domains::domain_name(domain);
89            reserved.insert_prefix(&format!("/v1/{name}/ingress"));
90            reserved.insert_prefix(&format!("/{name}/ingress"));
91        }
92        reserved
93    }
94
95    pub fn insert_exact(&mut self, path: &str) {
96        self.exact_paths.insert(normalize_public_path(path));
97    }
98
99    pub fn insert_prefix(&mut self, path: &str) {
100        self.prefix_paths.insert(normalize_public_path(path));
101    }
102
103    pub fn conflicts_with(&self, public_path: &str) -> bool {
104        let normalized = normalize_public_path(public_path);
105        self.exact_paths.contains(&normalized)
106            || self
107                .prefix_paths
108                .iter()
109                .any(|prefix| path_has_prefix(&normalized, prefix))
110    }
111}
112
113#[derive(Clone, Debug, PartialEq, Eq)]
114pub struct StaticRouteMatch<'a> {
115    pub descriptor: &'a StaticRouteDescriptor,
116    pub asset_path: String,
117    pub request_is_directory: bool,
118}
119
120#[derive(Clone, Debug, Default, PartialEq, Eq)]
121pub struct ActiveRouteTable {
122    routes: Vec<StaticRouteDescriptor>,
123}
124
125impl ActiveRouteTable {
126    pub fn from_plan(plan: &StaticRoutePlan) -> Self {
127        let mut routes = plan.routes.clone();
128        routes.sort_by(|a, b| {
129            b.route_segments
130                .len()
131                .cmp(&a.route_segments.len())
132                .then_with(|| a.public_path.cmp(&b.public_path))
133        });
134        Self { routes }
135    }
136
137    pub fn routes(&self) -> &[StaticRouteDescriptor] {
138        &self.routes
139    }
140
141    pub fn match_request<'a>(&'a self, request_path: &str) -> Option<StaticRouteMatch<'a>> {
142        let normalized = request_path
143            .trim_start_matches('/')
144            .split('/')
145            .filter(|segment| !segment.is_empty())
146            .collect::<Vec<_>>();
147        let request_is_directory = request_path.ends_with('/');
148        for descriptor in &self.routes {
149            if normalized.len() < descriptor.route_segments.len() {
150                continue;
151            }
152            let mut matched = true;
153            for (route_segment, request_segment) in
154                descriptor.route_segments.iter().zip(normalized.iter())
155            {
156                match route_segment {
157                    RouteScopeSegment::Literal(expected) if expected != request_segment => {
158                        matched = false;
159                        break;
160                    }
161                    RouteScopeSegment::Literal(_)
162                    | RouteScopeSegment::Tenant
163                    | RouteScopeSegment::Team => {}
164                }
165            }
166            if !matched {
167                continue;
168            }
169            let asset_path = normalized[descriptor.route_segments.len()..].join("/");
170            return Some(StaticRouteMatch {
171                descriptor,
172                asset_path,
173                request_is_directory,
174            });
175        }
176        None
177    }
178}
179
180#[derive(Debug, Deserialize)]
181struct StaticRoutesExtensionV1 {
182    #[serde(default = "default_schema_version")]
183    schema_version: u32,
184    #[serde(default)]
185    routes: Vec<StaticRouteRecord>,
186}
187
188#[derive(Clone, Debug, Deserialize)]
189struct StaticRouteRecord {
190    #[serde(default)]
191    id: Option<String>,
192    public_path: String,
193    source_root: String,
194    #[serde(default)]
195    index_file: Option<String>,
196    #[serde(default)]
197    spa_fallback: Option<String>,
198    #[serde(default)]
199    tenant: bool,
200    #[serde(default)]
201    team: bool,
202    #[serde(default)]
203    cache: Option<StaticRouteCacheRecord>,
204}
205
206#[derive(Clone, Debug, Deserialize)]
207struct StaticRouteCacheRecord {
208    strategy: String,
209    #[serde(default)]
210    max_age_seconds: Option<u64>,
211}
212
213fn default_schema_version() -> u32 {
214    1
215}
216
217pub fn discover_from_bundle(
218    bundle_root: &Path,
219    reserved_routes: &ReservedRouteSet,
220) -> anyhow::Result<StaticRoutePlan> {
221    let mut plan = StaticRoutePlan::default();
222    let pack_paths = collect_runtime_pack_paths(bundle_root)?;
223    for pack_path in pack_paths {
224        let descriptors = match read_pack_static_routes(&pack_path) {
225            Ok(Some(descriptors)) => descriptors,
226            Ok(None) => continue,
227            Err(err) => {
228                plan.blocking_failures.push(err.to_string());
229                continue;
230            }
231        };
232        plan.routes.extend(descriptors);
233    }
234    validate_plan(&mut plan, reserved_routes);
235    Ok(plan)
236}
237
238pub fn resolve_asset_path(route_match: &StaticRouteMatch<'_>) -> Option<String> {
239    if route_match.asset_path.is_empty() || route_match.request_is_directory {
240        return route_match.descriptor.index_file.clone();
241    }
242    Some(route_match.asset_path.clone())
243}
244
245pub fn fallback_asset_path(route_match: &StaticRouteMatch<'_>) -> Option<String> {
246    route_match.descriptor.spa_fallback.clone()
247}
248
249pub fn cache_control_value(strategy: &CacheStrategy) -> Option<String> {
250    match strategy {
251        CacheStrategy::None => None,
252        CacheStrategy::PublicMaxAge { max_age_seconds } => {
253            Some(format!("public, max-age={max_age_seconds}"))
254        }
255    }
256}
257
258fn collect_runtime_pack_paths(bundle_root: &Path) -> anyhow::Result<Vec<PathBuf>> {
259    let mut by_path = BTreeMap::new();
260    let discover = if bundle_root.join("greentic.demo.yaml").exists() {
261        domains::discover_provider_packs_cbor_only
262    } else {
263        domains::discover_provider_packs
264    };
265    for domain in [
266        Domain::Messaging,
267        Domain::Events,
268        Domain::Secrets,
269        Domain::OAuth,
270    ] {
271        for pack in discover(bundle_root, domain)? {
272            by_path.entry(pack.path.clone()).or_insert(pack.path);
273        }
274    }
275    Ok(by_path.into_values().collect())
276}
277
278fn read_pack_static_routes(pack_path: &Path) -> anyhow::Result<Option<Vec<StaticRouteDescriptor>>> {
279    let file = std::fs::File::open(pack_path)?;
280    let mut archive = ZipArchive::new(file)?;
281    let mut manifest_entry = archive.by_name("manifest.cbor").map_err(|err| {
282        anyhow::anyhow!(
283            "failed to open manifest.cbor in {}: {err}",
284            pack_path.display()
285        )
286    })?;
287    let mut bytes = Vec::new();
288    manifest_entry.read_to_end(&mut bytes)?;
289    let manifest = decode_pack_manifest(&bytes)
290        .with_context(|| format!("failed to decode pack manifest in {}", pack_path.display()))?;
291    let Some(extension) = manifest
292        .extensions
293        .as_ref()
294        .and_then(|extensions| extensions.get(EXT_STATIC_ROUTES_V1))
295    else {
296        return Ok(None);
297    };
298    let inline = extension
299        .inline
300        .as_ref()
301        .ok_or_else(|| anyhow::anyhow!("static-routes extension inline payload missing"))?;
302    let ExtensionInline::Other(value) = inline else {
303        anyhow::bail!("static-routes extension inline payload has unexpected type");
304    };
305    let decoded: StaticRoutesExtensionV1 = serde_json::from_value(value.clone())
306        .with_context(|| "failed to parse greentic.static-routes.v1 payload")?;
307    if decoded.schema_version != 1 {
308        anyhow::bail!(
309            "unsupported static-routes extension schema_version={} in {}",
310            decoded.schema_version,
311            pack_path.display()
312        );
313    }
314    let pack_id = manifest.pack_id.as_str().to_string();
315    let mut routes = Vec::new();
316    for (idx, route) in decoded.routes.into_iter().enumerate() {
317        routes.push(normalize_route_descriptor(&pack_id, pack_path, idx, route)?);
318    }
319    Ok(Some(routes))
320}
321
322fn normalize_route_descriptor(
323    pack_id: &str,
324    pack_path: &Path,
325    idx: usize,
326    route: StaticRouteRecord,
327) -> anyhow::Result<StaticRouteDescriptor> {
328    if route.team && !route.tenant {
329        anyhow::bail!(
330            "static route {} in {} sets team=true but tenant=false",
331            route.id.as_deref().unwrap_or("<unnamed>"),
332            pack_path.display()
333        );
334    }
335    let public_path = normalize_public_path(&route.public_path);
336    let route_segments = parse_route_segments(&public_path)?;
337    let uses_tenant = route_segments
338        .iter()
339        .any(|segment| matches!(segment, RouteScopeSegment::Tenant));
340    let uses_team = route_segments
341        .iter()
342        .any(|segment| matches!(segment, RouteScopeSegment::Team));
343    if route.tenant != uses_tenant {
344        anyhow::bail!(
345            "static route {} in {} has inconsistent tenant flag/public_path",
346            route.id.as_deref().unwrap_or("<unnamed>"),
347            pack_path.display()
348        );
349    }
350    if route.team != uses_team {
351        anyhow::bail!(
352            "static route {} in {} has inconsistent team flag/public_path",
353            route.id.as_deref().unwrap_or("<unnamed>"),
354            pack_path.display()
355        );
356    }
357
358    let source_root = normalize_relative_asset_path(&route.source_root).ok_or_else(|| {
359        anyhow::anyhow!(
360            "static route {} in {} has invalid source_root {}",
361            route.id.as_deref().unwrap_or("<unnamed>"),
362            pack_path.display(),
363            route.source_root
364        )
365    })?;
366    let index_file = normalize_optional_relative_asset_path(route.index_file)?;
367    let spa_fallback = normalize_optional_relative_asset_path(route.spa_fallback)?;
368    let cache_strategy =
369        normalize_cache_strategy(route.cache.as_ref(), pack_path, route.id.as_deref())?;
370
371    Ok(StaticRouteDescriptor {
372        route_id: route.id.unwrap_or_else(|| format!("{pack_id}::{idx}")),
373        pack_id: pack_id.to_string(),
374        pack_path: pack_path.to_path_buf(),
375        public_path,
376        source_root,
377        index_file,
378        spa_fallback,
379        tenant_scoped: route.tenant,
380        team_scoped: route.team,
381        cache_strategy,
382        route_segments,
383    })
384}
385
386fn normalize_cache_strategy(
387    cache: Option<&StaticRouteCacheRecord>,
388    pack_path: &Path,
389    route_id: Option<&str>,
390) -> anyhow::Result<CacheStrategy> {
391    let Some(cache) = cache else {
392        return Ok(CacheStrategy::None);
393    };
394    match cache.strategy.trim() {
395        "" | "none" => Ok(CacheStrategy::None),
396        "public-max-age" => Ok(CacheStrategy::PublicMaxAge {
397            max_age_seconds: cache.max_age_seconds.ok_or_else(|| {
398                anyhow::anyhow!(
399                    "static route {} in {} uses public-max-age without max_age_seconds",
400                    route_id.unwrap_or("<unnamed>"),
401                    pack_path.display()
402                )
403            })?,
404        }),
405        other => anyhow::bail!(
406            "static route {} in {} uses unsupported cache.strategy {}",
407            route_id.unwrap_or("<unnamed>"),
408            pack_path.display(),
409            other
410        ),
411    }
412}
413
414fn normalize_optional_relative_asset_path(value: Option<String>) -> anyhow::Result<Option<String>> {
415    match value {
416        Some(value) => normalize_relative_asset_path(&value)
417            .map(Some)
418            .ok_or_else(|| anyhow::anyhow!("invalid asset path {}", value)),
419        None => Ok(None),
420    }
421}
422
423fn normalize_relative_asset_path(path: &str) -> Option<String> {
424    let mut segments = Vec::new();
425    for component in Path::new(path).components() {
426        match component {
427            Component::Normal(segment) => segments.push(segment.to_string_lossy().to_string()),
428            Component::CurDir => {}
429            Component::ParentDir | Component::RootDir | Component::Prefix(_) => return None,
430        }
431    }
432    if segments.is_empty() {
433        return None;
434    }
435    Some(segments.join("/"))
436}
437
438fn parse_route_segments(path: &str) -> anyhow::Result<Vec<RouteScopeSegment>> {
439    let segments = path
440        .trim_start_matches('/')
441        .split('/')
442        .filter(|segment| !segment.is_empty())
443        .collect::<Vec<_>>();
444    if segments.is_empty() {
445        anyhow::bail!("public_path must not be /");
446    }
447    let mut parsed = Vec::new();
448    for segment in segments {
449        match segment {
450            "{tenant}" => parsed.push(RouteScopeSegment::Tenant),
451            "{team}" => parsed.push(RouteScopeSegment::Team),
452            _ if segment.contains('{') || segment.contains('}') => {
453                anyhow::bail!("unsupported public_path segment {}", segment)
454            }
455            _ => parsed.push(RouteScopeSegment::Literal(segment.to_string())),
456        }
457    }
458    let team_pos = parsed
459        .iter()
460        .position(|segment| matches!(segment, RouteScopeSegment::Team));
461    let tenant_pos = parsed
462        .iter()
463        .position(|segment| matches!(segment, RouteScopeSegment::Tenant));
464    if let Some(team_pos) = team_pos {
465        let Some(tenant_pos) = tenant_pos else {
466            anyhow::bail!("public_path uses {{team}} without {{tenant}}");
467        };
468        if team_pos <= tenant_pos {
469            anyhow::bail!("public_path must place {{team}} after {{tenant}}");
470        }
471    }
472    Ok(parsed)
473}
474
475fn validate_plan(plan: &mut StaticRoutePlan, reserved_routes: &ReservedRouteSet) {
476    let mut seen_paths = BTreeMap::<String, String>::new();
477    for route in &plan.routes {
478        if reserved_routes.conflicts_with(&route.public_path) {
479            plan.blocking_failures.push(format!(
480                "static route {} conflicts with reserved operator path space at {}",
481                route.route_id, route.public_path
482            ));
483        }
484        if let Some(existing) = seen_paths.insert(route.public_path.clone(), route.route_id.clone())
485        {
486            plan.blocking_failures.push(format!(
487                "static route {} duplicates public_path {} already claimed by {}",
488                route.route_id, route.public_path, existing
489            ));
490        }
491    }
492    for i in 0..plan.routes.len() {
493        for j in (i + 1)..plan.routes.len() {
494            let left = &plan.routes[i];
495            let right = &plan.routes[j];
496            if paths_overlap(&left.public_path, &right.public_path) {
497                plan.blocking_failures.push(format!(
498                    "static routes {} ({}) and {} ({}) overlap ambiguously",
499                    left.route_id, left.public_path, right.route_id, right.public_path
500                ));
501            }
502        }
503    }
504}
505
506fn paths_overlap(left: &str, right: &str) -> bool {
507    path_has_prefix(left, right) || path_has_prefix(right, left)
508}
509
510fn path_has_prefix(path: &str, prefix: &str) -> bool {
511    if path == prefix {
512        return true;
513    }
514    let prefix = prefix.trim_end_matches('/');
515    path.strip_prefix(prefix)
516        .map(|rest| rest.starts_with('/'))
517        .unwrap_or(false)
518}
519
520fn normalize_public_path(path: &str) -> String {
521    let trimmed = path.trim();
522    let normalized = if trimmed.starts_with('/') {
523        trimmed.to_string()
524    } else {
525        format!("/{trimmed}")
526    };
527    if normalized.len() > 1 {
528        normalized.trim_end_matches('/').to_string()
529    } else {
530        normalized
531    }
532}
533
534#[cfg(test)]
535mod tests {
536    use std::collections::BTreeMap;
537    use std::io::Write;
538
539    use greentic_types::{ExtensionRef, PackId, PackKind, PackManifest, PackSignatures};
540    use semver::Version;
541    use serde_json::json;
542    use tempfile::tempdir;
543    use zip::write::FileOptions;
544
545    use super::*;
546
547    fn write_pack(path: &Path, extension_payload: serde_json::Value) {
548        let mut extensions = BTreeMap::new();
549        extensions.insert(
550            EXT_STATIC_ROUTES_V1.to_string(),
551            ExtensionRef {
552                kind: EXT_STATIC_ROUTES_V1.to_string(),
553                version: "v1".to_string(),
554                digest: None,
555                location: None,
556                inline: Some(greentic_types::ExtensionInline::Other(extension_payload)),
557            },
558        );
559        let manifest = PackManifest {
560            schema_version: "pack-v1".to_string(),
561            pack_id: PackId::new("web-pack").expect("pack id"),
562            name: None,
563            version: Version::parse("0.1.0").expect("version"),
564            kind: PackKind::Provider,
565            publisher: "demo".to_string(),
566            components: Vec::new(),
567            flows: Vec::new(),
568            dependencies: Vec::new(),
569            capabilities: Vec::new(),
570            secret_requirements: Vec::new(),
571            signatures: PackSignatures::default(),
572            bootstrap: None,
573            extensions: Some(extensions),
574        };
575        let file = std::fs::File::create(path).expect("create pack");
576        let mut zip = zip::ZipWriter::new(file);
577        zip.start_file("manifest.cbor", FileOptions::<()>::default())
578            .expect("start manifest");
579        let bytes = greentic_types::encode_pack_manifest(&manifest).expect("manifest bytes");
580        zip.write_all(&bytes).expect("write manifest");
581        zip.finish().expect("finish zip");
582    }
583
584    #[test]
585    fn discovers_static_routes_from_manifest_extension() {
586        let tmp = tempdir().expect("tempdir");
587        let providers = tmp.path().join("providers").join("messaging");
588        std::fs::create_dir_all(&providers).expect("providers dir");
589        let pack_path = providers.join("web.gtpack");
590        write_pack(
591            &pack_path,
592            json!({
593                "schema_version": 1,
594                "routes": [{
595                    "id": "docs",
596                    "public_path": "/v1/web/docs",
597                    "source_root": "assets/site",
598                    "index_file": "index.html",
599                    "spa_fallback": "index.html",
600                    "cache": {"strategy": "public-max-age", "max_age_seconds": 60}
601                }]
602            }),
603        );
604
605        let plan = discover_from_bundle(tmp.path(), &ReservedRouteSet::operator_defaults())
606            .expect("discover plan");
607        assert!(
608            plan.blocking_failures.is_empty(),
609            "{:?}",
610            plan.blocking_failures
611        );
612        assert_eq!(plan.routes.len(), 1);
613        assert_eq!(plan.routes[0].public_path, "/v1/web/docs");
614        assert_eq!(
615            plan.routes[0].cache_strategy,
616            CacheStrategy::PublicMaxAge {
617                max_age_seconds: 60
618            }
619        );
620    }
621
622    #[test]
623    fn rejects_reserved_and_overlapping_routes() {
624        let reserved = ReservedRouteSet::operator_defaults();
625        let mut plan = StaticRoutePlan {
626            routes: vec![
627                StaticRouteDescriptor {
628                    route_id: "one".into(),
629                    pack_id: "pack".into(),
630                    pack_path: PathBuf::from("one.gtpack"),
631                    public_path: "/api/onboard/docs".into(),
632                    source_root: "assets".into(),
633                    index_file: None,
634                    spa_fallback: None,
635                    tenant_scoped: false,
636                    team_scoped: false,
637                    cache_strategy: CacheStrategy::None,
638                    route_segments: parse_route_segments("/api/onboard/docs").expect("segments"),
639                },
640                StaticRouteDescriptor {
641                    route_id: "two".into(),
642                    pack_id: "pack".into(),
643                    pack_path: PathBuf::from("two.gtpack"),
644                    public_path: "/v1/web/docs/admin".into(),
645                    source_root: "assets".into(),
646                    index_file: None,
647                    spa_fallback: None,
648                    tenant_scoped: false,
649                    team_scoped: false,
650                    cache_strategy: CacheStrategy::None,
651                    route_segments: parse_route_segments("/v1/web/docs/admin").expect("segments"),
652                },
653                StaticRouteDescriptor {
654                    route_id: "three".into(),
655                    pack_id: "pack".into(),
656                    pack_path: PathBuf::from("three.gtpack"),
657                    public_path: "/v1/web/docs".into(),
658                    source_root: "assets".into(),
659                    index_file: None,
660                    spa_fallback: None,
661                    tenant_scoped: false,
662                    team_scoped: false,
663                    cache_strategy: CacheStrategy::None,
664                    route_segments: parse_route_segments("/v1/web/docs").expect("segments"),
665                },
666            ],
667            warnings: Vec::new(),
668            blocking_failures: Vec::new(),
669        };
670        validate_plan(&mut plan, &reserved);
671        assert_eq!(plan.blocking_failures.len(), 2);
672    }
673
674    #[test]
675    fn active_route_table_matches_placeholders() {
676        let route = StaticRouteDescriptor {
677            route_id: "tenant-gui".into(),
678            pack_id: "web".into(),
679            pack_path: PathBuf::from("web.gtpack"),
680            public_path: "/v1/web/webchat/{tenant}/{team}".into(),
681            source_root: "assets/webchat".into(),
682            index_file: Some("index.html".into()),
683            spa_fallback: Some("index.html".into()),
684            tenant_scoped: true,
685            team_scoped: true,
686            cache_strategy: CacheStrategy::None,
687            route_segments: parse_route_segments("/v1/web/webchat/{tenant}/{team}")
688                .expect("segments"),
689        };
690        let table = ActiveRouteTable::from_plan(&StaticRoutePlan {
691            routes: vec![route],
692            warnings: Vec::new(),
693            blocking_failures: Vec::new(),
694        });
695        let matched = table
696            .match_request("/v1/web/webchat/demo/default/app.js")
697            .expect("route match");
698        assert_eq!(matched.asset_path, "app.js");
699    }
700}