diff --git a/architecture/gateway.md b/architecture/gateway.md index db8f2508e..f3da5c5e6 100644 --- a/architecture/gateway.md +++ b/architecture/gateway.md @@ -639,6 +639,15 @@ Driver implementation settings live in the TOML driver tables. See `docs/reference/gateway-config.mdx` for worked per-driver examples and RFC 0003 for the full schema. +Each installation has an operator-assigned gateway name. Configure it with +`[openshell.gateway].name`, `--name`, or `OPENSHELL_GATEWAY_NAME`. +The built-in default is `openshell`; the Helm chart defaults it to the chart +fullname so every replica in one installation reports the same identity. +Operators must set a globally distinct name when one telemetry collector serves +installations in multiple Kubernetes namespaces or clusters. +The name identifies the gateway installation independently of client-side +aliases, network names, and the sandbox JWT issuer. + `database_url` is env-only and rejected when present in the file (`OPENSHELL_DB_URL` / `--db-url`). @@ -687,10 +696,12 @@ between a trace and its log lines. Store and compute-driver spans become children of the request span. Reconciliation, provider refresh, and driver-watch loops create their own operation spans because they have no inbound request to provide a parent. gRPC status is recorded when response -trailers arrive. +trailers arrive. Gateway spans carry resource attributes for the gateway +identity and configured compute driver. -The gateway forwards OTLP configuration and W3C trace context to managed -external drivers. Each driver exports under its own service name. +The gateway forwards OTLP configuration, its configured gateway name, and W3C +trace context to managed external drivers. Each driver exports under its own +service name and carries the gateway name as a resource attribute. Two invariants shape the failure behavior. Telemetry is diagnostic, so no OTLP failure stops the gateway from serving: a malformed endpoint is logged at diff --git a/crates/openshell-core/src/config.rs b/crates/openshell-core/src/config.rs index fcbdeb73b..76ea4c55c 100644 --- a/crates/openshell-core/src/config.rs +++ b/crates/openshell-core/src/config.rs @@ -28,6 +28,9 @@ pub const DEFAULT_SSH_PORT: u16 = 2222; /// Default gateway server port. pub const DEFAULT_SERVER_PORT: u16 = 17670; +/// Default operator-facing name for a gateway installation. +pub const DEFAULT_GATEWAY_NAME: &str = "openshell"; + /// Default container stop timeout in seconds (SIGTERM → SIGKILL). pub const DEFAULT_STOP_TIMEOUT_SECS: u32 = 10; @@ -412,6 +415,9 @@ fn docker_socket_responds(path: &Path) -> bool { /// `Deserialize` impls for that purpose). #[derive(Debug, Clone)] pub struct Config { + /// Operator-assigned name for this gateway installation. + pub name: String, + /// Address to bind the server to. pub bind_address: SocketAddr, @@ -819,6 +825,7 @@ impl Config { /// Create a new config with optional TLS. pub fn new(tls: Option) -> Self { Self { + name: DEFAULT_GATEWAY_NAME.to_string(), bind_address: default_bind_address(), health_bind_address: None, metrics_bind_address: None, @@ -846,6 +853,13 @@ impl Config { } } + /// Create a new configuration with the gateway installation name. + #[must_use] + pub fn with_name(mut self, name: impl Into) -> Self { + self.name = name.into(); + self + } + /// Create a new configuration with the given bind address. #[must_use] pub const fn with_bind_address(mut self, addr: SocketAddr) -> Self { @@ -1219,6 +1233,15 @@ mod tests { assert_eq!(cfg.ttl_secs, 0); } + #[test] + fn name_defaults_and_can_be_overridden() { + assert_eq!(Config::new(None).name, "openshell"); + assert_eq!( + Config::new(None).with_name("production-us-west").name, + "production-us-west" + ); + } + #[test] fn gateway_interceptor_failure_policy_rejects_ignore() { let err = diff --git a/crates/openshell-driver-vm/src/main.rs b/crates/openshell-driver-vm/src/main.rs index 949d4ce05..95ebf0f8b 100644 --- a/crates/openshell-driver-vm/src/main.rs +++ b/crates/openshell-driver-vm/src/main.rs @@ -91,6 +91,9 @@ struct Args { #[arg(long, env = "OPENSHELL_OTLP_ENDPOINT")] otlp_endpoint: Option, + #[arg(long, env = "OPENSHELL_GATEWAY_NAME")] + gateway_name: Option, + #[arg(long, env = "OPENSHELL_GRPC_ENDPOINT")] openshell_endpoint: Option, @@ -186,8 +189,10 @@ async fn main() -> Result<()> { return Ok(()); } - let (tracer_provider, setup_error) = - openshell_driver_vm::otel_tracing::provider_for(args.otlp_endpoint.as_deref()); + let (tracer_provider, setup_error) = openshell_driver_vm::otel_tracing::provider_for( + args.otlp_endpoint.as_deref(), + args.gateway_name.as_deref(), + ); tracing_subscriber::registry() .with(EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(&args.log_level))) .with(tracing_subscriber::fmt::layer()) @@ -691,11 +696,13 @@ mod tests { } #[test] - fn accepts_gateway_otlp_endpoint() { + fn accepts_gateway_otlp_configuration() { let args = Args::try_parse_from([ "openshell-driver-vm", "--otlp-endpoint", "http://127.0.0.1:4317", + "--gateway-name", + "production-us-west", ]); assert!( args.is_ok(), diff --git a/crates/openshell-driver-vm/src/otel_tracing.rs b/crates/openshell-driver-vm/src/otel_tracing.rs index adfeb896c..9da6e17ed 100644 --- a/crates/openshell-driver-vm/src/otel_tracing.rs +++ b/crates/openshell-driver-vm/src/otel_tracing.rs @@ -81,14 +81,28 @@ fn compute_driver_rpc_operation(path: &str) -> (&'static str, &'static str) { } } -/// Build a tracer provider for the configured OTLP/gRPC endpoint. +/// Build a tracer provider for the configured OTLP/gRPC endpoint and gateway. #[must_use] -pub fn provider_for(endpoint: Option<&str>) -> (Option, Option) { - openshell_otel::provider_for(endpoint.map(|endpoint| OtlpTraceConfig { - endpoint, - service_name: ServiceName::Fixed(SERVICE_NAME), - service_version: Some(openshell_core::VERSION), - resource_attributes: Vec::new(), +pub fn provider_for( + endpoint: Option<&str>, + gateway_name: Option<&str>, +) -> (Option, Option) { + openshell_otel::provider_for(endpoint.map(|endpoint| { + OtlpTraceConfig { + endpoint, + service_name: ServiceName::Fixed(SERVICE_NAME), + service_version: Some(openshell_core::VERSION), + resource_attributes: gateway_name + .map(str::trim) + .filter(|name| !name.is_empty()) + .map(|name| { + vec![opentelemetry::KeyValue::new( + "openshell.gateway.name", + name.to_string(), + )] + }) + .unwrap_or_default(), + } })) } @@ -115,6 +129,7 @@ mod tests { struct Received { spans: Vec, service_names: Vec, + gateway_names: Vec, } #[derive(Clone)] @@ -133,18 +148,19 @@ mod tests { let mut received = self.received.lock().unwrap(); for resource_span in request.into_inner().resource_spans { if let Some(resource) = resource_span.resource { - received.service_names.extend( - resource - .attributes - .into_iter() - .filter(|attribute| attribute.key == "service.name") - .filter_map(|attribute| attribute.value) - .filter_map(|value| value.value) - .filter_map(|value| match value { - opentelemetry_proto::tonic::common::v1::any_value::Value::StringValue(value) => Some(value), - _ => None, - }), - ); + for attribute in resource.attributes { + let Some(value) = attribute.value.and_then(|value| value.value) else { + continue; + }; + let opentelemetry_proto::tonic::common::v1::any_value::Value::StringValue(value) = value else { + continue; + }; + match attribute.key.as_str() { + "service.name" => received.service_names.push(value), + "openshell.gateway.name" => received.gateway_names.push(value), + _ => {} + } + } } for scope_span in resource_span.scope_spans { received.spans.extend(scope_span.spans); @@ -204,7 +220,7 @@ mod tests { } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn vm_driver_spans_reach_otlp_collector_with_distinct_service_name() { + async fn vm_driver_spans_reach_otlp_collector_with_resource_identity() { let received = Arc::new(Mutex::new(Received::default())); let exported = Arc::new(tokio::sync::Notify::new()); let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); @@ -226,7 +242,10 @@ mod tests { .await }); - let (provider, error) = super::provider_for(Some(&format!("http://{address}"))); + let (provider, error) = super::provider_for( + Some(&format!("http://{address}")), + Some("production-us-west"), + ); assert!(error.is_none(), "valid OTLP endpoint should configure"); let provider = provider.expect("provider"); let subscriber = tracing_subscriber::registry().with(super::layer(&provider)); @@ -265,5 +284,6 @@ mod tests { "VM spans should use a distinct service name, got {:?}", received.service_names ); + assert_eq!(received.gateway_names, ["production-us-west"]); } } diff --git a/crates/openshell-server/src/cli.rs b/crates/openshell-server/src/cli.rs index 2e86c3a1b..841b7fb3e 100644 --- a/crates/openshell-server/src/cli.rs +++ b/crates/openshell-server/src/cli.rs @@ -7,7 +7,7 @@ use clap::parser::ValueSource; use clap::{ArgAction, ArgMatches, Command, CommandFactory, FromArgMatches, Parser}; use miette::{IntoDiagnostic, Result}; use openshell_core::ComputeDriverKind; -use openshell_core::config::DEFAULT_SERVER_PORT; +use openshell_core::config::{DEFAULT_GATEWAY_NAME, DEFAULT_SERVER_PORT}; use std::net::{IpAddr, SocketAddr}; use std::path::PathBuf; use tracing::{error, info, warn}; @@ -17,7 +17,9 @@ use crate::certgen; use crate::compute::driver_config::GuestTlsPaths; use crate::config_file::{self, ConfigFile, GatewayFileSection}; use crate::defaults::{self, LocalTlsPaths}; -use crate::{ServerStartupConfig, run_server, tracing_bus::TracingLogBus}; +use crate::{ + ServerStartupConfig, configured_compute_driver_name, run_server, tracing_bus::TracingLogBus, +}; /// `OpenShell` gateway process - gRPC and HTTP server with protocol multiplexing. /// @@ -52,6 +54,14 @@ struct RunArgs { #[arg(long, env = "OPENSHELL_GATEWAY_CONFIG")] config: Option, + /// Operator-assigned name for this gateway installation. + #[arg( + long = "name", + default_value = DEFAULT_GATEWAY_NAME, + env = "OPENSHELL_GATEWAY_NAME" + )] + name: String, + /// IP address to bind the server, health, and metrics listeners to. #[arg(long, default_value = "127.0.0.1", env = "OPENSHELL_BIND_ADDRESS")] bind_address: IpAddr, @@ -323,7 +333,13 @@ fn prepare_server_config(args: &mut RunArgs, matches: &ArgMatches) -> Result Result<()> { .config_file .as_ref() .and_then(|f| f.openshell.gateway.otlp.as_ref()); + let compute_driver_name = configured_compute_driver_name(&prepared)?; + let gateway_resource = crate::otel_tracing::GatewayResourceAttributes::new( + Some(prepared.config.name.as_str()), + Some(compute_driver_name.as_str()), + ); let (tracing_handle, setup_error) = crate::tracing_setup::install( EnvFilter::try_from_default_env() .unwrap_or_else(|_| EnvFilter::new(&prepared.config.log_level)), &tracing_log_bus, otlp_config, + gateway_resource, ); let has_client_ca = prepared @@ -631,6 +653,11 @@ fn resolve_aux_listener( /// The function intentionally does not touch `database_url` — that secret is /// env-only and the loader already rejected it when it appears in the file. fn merge_file_into_args(args: &mut RunArgs, file: &GatewayFileSection, matches: &ArgMatches) { + if let Some(name) = &file.name + && arg_defaulted(matches, "name") + { + args.name.clone_from(name); + } if let Some(addr) = file.bind_address { if arg_defaulted(matches, "bind_address") { args.bind_address = addr.ip(); @@ -1364,12 +1391,14 @@ enabled = false let _g1 = EnvVarGuard::remove("OPENSHELL_BIND_ADDRESS"); let _g2 = EnvVarGuard::remove("OPENSHELL_SERVER_PORT"); let _g3 = EnvVarGuard::remove("OPENSHELL_LOG_LEVEL"); + let _g4 = EnvVarGuard::remove("OPENSHELL_GATEWAY_NAME"); let (mut args, matches) = parse_with_args(&["openshell-gateway", "--db-url", "sqlite::memory:"]); let file = config_file_from_toml( r#" [openshell.gateway] +name = "production-us-west" bind_address = "0.0.0.0:9090" log_level = "debug" "#, @@ -1379,6 +1408,7 @@ log_level = "debug" assert_eq!(args.bind_address, IpAddr::V4(Ipv4Addr::UNSPECIFIED)); assert_eq!(args.port, 9090); assert_eq!(args.log_level, "debug"); + assert_eq!(args.name, "production-us-west"); } #[test] @@ -1388,6 +1418,7 @@ log_level = "debug" .unwrap_or_else(std::sync::PoisonError::into_inner); let _g1 = EnvVarGuard::remove("OPENSHELL_BIND_ADDRESS"); let _g2 = EnvVarGuard::remove("OPENSHELL_LOG_LEVEL"); + let _g3 = EnvVarGuard::remove("OPENSHELL_GATEWAY_NAME"); let (mut args, matches) = parse_with_args(&[ "openshell-gateway", @@ -1395,16 +1426,20 @@ log_level = "debug" "sqlite::memory:", "--log-level", "warn", + "--name", + "cli-gateway", ]); let file = config_file_from_toml( r#" [openshell.gateway] +name = "file-gateway" log_level = "debug" "#, ); merge_file_into_args(&mut args, &file.openshell.gateway, &matches); assert_eq!(args.log_level, "warn", "CLI flag must win over file"); + assert_eq!(args.name, "cli-gateway"); } #[test] @@ -1413,18 +1448,21 @@ log_level = "debug" .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); let _g = EnvVarGuard::set("OPENSHELL_LOG_LEVEL", "trace"); + let _g2 = EnvVarGuard::set("OPENSHELL_GATEWAY_NAME", "env-gateway"); let (mut args, matches) = parse_with_args(&["openshell-gateway", "--db-url", "sqlite::memory:"]); let file = config_file_from_toml( r#" [openshell.gateway] +name = "file-gateway" log_level = "debug" "#, ); merge_file_into_args(&mut args, &file.openshell.gateway, &matches); assert_eq!(args.log_level, "trace", "env var must win over file"); + assert_eq!(args.name, "env-gateway"); } #[test] diff --git a/crates/openshell-server/src/compute/vm.rs b/crates/openshell-server/src/compute/vm.rs index 80b445d20..6a66fc8aa 100644 --- a/crates/openshell-server/src/compute/vm.rs +++ b/crates/openshell-server/src/compute/vm.rs @@ -478,7 +478,7 @@ pub async fn spawn( .arg("--expected-peer-pid") .arg(std::process::id().to_string()); command.arg("--log-level").arg(&config.log_level); - append_otlp_args(&mut command, otlp_config); + append_otlp_args(&mut command, otlp_config, &config.name); command .arg("--openshell-endpoint") .arg(&vm_config.grpc_endpoint); @@ -521,9 +521,10 @@ pub async fn spawn( } #[cfg(unix)] -fn append_otlp_args(command: &mut Command, otlp_config: Option<&OtlpConfig>) { +fn append_otlp_args(command: &mut Command, otlp_config: Option<&OtlpConfig>, gateway_name: &str) { if let Some(config) = otlp_config { command.arg("--otlp-endpoint").arg(&config.endpoint); + command.arg("--gateway-name").arg(gateway_name); } } @@ -621,7 +622,7 @@ mod tests { use tempfile::tempdir; #[test] - fn vm_driver_command_includes_gateway_otlp_endpoint() { + fn vm_driver_command_includes_gateway_otlp_configuration() { let mut command = tokio::process::Command::new("openshell-driver-vm"); append_otlp_args( &mut command, @@ -629,6 +630,7 @@ mod tests { endpoint: "http://collector.internal:4317".to_string(), service_name: Some("custom-gateway".to_string()), }), + "production-us-west", ); let args = command @@ -636,7 +638,15 @@ mod tests { .get_args() .map(|arg| arg.to_string_lossy().into_owned()) .collect::>(); - assert_eq!(args, ["--otlp-endpoint", "http://collector.internal:4317"]); + assert_eq!( + args, + [ + "--otlp-endpoint", + "http://collector.internal:4317", + "--gateway-name", + "production-us-west" + ] + ); } #[tokio::test] diff --git a/crates/openshell-server/src/config_file.rs b/crates/openshell-server/src/config_file.rs index 00b7a2f64..087850d15 100644 --- a/crates/openshell-server/src/config_file.rs +++ b/crates/openshell-server/src/config_file.rs @@ -87,6 +87,11 @@ pub struct OpenShellRoot { #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct GatewayFileSection { + // ── Identity ───────────────────────────────────────────────────────── + /// Operator-assigned name for this gateway installation. + #[serde(default)] + pub name: Option, + // ── Listeners ──────────────────────────────────────────────────────── #[serde(default)] pub bind_address: Option, diff --git a/crates/openshell-server/src/lib.rs b/crates/openshell-server/src/lib.rs index a6031a9bc..591f48100 100644 --- a/crates/openshell-server/src/lib.rs +++ b/crates/openshell-server/src/lib.rs @@ -583,13 +583,8 @@ pub(crate) async fn run_server( let sandbox_index = SandboxIndex::new(); let sandbox_watch_bus = SandboxWatchBus::new(); let supervisor_sessions = Arc::new(supervisor_session::SupervisorSessionRegistry::new()); - let driver_startup = compute::driver_config::DriverStartupContext { - file: config_file.as_ref(), - guest_tls: guest_tls.as_ref(), - gateway_port: config.bind_address.port(), - gateway_tls_enabled: config.tls.is_some(), - endpoint_overrides: &config.compute_driver_endpoints, - }; + let driver_startup = + compute_driver_startup_context(&config, config_file.as_ref(), guest_tls.as_ref()); let (compute, operator_allowlist) = build_compute_runtime( &config, driver_startup, @@ -1205,6 +1200,30 @@ async fn build_compute_runtime( Ok((runtime, operator_allowlist)) } +pub(crate) fn configured_compute_driver_name(startup: &ServerStartupConfig) -> Result { + let driver_startup = compute_driver_startup_context( + &startup.config, + startup.config_file.as_ref(), + startup.guest_tls.as_ref(), + ); + configured_compute_driver(&startup.config, driver_startup) + .map(|driver| driver.name().to_string()) +} + +fn compute_driver_startup_context<'a>( + config: &'a Config, + config_file: Option<&'a config_file::ConfigFile>, + guest_tls: Option<&'a compute::driver_config::GuestTlsPaths>, +) -> compute::driver_config::DriverStartupContext<'a> { + compute::driver_config::DriverStartupContext { + file: config_file, + guest_tls, + gateway_port: config.bind_address.port(), + gateway_tls_enabled: config.tls.is_some(), + endpoint_overrides: &config.compute_driver_endpoints, + } +} + #[derive(Debug, Clone)] enum ConfiguredComputeDriver { Builtin(ComputeDriverKind), @@ -1347,7 +1366,7 @@ mod tests { BoundGatewayListener, ConfiguredComputeDriver, ConnectionProtocol, ExtensionKind, GatewayListenerScope, MultiplexService, ServerState, TlsAcceptor, allow_plaintext_service_http, bind_gateway_listeners, classify_initial_bytes, - configured_compute_driver, is_benign_tls_handshake_failure, + configured_compute_driver, configured_compute_driver_name, is_benign_tls_handshake_failure, kubernetes_sandbox_jwt_expiry_disabled, mint_gateway_extension_credential, serve_gateway_listener, }; @@ -1860,6 +1879,17 @@ mod tests { )); } + #[test] + fn configured_compute_driver_name_uses_effective_driver_resolution() { + let startup = crate::ServerStartupConfig { + config: Config::new(None).with_compute_drivers([ComputeDriverKind::Podman]), + config_file: None, + guest_tls: None, + }; + + assert_eq!(configured_compute_driver_name(&startup).unwrap(), "podman"); + } + #[test] fn configured_compute_driver_resolves_named_remote() { let config = Config::new(None).with_compute_drivers(["kyma"]); diff --git a/crates/openshell-server/src/otel_tracing.rs b/crates/openshell-server/src/otel_tracing.rs index cbe23f423..d09de7203 100644 --- a/crates/openshell-server/src/otel_tracing.rs +++ b/crates/openshell-server/src/otel_tracing.rs @@ -37,7 +37,26 @@ const DEFAULT_SERVICE_NAME: &str = "openshell-gateway"; /// Instrumentation scope recorded on spans this gateway emits. const INSTRUMENTATION_SCOPE: &str = "openshell-gateway"; -fn trace_config(cfg: &OtlpConfig) -> OtlpTraceConfig<'_> { +/// Gateway identity recorded on every exported span. +#[derive(Debug, Clone, Copy, Default)] +pub struct GatewayResourceAttributes<'a> { + name: Option<&'a str>, + compute_driver: Option<&'a str>, +} + +impl<'a> GatewayResourceAttributes<'a> { + pub fn new(name: Option<&'a str>, compute_driver: Option<&'a str>) -> Self { + Self { + name, + compute_driver, + } + } +} + +fn trace_config<'cfg>( + cfg: &'cfg OtlpConfig, + gateway: GatewayResourceAttributes<'_>, +) -> OtlpTraceConfig<'cfg> { let service_name = cfg .service_name .as_deref() @@ -48,17 +67,35 @@ fn trace_config(cfg: &OtlpConfig) -> OtlpTraceConfig<'_> { ServiceName::Fixed, ); + let mut resource_attributes = Vec::new(); + if let Some(name) = gateway.name.map(str::trim).filter(|s| !s.is_empty()) { + resource_attributes.push(opentelemetry::KeyValue::new( + "openshell.gateway.name", + name.to_string(), + )); + } + if let Some(compute_driver) = gateway + .compute_driver + .map(str::trim) + .filter(|s| !s.is_empty()) + { + resource_attributes.push(opentelemetry::KeyValue::new( + "openshell.gateway.compute_driver", + compute_driver.to_string(), + )); + } + OtlpTraceConfig { endpoint: &cfg.endpoint, service_name, service_version: Some(openshell_core::VERSION), - resource_attributes: Vec::new(), + resource_attributes, } } #[cfg(test)] -fn build_resource(cfg: &OtlpConfig) -> Resource { - openshell_otel::resource_for(&trace_config(cfg)) +fn build_resource(cfg: &OtlpConfig, gateway: GatewayResourceAttributes<'_>) -> Resource { + openshell_otel::resource_for(&trace_config(cfg, gateway)) } /// Build a tracer provider exporting over OTLP/gRPC to the configured endpoint. @@ -70,8 +107,11 @@ fn build_resource(cfg: &OtlpConfig) -> Resource { /// The sampler and span limits are left at the SDK's defaults, which are /// themselves resolved from `OTEL_*` env vars (see the module docs). #[cfg(test)] -fn build_provider(cfg: &OtlpConfig) -> Result { - openshell_otel::build_provider(&trace_config(cfg)) +fn build_provider( + cfg: &OtlpConfig, + gateway: GatewayResourceAttributes<'_>, +) -> Result { + openshell_otel::build_provider(&trace_config(cfg, gateway)) } /// Resolve the tracer provider for a gateway config file's optional @@ -82,8 +122,11 @@ fn build_provider(cfg: &OtlpConfig) -> Result { /// /// The error is returned rather than logged because the provider is built /// before the subscriber it attaches to, so logging here would go nowhere. -pub fn provider_for(cfg: Option<&OtlpConfig>) -> (Option, Option) { - openshell_otel::provider_for(cfg.map(trace_config)) +pub fn provider_for( + cfg: Option<&OtlpConfig>, + gateway: GatewayResourceAttributes<'_>, +) -> (Option, Option) { + openshell_otel::provider_for(cfg.map(|cfg| trace_config(cfg, gateway))) } /// Build the `tracing` layer that forwards spans to `provider`. @@ -253,6 +296,10 @@ mod tests { } } + fn build_test_resource(cfg: &OtlpConfig) -> Resource { + build_resource(cfg, GatewayResourceAttributes::default()) + } + #[test] fn resource_defaults_the_service_name() { let _lock = crate::TEST_ENV_LOCK @@ -261,7 +308,7 @@ mod tests { let _env = EnvVarGuard::remove("OTEL_SERVICE_NAME"); assert_eq!( - service_name_of(&build_resource(&config())), + service_name_of(&build_test_resource(&config())), Some(DEFAULT_SERVICE_NAME.to_string()) ); } @@ -270,7 +317,7 @@ mod tests { fn resource_honors_configured_service_name_and_carries_version() { let mut cfg = config(); cfg.service_name = Some("gateway-staging".into()); - let resource = build_resource(&cfg); + let resource = build_test_resource(&cfg); assert_eq!( resource @@ -286,6 +333,31 @@ mod tests { ); } + #[test] + fn resource_carries_gateway_name_and_compute_driver() { + let resource = build_resource( + &config(), + GatewayResourceAttributes::new(Some("vm-dev"), Some("vm")), + ); + + assert_eq!( + resource + .get(&opentelemetry::Key::from_static_str( + "openshell.gateway.name", + )) + .map(|v| v.to_string()), + Some("vm-dev".to_string()) + ); + assert_eq!( + resource + .get(&opentelemetry::Key::from_static_str( + "openshell.gateway.compute_driver", + )) + .map(|v| v.to_string()), + Some("vm".to_string()) + ); + } + struct EnvVarGuard { key: &'static str, original: Option, @@ -340,7 +412,7 @@ mod tests { cfg.service_name = Some("from-config".into()); assert_eq!( - service_name_of(&build_resource(&cfg)), + service_name_of(&build_test_resource(&cfg)), Some("from-config".to_string()) ); } @@ -355,7 +427,7 @@ mod tests { let _env = EnvVarGuard::set("OTEL_SERVICE_NAME", "from-env"); assert_eq!( - service_name_of(&build_resource(&config())), + service_name_of(&build_test_resource(&config())), Some("from-env".to_string()) ); } @@ -370,7 +442,7 @@ mod tests { let mut cfg = config(); cfg.service_name = Some(" ".into()); assert_eq!( - service_name_of(&build_resource(&cfg)), + service_name_of(&build_test_resource(&cfg)), Some(DEFAULT_SERVICE_NAME.to_string()) ); } @@ -379,7 +451,8 @@ mod tests { fn provider_rejects_a_malformed_endpoint() { let mut cfg = config(); cfg.endpoint = "definitely not a url".into(); - let err = build_provider(&cfg).expect_err("malformed endpoint"); + let err = build_provider(&cfg, GatewayResourceAttributes::default()) + .expect_err("malformed endpoint"); assert!( err.to_string().contains("definitely not a url"), "error names the offending endpoint: {err}" @@ -390,7 +463,10 @@ mod tests { fn provider_rejects_an_empty_endpoint() { let mut cfg = config(); cfg.endpoint = " ".into(); - assert!(build_provider(&cfg).is_err(), "empty endpoint is rejected"); + assert!( + build_provider(&cfg, GatewayResourceAttributes::default()).is_err(), + "empty endpoint is rejected" + ); } #[tokio::test] @@ -398,7 +474,8 @@ mod tests { // The OTLP batch exporter connects lazily, so a valid endpoint must // build even when nothing is listening — the gateway must not fail to // start because its collector is down. - let provider = build_provider(&config()).expect("provider builds"); + let provider = build_provider(&config(), GatewayResourceAttributes::default()) + .expect("provider builds"); provider.shutdown().ok(); } @@ -407,7 +484,7 @@ mod tests { /// error for the caller to log — see the misconfigured-endpoint test. #[tokio::test] async fn absent_otlp_table_disables_export() { - let (provider, err) = provider_for(None); + let (provider, err) = provider_for(None, GatewayResourceAttributes::default()); assert!(provider.is_none(), "export is off"); assert!( err.is_none(), @@ -417,7 +494,7 @@ mod tests { #[tokio::test] async fn present_otlp_table_enables_export() { - let (provider, err) = provider_for(Some(&config())); + let (provider, err) = provider_for(Some(&config()), GatewayResourceAttributes::default()); assert!(err.is_none()); provider.expect("provider is present").shutdown().ok(); } @@ -430,7 +507,7 @@ mod tests { let mut cfg = config(); cfg.endpoint = "definitely not a url".into(); - let (provider, err) = provider_for(Some(&cfg)); + let (provider, err) = provider_for(Some(&cfg), GatewayResourceAttributes::default()); assert!( provider.is_none(), "a bad endpoint degrades to no export rather than failing startup" diff --git a/crates/openshell-server/src/tracing_setup.rs b/crates/openshell-server/src/tracing_setup.rs index 321edefaf..e440bbd3b 100644 --- a/crates/openshell-server/src/tracing_setup.rs +++ b/crates/openshell-server/src/tracing_setup.rs @@ -12,7 +12,7 @@ use tracing_subscriber::EnvFilter; use tracing_subscriber::prelude::*; use crate::config_file::OtlpConfig; -use crate::otel_tracing::SetupError; +use crate::otel_tracing::{GatewayResourceAttributes, SetupError}; use crate::tracing_bus::TracingLogBus; pub struct TracingHandle { @@ -33,8 +33,9 @@ pub fn install( env_filter: EnvFilter, tracing_log_bus: &TracingLogBus, otlp_config: Option<&OtlpConfig>, + gateway: GatewayResourceAttributes<'_>, ) -> (TracingHandle, Option) { - let (tracer_provider, setup_error) = crate::otel_tracing::provider_for(otlp_config); + let (tracer_provider, setup_error) = crate::otel_tracing::provider_for(otlp_config, gateway); tracing_subscriber::registry() .with(env_filter) diff --git a/deploy/helm/openshell/README.md b/deploy/helm/openshell/README.md index 93dab354b..07c490bed 100644 --- a/deploy/helm/openshell/README.md +++ b/deploy/helm/openshell/README.md @@ -240,6 +240,7 @@ add `ci/values-spire.yaml` to the OpenShell release values files. | server.grpcRateLimit.windowSeconds | int | `0` | gRPC rate-limit window length in seconds. Must be positive (alongside requests) to enable rate limiting; 0 (default) disables it. | | server.hostGatewayIP | string | `""` | Host gateway IP for sandbox pod hostAliases. When set, sandbox pods get hostAliases entries mapping host.docker.internal and host.openshell.internal to this IP, allowing them to reach services running on the Docker host. Auto-detected by the cluster entrypoint script. | | server.logLevel | string | `"info"` | Gateway log level. | +| server.name | string | `""` | Operator-facing gateway name. Defaults to the chart fullname so all replicas in one installation share an identity. Set explicitly when one telemetry collector receives spans from multiple namespaces or clusters. | | server.oidc.adminRole | string | `""` | Role name for admin access. Leave empty (with userRole also empty) for authentication-only mode. Both must be set or both empty. | | server.oidc.audience | string | `"openshell-cli"` | Expected audience claim for the API resource server. This should match the server's --oidc-audience, NOT the CLI client ID. | | server.oidc.caConfigMapName | string | `""` | Name of a ConfigMap containing a CA certificate bundle (key: ca.crt) for verifying the OIDC issuer's TLS certificate. Required when the issuer uses a non-public CA (e.g. OpenShift ingress, private PKI). | diff --git a/deploy/helm/openshell/templates/gateway-config.yaml b/deploy/helm/openshell/templates/gateway-config.yaml index 9d24dbd91..cb105f958 100644 --- a/deploy/helm/openshell/templates/gateway-config.yaml +++ b/deploy/helm/openshell/templates/gateway-config.yaml @@ -34,6 +34,7 @@ data: version = 1 [openshell.gateway] + name = {{ .Values.server.name | default (include "openshell.fullname" .) | quote }} bind_address = "0.0.0.0:{{ .Values.service.port }}" {{- if .Values.service.healthPort }} health_bind_address = "0.0.0.0:{{ .Values.service.healthPort }}" diff --git a/deploy/helm/openshell/tests/gateway_config_test.yaml b/deploy/helm/openshell/tests/gateway_config_test.yaml index afacd01eb..7d60bca11 100644 --- a/deploy/helm/openshell/tests/gateway_config_test.yaml +++ b/deploy/helm/openshell/tests/gateway_config_test.yaml @@ -11,6 +11,22 @@ release: namespace: my-namespace tests: + - it: identifies the gateway by chart fullname by default + template: templates/gateway-config.yaml + asserts: + - matchRegex: + path: data["gateway.toml"] + pattern: '(?m)^name\s*=\s*"openshell"$' + + - it: renders an explicit gateway name + template: templates/gateway-config.yaml + set: + server.name: production-us-west + asserts: + - matchRegex: + path: data["gateway.toml"] + pattern: '(?m)^name\s*=\s*"production-us-west"$' + # Regression for Drew's P2: a ConfigMap-only mutation in `helm upgrade` # must roll the StatefulSet, otherwise pods keep running with stale config. - it: annotates the StatefulSet pod template with a ConfigMap checksum diff --git a/deploy/helm/openshell/values.yaml b/deploy/helm/openshell/values.yaml index 33337c768..bbab455b1 100644 --- a/deploy/helm/openshell/values.yaml +++ b/deploy/helm/openshell/values.yaml @@ -179,6 +179,10 @@ affinity: {} # Server configuration server: + # -- Operator-facing gateway name. Defaults to the chart fullname so all + # replicas in one installation share an identity. Set explicitly when one + # telemetry collector receives spans from multiple namespaces or clusters. + name: "" # -- Gateway log level. logLevel: info # -- Enable anonymous OpenShell telemetry from the gateway and the sandbox diff --git a/docs/reference/gateway-config.mdx b/docs/reference/gateway-config.mdx index 85e965a35..95697f6b9 100644 --- a/docs/reference/gateway-config.mdx +++ b/docs/reference/gateway-config.mdx @@ -18,6 +18,8 @@ Gateway CLI flag > gateway OPENSHELL_* env var > TOML file > built-in defa `database_url` is env-only. The loader rejects it when it appears in the file. When `OPENSHELL_DB_URL` is unset, the gateway stores its SQLite database under `$XDG_STATE_HOME/openshell/gateway/openshell.db`. +`name` assigns an operator-facing identity to the gateway installation. Set it with `[openshell.gateway].name`, `--name`, or `OPENSHELL_GATEWAY_NAME`. It defaults to `openshell`; the Helm chart defaults it to the chart fullname so all replicas in one installation share a name. Chart fullnames are only unique within their Kubernetes namespace, so set `server.name` explicitly when one collector receives telemetry from multiple namespaces or clusters. This identity is independent of client-side gateway aliases, TLS names, and `gateway_jwt.gateway_id`. + ## Package-Managed Locations Package-managed gateways do not require a TOML file. Create one at the package's optional config location when you need to override built-in defaults. Set `OPENSHELL_GATEWAY_CONFIG` in the launch environment to use a different file. @@ -69,6 +71,7 @@ A complete gateway configuration covering every section. Trim to the fields you version = 1 [openshell.gateway] +name = "production-us-west" bind_address = "0.0.0.0:8080" health_bind_address = "0.0.0.0:8081" metrics_bind_address = "0.0.0.0:9090" @@ -228,11 +231,11 @@ The transport is **OTLP over gRPC only**. HTTP/protobuf and HTTP/JSON are not su The OpenTelemetry SDK logs export failures after startup. Spans in a failed batch are dropped rather than retried. -`service_name` sets the gateway's `service.name` resource attribute and defaults to `openshell-gateway`. The gateway also reports `service.version`. +`service_name` sets the gateway's `service.name` resource attribute and defaults to `openshell-gateway`. The gateway also reports `service.version`, `openshell.gateway.name` from the gateway's configured `name`, and `openshell.gateway.compute_driver`. Only OpenTelemetry traces are exported. Inbound gRPC and HTTP requests produce server spans named for the RPC or HTTP method. Store and compute-driver operations appear as child spans. Internal reconciliation, credential-refresh, and driver-watch loops create operation roots for their store work because no inbound request supplies a parent. The gateway continues valid W3C `traceparent` context and starts a new trace when none is supplied. Request spans carry `method`, `path`, and the `request_id` that also appears in gateway logs. Health endpoint spans use DEBUG level and are not exported by the default INFO filter. -The gateway forwards the OTLP configuration to managed external drivers. Each driver exports under its own service name. +The gateway forwards the OTLP configuration and configured gateway name to managed external drivers. Each driver exports under its own service name while carrying the same `openshell.gateway.name` resource attribute. Operator-run external drivers own their own telemetry configuration. ### Tuning