Skip to main content

greentic_runner_host/
lib.rs

1#![deny(unsafe_code)]
2//! Canonical Greentic host runtime.
3//!
4//! This crate owns tenant bindings, pack ingestion/watchers, ingress adapters,
5//! Wasmtime glue, session/state storage, and the HTTP server used by the
6//! `greentic-runner` CLI. Downstream crates embed it either through
7//! [`RunnerConfig`] + [`run`] (HTTP host) or [`HostBuilder`] (direct API access).
8
9use std::collections::{HashMap, HashSet};
10use std::fs;
11use std::path::PathBuf;
12use std::sync::Arc;
13use std::time::Duration;
14
15use crate::secrets::SecretsBackend;
16use anyhow::{Context, Result, anyhow};
17use greentic_config::ResolvedConfig;
18use greentic_config_types::TelemetryExporterKind;
19use greentic_config_types::{
20    NetworkConfig, PackSourceConfig, PacksConfig, PathsConfig, TelemetryConfig,
21};
22use greentic_telemetry::export::{ExportConfig as TelemetryExportConfig, ExportMode, Sampling};
23use runner_core::env::PackConfig;
24use serde_json::json;
25use tokio::signal;
26
27pub mod boot;
28pub mod cache;
29pub mod component_api;
30pub mod config;
31pub mod engine;
32pub mod extension_provider;
33pub mod fault;
34#[cfg(feature = "greentic-x-provider")]
35pub mod greentic_x_provider;
36pub mod gtbind;
37pub mod http;
38pub mod identify_hint;
39pub mod metrics;
40pub mod operator_metrics;
41pub mod operator_registry;
42pub mod pack;
43pub mod provider;
44pub mod provider_core;
45pub mod provider_core_only;
46pub mod routing;
47pub mod runner;
48pub mod runtime;
49pub mod runtime_refs;
50pub mod runtime_wasmtime;
51pub mod secrets;
52pub(crate) mod secrets_broker;
53pub mod sql;
54pub mod storage;
55pub mod telemetry;
56pub mod telemetry_scan;
57#[cfg(feature = "fault-injection")]
58pub mod testing;
59pub mod trace;
60pub mod validate;
61pub mod verify;
62pub mod wasi;
63pub mod watcher;
64
65mod activity;
66mod host;
67pub mod oauth;
68
69pub use activity::{Activity, ActivityKind, WelcomeFlowHint};
70pub use config::HostConfig;
71pub use gtbind::{PackBinding, TenantBindings};
72pub use host::TelemetryCfg;
73pub use host::{HostBuilder, RunnerHost, TenantHandle};
74pub use wasi::{PreopenSpec, RunnerWasiPolicy};
75
76pub use greentic_types::{EnvId, FlowId, PackId, TenantCtx, TenantId};
77
78pub use http::auth::AdminAuth;
79pub use routing::RoutingConfig;
80use routing::TenantRouting;
81pub use runner::HostServer;
82
83#[cfg(test)]
84pub(crate) mod test_support {
85    use super::*;
86    use crate::config::{OperatorPolicy, SecretsPolicy};
87    use crate::runtime::TenantRuntime;
88    use crate::secrets::default_manager;
89    use crate::storage::{new_session_store, new_state_store, session_host_from, state_host_from};
90    use crate::trace::TraceConfig;
91    use crate::validate::ValidationConfig;
92    use tempfile::TempDir;
93
94    pub(crate) fn fixture_pack_path() -> PathBuf {
95        PathBuf::from(env!("CARGO_MANIFEST_DIR"))
96            .join("../../examples/packs/demo.gtpack")
97            .canonicalize()
98            .expect("fixture pack path")
99    }
100
101    fn minimal_config(workspace: &std::path::Path) -> Result<Arc<HostConfig>> {
102        let bindings_path = workspace.join("bindings.yaml");
103        std::fs::write(
104            &bindings_path,
105            r#"
106tenant: demo
107flow_type_bindings: {}
108rate_limits: {}
109retry: {}
110timers: []
111"#,
112        )?;
113        let mut config =
114            HostConfig::load_from_path(&bindings_path).context("load minimal host bindings")?;
115        config.secrets_policy = SecretsPolicy::allow_all();
116        config.operator_policy = OperatorPolicy::allow_all();
117        config.trace = TraceConfig::from_env();
118        config.validation = ValidationConfig::from_env();
119        Ok(Arc::new(config))
120    }
121
122    pub(crate) async fn build_test_runtime() -> Result<(TempDir, Arc<TenantRuntime>)> {
123        let workspace = TempDir::new().context("temp workspace")?;
124        let config = minimal_config(workspace.path())?;
125        let session_store = new_session_store();
126        let session_host = session_host_from(Arc::clone(&session_store));
127        let state_store = new_state_store();
128        let state_host = state_host_from(Arc::clone(&state_store));
129        let secrets = default_manager()?;
130        let pack_path = fixture_pack_path();
131        let runtime = TenantRuntime::load(
132            &pack_path,
133            config,
134            None,
135            Some(&pack_path),
136            None,
137            Arc::new(RunnerWasiPolicy::new()),
138            session_host,
139            Arc::clone(&session_store),
140            Arc::clone(&state_store),
141            state_host,
142            secrets,
143        )
144        .await?;
145        Ok((workspace, runtime))
146    }
147}
148
149/// User-facing configuration for running the unified host.
150#[derive(Clone)]
151pub struct RunnerConfig {
152    pub tenant_bindings: HashMap<String, TenantBindings>,
153    pub pack: PackConfig,
154    pub port: u16,
155    pub refresh_interval: Duration,
156    pub routing: RoutingConfig,
157    pub admin: AdminAuth,
158    pub telemetry: Option<TelemetryCfg>,
159    pub secrets_backend: SecretsBackend,
160    pub wasi_policy: RunnerWasiPolicy,
161    pub resolved_config: ResolvedConfig,
162    pub trace: trace::TraceConfig,
163    pub validation: validate::ValidationConfig,
164}
165
166impl RunnerConfig {
167    /// Build a [`RunnerConfig`] from a resolved greentic-config and the provided binding files.
168    pub fn from_config(resolved_config: ResolvedConfig, bindings: Vec<PathBuf>) -> Result<Self> {
169        if bindings.is_empty() {
170            anyhow::bail!("at least one gtbind file is required");
171        }
172        let tenant_bindings = gtbind::load_gtbinds(&bindings)?;
173        if tenant_bindings.is_empty() {
174            anyhow::bail!("no gtbind files loaded");
175        }
176        let mut pack = pack_config_from(
177            &resolved_config.config.packs,
178            &resolved_config.config.paths,
179            &resolved_config.config.network,
180        )?;
181        maybe_write_gtbind_index(&tenant_bindings, &resolved_config.config.paths, &mut pack)?;
182        let refresh = parse_refresh_interval(std::env::var("PACK_REFRESH_INTERVAL").ok())?;
183        let port = std::env::var("PORT")
184            .ok()
185            .and_then(|value| value.parse().ok())
186            .unwrap_or(8080);
187        let default_tenant = resolved_config
188            .config
189            .dev
190            .as_ref()
191            .map(|dev| dev.default_tenant.clone())
192            .unwrap_or_else(|| crate::routing::DEFAULT_TENANT.into());
193        let routing = RoutingConfig::from_env_with_default(default_tenant);
194        let paths = &resolved_config.config.paths;
195        ensure_paths_exist(paths)?;
196        let mut wasi_policy = default_wasi_policy(paths);
197        let mut env_allow = HashSet::new();
198        for binding in tenant_bindings.values() {
199            env_allow.extend(binding.env_passthrough.iter().cloned());
200        }
201        for key in env_allow {
202            wasi_policy = wasi_policy.allow_env(key);
203        }
204
205        let admin = AdminAuth::new(resolved_config.config.services.as_ref().and_then(|s| {
206            s.events
207                .as_ref()
208                .and_then(|svc| svc.headers.as_ref())
209                .and_then(|headers| headers.get("x-admin-token").cloned())
210        }));
211        let secrets_backend = SecretsBackend::from_config(&resolved_config.config.secrets)?;
212        Ok(Self {
213            tenant_bindings,
214            pack,
215            port,
216            refresh_interval: refresh,
217            routing,
218            admin,
219            telemetry: telemetry_from(&resolved_config.config.telemetry),
220            secrets_backend,
221            wasi_policy,
222            resolved_config,
223            trace: trace::TraceConfig::from_env(),
224            validation: validate::ValidationConfig::from_env(),
225        })
226    }
227
228    /// Override the HTTP port used by the host server.
229    pub fn with_port(mut self, port: u16) -> Self {
230        self.port = port;
231        self
232    }
233
234    pub fn with_wasi_policy(mut self, policy: RunnerWasiPolicy) -> Self {
235        self.wasi_policy = policy;
236        self
237    }
238}
239
240fn maybe_write_gtbind_index(
241    tenant_bindings: &HashMap<String, TenantBindings>,
242    paths: &PathsConfig,
243    pack: &mut PackConfig,
244) -> Result<()> {
245    let mut uses_locators = false;
246    for binding in tenant_bindings.values() {
247        for pack_binding in &binding.packs {
248            if pack_binding.pack_locator.is_some() {
249                uses_locators = true;
250            }
251        }
252    }
253    if !uses_locators {
254        return Ok(());
255    }
256
257    let mut entries = serde_json::Map::new();
258    for binding in tenant_bindings.values() {
259        let mut packs = Vec::new();
260        for pack_binding in &binding.packs {
261            let locator = pack_binding.pack_locator.as_ref().ok_or_else(|| {
262                anyhow::anyhow!(
263                    "gtbind {} missing pack_locator for pack {}",
264                    binding.tenant,
265                    pack_binding.pack_id
266                )
267            })?;
268            let (name, version_or_digest) =
269                pack_binding.pack_ref.split_once('@').ok_or_else(|| {
270                    anyhow::anyhow!(
271                        "gtbind {} invalid pack_ref {} (expected name@version)",
272                        binding.tenant,
273                        pack_binding.pack_ref
274                    )
275                })?;
276            if name != pack_binding.pack_id {
277                anyhow::bail!(
278                    "gtbind {} pack_ref {} does not match pack_id {}",
279                    binding.tenant,
280                    pack_binding.pack_ref,
281                    pack_binding.pack_id
282                );
283            }
284            let mut entry = serde_json::Map::new();
285            entry.insert("name".to_string(), json!(name));
286            if version_or_digest.contains(':') {
287                entry.insert("digest".to_string(), json!(version_or_digest));
288            } else {
289                entry.insert("version".to_string(), json!(version_or_digest));
290            }
291            entry.insert("locator".to_string(), json!(locator));
292            packs.push(serde_json::Value::Object(entry));
293        }
294        let main_pack = packs
295            .first()
296            .cloned()
297            .ok_or_else(|| anyhow::anyhow!("gtbind {} has no packs", binding.tenant))?;
298        let overlays = packs.into_iter().skip(1).collect::<Vec<_>>();
299        entries.insert(
300            binding.tenant.clone(),
301            json!({
302                "main_pack": main_pack,
303                "overlays": overlays,
304            }),
305        );
306    }
307
308    let index_path = paths.greentic_root.join("packs").join("gtbind.index.json");
309    if let Some(parent) = index_path.parent() {
310        fs::create_dir_all(parent)
311            .with_context(|| format!("failed to create {}", parent.display()))?;
312    }
313    let serialized = serde_json::to_vec_pretty(&serde_json::Value::Object(entries))?;
314    fs::write(&index_path, serialized)
315        .with_context(|| format!("failed to write {}", index_path.display()))?;
316    pack.index_location = runner_core::env::IndexLocation::File(index_path);
317    Ok(())
318}
319
320fn parse_refresh_interval(value: Option<String>) -> Result<Duration> {
321    let raw = value.unwrap_or_else(|| "30s".into());
322    humantime::parse_duration(&raw).map_err(|err| anyhow!("invalid PACK_REFRESH_INTERVAL: {err}"))
323}
324
325fn default_wasi_policy(paths: &PathsConfig) -> RunnerWasiPolicy {
326    let mut policy = RunnerWasiPolicy::default()
327        .with_env("GREENTIC_ROOT", paths.greentic_root.display().to_string())
328        .with_env("GREENTIC_STATE_DIR", paths.state_dir.display().to_string())
329        .with_env("GREENTIC_CACHE_DIR", paths.cache_dir.display().to_string())
330        .with_env("GREENTIC_LOGS_DIR", paths.logs_dir.display().to_string());
331    policy = policy
332        .with_preopen(PreopenSpec::new(&paths.state_dir, "/state"))
333        .with_preopen(PreopenSpec::new(&paths.cache_dir, "/cache"))
334        .with_preopen(PreopenSpec::new(&paths.logs_dir, "/logs"));
335    policy
336}
337
338fn ensure_paths_exist(paths: &PathsConfig) -> Result<()> {
339    for dir in [
340        &paths.greentic_root,
341        &paths.state_dir,
342        &paths.cache_dir,
343        &paths.logs_dir,
344    ] {
345        fs::create_dir_all(dir)
346            .with_context(|| format!("failed to ensure directory {}", dir.display()))?;
347    }
348    Ok(())
349}
350
351fn pack_config_from(
352    packs: &Option<PacksConfig>,
353    paths: &PathsConfig,
354    network: &NetworkConfig,
355) -> Result<PackConfig> {
356    if let Some(cfg) = packs {
357        let cache_dir = cfg.cache_dir.clone();
358        let index_location = match &cfg.source {
359            PackSourceConfig::LocalIndex { path } => {
360                runner_core::env::IndexLocation::File(path.clone())
361            }
362            PackSourceConfig::HttpIndex { url } => {
363                runner_core::env::IndexLocation::from_value(url)?
364            }
365            PackSourceConfig::OciRegistry { reference } => {
366                runner_core::env::IndexLocation::from_value(reference)?
367            }
368        };
369        let public_key = cfg
370            .trust
371            .as_ref()
372            .and_then(|trust| trust.public_keys.first().cloned());
373        return Ok(PackConfig {
374            source: runner_core::env::PackSource::Fs,
375            index_location,
376            cache_dir,
377            public_key,
378            network: Some(network.clone()),
379        });
380    }
381    let mut cfg = PackConfig::default_for_paths(paths)?;
382    cfg.network = Some(network.clone());
383    Ok(cfg)
384}
385
386fn telemetry_from(cfg: &TelemetryConfig) -> Option<TelemetryCfg> {
387    telemetry_from_env(
388        cfg,
389        std::env::var("OTLP_HEADERS").ok(),
390        std::env::var("OTEL_SERVICE_NAME").ok(),
391    )
392}
393
394/// The pure half of [`telemetry_from`]: the two environment variables are
395/// parameters so this can be tested without mutating process-wide state
396/// (`std::env::set_var` is `unsafe` under edition 2024, and this crate denies
397/// `unsafe_code`).
398fn telemetry_from_env(
399    cfg: &TelemetryConfig,
400    otlp_headers: Option<String>,
401    service_name: Option<String>,
402) -> Option<TelemetryCfg> {
403    if !cfg.enabled || matches!(cfg.exporter, TelemetryExporterKind::None) {
404        return None;
405    }
406    let mut export = TelemetryExportConfig::json_default();
407    export.mode = match cfg.exporter {
408        TelemetryExporterKind::Otlp => ExportMode::OtlpGrpc,
409        TelemetryExporterKind::Stdout => ExportMode::JsonStdout,
410        TelemetryExporterKind::Gcp => ExportMode::GcpCloudTrace,
411        TelemetryExporterKind::Azure => ExportMode::AzureAppInsights,
412        TelemetryExporterKind::Aws => ExportMode::AwsXRay,
413        TelemetryExporterKind::None => return None,
414    };
415    export.endpoint = cfg.endpoint.clone();
416    export.sampling = Sampling::TraceIdRatio(cfg.sampling as f64);
417
418    // `OTLP_HEADERS` is how an authenticated collector is reached — Honeycomb,
419    // Grafana Cloud and most hosted OTLP endpoints need an auth header, and
420    // without one they reject every export.
421    //
422    // This config is built from `json_default()`, which starts with no headers,
423    // and only `ExportConfig::from_env()` reads that variable — a function this
424    // crate never calls. So the header was silently dropped and no exporter
425    // that needs one could work at all. Callers that resolve a credential and
426    // set the variable (greentic-designer resolves one through its admin
427    // secret broker on every deploy) were doing that work for nothing.
428    //
429    // `from_env()` is deliberately NOT used here: it also runs preset detection
430    // and would overwrite the mode, endpoint and sampling this function has
431    // just derived from the operator's own configuration. The public
432    // `parse_headers_from_env` is the same parser without that side effect.
433    match greentic_telemetry::presets::parse_headers_from_env(otlp_headers) {
434        Ok(headers) => {
435            if !headers.is_empty() {
436                export.headers = headers;
437            }
438        }
439        // Malformed input must degrade to unauthenticated export rather than
440        // killing telemetry outright, and it must say so: an exporter that
441        // silently sends nothing is the failure this whole change is about.
442        // The error names the offending ENTRY, never its value.
443        Err(err) => {
444            tracing::warn!(
445                error = %err,
446                "OTLP_HEADERS could not be parsed; exporting without headers"
447            );
448        }
449    }
450
451    Some(TelemetryCfg {
452        config: greentic_telemetry::TelemetryConfig {
453            // `OTEL_SERVICE_NAME` is the standard way to name a service, and a
454            // hardcoded value here silently defeated it: the SDK's own
455            // `SdkProvidedResourceDetector` reads that variable, but
456            // `with_service_name` is applied afterwards and merges
457            // last-writer-wins, so setting it had no effect and produced no
458            // error to notice. Several runners deployed side by side were
459            // therefore indistinguishable in the collector.
460            //
461            // The literal stays as the fallback, so nothing changes for a
462            // deployment that does not set it.
463            service_name: service_name
464                .filter(|s| !s.trim().is_empty())
465                .unwrap_or_else(|| "greentic-runner".into()),
466        },
467        export,
468    })
469}
470
471/// Spawn the in-process agentic-worker NATS service once, when opted in.
472///
473/// Gated on `GREENTIC_AGENTIC_SERVE_INPROC` (truthy) AND `GREENTIC_EVENTS_NATS_URL`
474/// (set) via [`runner::agent_node::should_serve_agentic_inproc`]. Loads
475/// process-level base agent configs from `GREENTIC_AGENT_MANIFESTS_DIR`
476/// (`<agent_id>.json` full [`greentic_aw_runtime::AgentConfig`] files) — the only
477/// process-level agent source, since pack-embedded and per-tenant `HostConfig`
478/// agents only exist inside `TenantRuntime::from_packs`.
479///
480/// Skips with a warning (continuing normal startup) when no agents are
481/// configured; [`serve_agentic`] itself further degrades gracefully when the
482/// runtime cannot be built (no `GREENTIC_AW_REDIS_URL` / LLM key). The spawned
483/// task owns the subscriber for the lifetime of the process.
484#[cfg(feature = "agentic-worker")]
485fn maybe_spawn_inproc_agentic_serve() {
486    use crate::runner::agent_node::{
487        load_process_agent_configs, serve_agentic, should_serve_agentic_inproc,
488    };
489
490    if !should_serve_agentic_inproc(|key| std::env::var(key).ok()) {
491        return;
492    }
493
494    // Both env vars are guaranteed present/non-empty by the gate above.
495    let nats_url = std::env::var("GREENTIC_EVENTS_NATS_URL").unwrap_or_default();
496    let agents = load_process_agent_configs();
497    if agents.is_empty() {
498        tracing::warn!(
499            "GREENTIC_AGENTIC_SERVE_INPROC set but no process-level agents found in \
500             GREENTIC_AGENT_MANIFESTS_DIR; in-process agentic serve skipped"
501        );
502        return;
503    }
504
505    let agent_count = agents.len();
506    tracing::info!(
507        agent_count,
508        nats_url = %nats_url,
509        "starting in-process agentic serve (GREENTIC_AGENTIC_SERVE_INPROC)"
510    );
511    tokio::spawn(async move {
512        if let Err(error) = serve_agentic(&nats_url, agents).await {
513            tracing::warn!(error = %error, "in-process agentic serve stopped with error");
514        }
515    });
516}
517
518/// Run the unified Greentic runner host until shutdown.
519pub async fn run(cfg: RunnerConfig) -> Result<()> {
520    let RunnerConfig {
521        tenant_bindings,
522        pack,
523        port,
524        refresh_interval,
525        routing,
526        admin,
527        telemetry,
528        secrets_backend,
529        wasi_policy,
530        resolved_config: _resolved_config,
531        trace,
532        validation,
533    } = cfg;
534
535    let mut builder = HostBuilder::new();
536    for bindings in tenant_bindings.into_values() {
537        let mut host_config = HostConfig::from_gtbind(bindings);
538        host_config.trace = trace.clone();
539        host_config.validation = validation.clone();
540        builder = builder.with_config(host_config);
541    }
542    if let Some(telemetry) = telemetry.clone() {
543        builder = builder.with_telemetry(telemetry);
544    }
545    builder = builder
546        .with_wasi_policy(wasi_policy.clone())
547        .with_secrets_manager(
548            secrets_backend
549                .build_manager()
550                .context("failed to initialise secrets backend")?,
551        );
552
553    let host = Arc::new(builder.build()?);
554    host.start().await?;
555
556    // Opt-in, once-per-process co-host of the agentic-worker NATS service.
557    // Default OFF: distributed deploys run a standalone `aw-serve` so the
558    // service scales independently. When enabled, this spawns a SINGLE
559    // subscriber for the whole process (NOT per-tenant — multiple subscribers
560    // on `greentic.agentic.request.v1` would each handle every request).
561    #[cfg(feature = "agentic-worker")]
562    maybe_spawn_inproc_agentic_serve();
563
564    let (watcher, reload_handle) =
565        watcher::start_pack_watcher(Arc::clone(&host), pack.clone(), refresh_interval).await?;
566
567    let routing = TenantRouting::new(routing.clone());
568    let server = HostServer::new(
569        port,
570        host.active_packs(),
571        routing,
572        host.health_state(),
573        Some(reload_handle),
574        admin.clone(),
575        Arc::clone(&host),
576    )?;
577
578    tokio::select! {
579        result = server.serve() => {
580            result?;
581        }
582        _ = signal::ctrl_c() => {
583            tracing::info!("received shutdown signal");
584        }
585    }
586
587    drop(watcher);
588    host.stop().await?;
589    Ok(())
590}
591
592#[cfg(test)]
593mod tests {
594    use super::*;
595    use crate::gtbind::{PackBinding, TenantBindings};
596    use greentic_config_types::PackTrustConfig;
597    use tempfile::TempDir;
598
599    fn paths(temp: &TempDir) -> PathsConfig {
600        PathsConfig {
601            greentic_root: temp.path().join("greentic"),
602            state_dir: temp.path().join("state"),
603            cache_dir: temp.path().join("cache"),
604            logs_dir: temp.path().join("logs"),
605        }
606    }
607
608    #[test]
609    fn parse_refresh_interval_uses_default_and_rejects_invalid_values() {
610        assert_eq!(
611            parse_refresh_interval(None).expect("default interval"),
612            Duration::from_secs(30)
613        );
614        assert!(parse_refresh_interval(Some("not-a-duration".into())).is_err());
615    }
616
617    #[test]
618    fn ensure_paths_exist_creates_expected_directories() {
619        let temp = TempDir::new().expect("tempdir");
620        let paths = paths(&temp);
621        ensure_paths_exist(&paths).expect("create directories");
622
623        assert!(paths.greentic_root.is_dir());
624        assert!(paths.state_dir.is_dir());
625        assert!(paths.cache_dir.is_dir());
626        assert!(paths.logs_dir.is_dir());
627    }
628
629    #[test]
630    fn maybe_write_gtbind_index_writes_locator_index() {
631        let temp = TempDir::new().expect("tempdir");
632        let paths = paths(&temp);
633        fs::create_dir_all(&paths.greentic_root).expect("greentic root");
634        let mut pack = PackConfig::default_for_paths(&paths).expect("pack config");
635        let tenant_bindings = HashMap::from([(
636            "demo".to_string(),
637            TenantBindings {
638                tenant: "demo".into(),
639                packs: vec![
640                    PackBinding {
641                        pack_id: "pack.main".into(),
642                        pack_ref: "pack.main@1.0.0".into(),
643                        pack_locator: Some("fs:///packs/main.gtpack".into()),
644                        flows: vec!["main".into()],
645                    },
646                    PackBinding {
647                        pack_id: "pack.overlay".into(),
648                        pack_ref: "pack.overlay@sha256:abcd".into(),
649                        pack_locator: Some("fs:///packs/overlay.gtpack".into()),
650                        flows: vec![],
651                    },
652                ],
653                env_passthrough: vec![],
654            },
655        )]);
656
657        maybe_write_gtbind_index(&tenant_bindings, &paths, &mut pack).expect("write gtbind index");
658
659        let index_path = paths.greentic_root.join("packs").join("gtbind.index.json");
660        let json: serde_json::Value =
661            serde_json::from_slice(&fs::read(&index_path).expect("read index")).expect("json");
662        assert_eq!(
663            json["demo"]["main_pack"]["locator"],
664            "fs:///packs/main.gtpack"
665        );
666        assert_eq!(json["demo"]["overlays"][0]["digest"], "sha256:abcd");
667        match pack.index_location {
668            runner_core::env::IndexLocation::File(path) => assert_eq!(path, index_path),
669            runner_core::env::IndexLocation::Remote(_) => panic!("expected generated file index"),
670        }
671    }
672
673    #[test]
674    fn pack_config_from_prefers_configured_pack_source() {
675        let temp = TempDir::new().expect("tempdir");
676        let paths = paths(&temp);
677        let packs = Some(PacksConfig {
678            source: PackSourceConfig::HttpIndex {
679                url: "https://example.com/index.json".into(),
680            },
681            cache_dir: temp.path().join("packs-cache"),
682            index_cache_ttl_secs: None,
683            trust: Some(PackTrustConfig {
684                public_keys: vec!["ed25519:test".into()],
685                require_signatures: true,
686            }),
687        });
688        let config = pack_config_from(&packs, &paths, &NetworkConfig::default())
689            .expect("pack config from packs");
690
691        match config.index_location {
692            runner_core::env::IndexLocation::Remote(url) => {
693                assert_eq!(url.as_str(), "https://example.com/index.json");
694            }
695            runner_core::env::IndexLocation::File(_) => panic!("expected remote index"),
696        }
697        assert_eq!(config.public_key.as_deref(), Some("ed25519:test"));
698        assert!(config.network.is_some());
699    }
700
701    #[test]
702    fn default_wasi_policy_sets_expected_env_and_preopens() {
703        let temp = TempDir::new().expect("tempdir");
704        let paths = paths(&temp);
705        let policy = default_wasi_policy(&paths);
706
707        assert_eq!(
708            policy.env_set.get("GREENTIC_ROOT"),
709            Some(&paths.greentic_root.display().to_string())
710        );
711        assert_eq!(policy.preopens.len(), 3);
712        assert_eq!(policy.preopens[0].guest_path, "/state");
713        assert_eq!(policy.preopens[1].guest_path, "/cache");
714        assert_eq!(policy.preopens[2].guest_path, "/logs");
715    }
716
717    #[test]
718    fn telemetry_from_is_disabled_without_exporter() {
719        assert!(telemetry_from(&TelemetryConfig::default()).is_none());
720    }
721
722    #[test]
723    fn telemetry_from_env_honours_the_telemetry_environment() {
724        fn enabled() -> TelemetryConfig {
725            TelemetryConfig {
726                enabled: true,
727                exporter: TelemetryExporterKind::Otlp,
728                ..Default::default()
729            }
730        }
731
732        // Neither variable set: a deployment that configures nothing must
733        // behave exactly as it did before this change.
734        let bare = telemetry_from_env(&enabled(), None, None).expect("enabled yields a config");
735        assert!(bare.export.headers.is_empty());
736        assert_eq!(bare.config.service_name, "greentic-runner");
737
738        let set = telemetry_from_env(
739            &enabled(),
740            Some("authorization=Bearer tok,x-team=alpha".into()),
741            Some("greentic-designer-worker".into()),
742        )
743        .expect("enabled yields a config");
744        assert_eq!(
745            set.export.headers.get("authorization").map(String::as_str),
746            Some("Bearer tok"),
747            "an authenticated collector cannot be reached without this header"
748        );
749        assert_eq!(
750            set.export.headers.get("x-team").map(String::as_str),
751            Some("alpha")
752        );
753        assert_eq!(set.config.service_name, "greentic-designer-worker");
754
755        // A blank name falls back rather than registering an unnamed service.
756        let blank = telemetry_from_env(&enabled(), None, Some("   ".into()))
757            .expect("enabled yields a config");
758        assert_eq!(blank.config.service_name, "greentic-runner");
759
760        // Malformed headers degrade to unauthenticated export — never a panic,
761        // and never telemetry dropped entirely.
762        let malformed =
763            telemetry_from_env(&enabled(), Some("this-has-no-equals-sign".into()), None)
764                .expect("malformed headers must not disable telemetry");
765        assert!(malformed.export.headers.is_empty());
766    }
767}