Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 14 additions & 3 deletions architecture/gateway.md
Original file line number Diff line number Diff line change
Expand Up @@ -606,6 +606,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`).

Expand Down Expand Up @@ -654,10 +663,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
Expand Down
23 changes: 23 additions & 0 deletions crates/openshell-core/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,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;

Expand Down Expand Up @@ -417,6 +420,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,

Expand Down Expand Up @@ -780,6 +786,7 @@ impl Config {
/// Create a new config with optional TLS.
pub fn new(tls: Option<TlsConfig>) -> Self {
Self {
name: DEFAULT_GATEWAY_NAME.to_string(),
bind_address: default_bind_address(),
health_bind_address: None,
metrics_bind_address: None,
Expand Down Expand Up @@ -807,6 +814,13 @@ impl Config {
}
}

/// Create a new configuration with the gateway installation name.
#[must_use]
pub fn with_name(mut self, name: impl Into<String>) -> 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 {
Expand Down Expand Up @@ -1180,6 +1194,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 =
Expand Down
13 changes: 10 additions & 3 deletions crates/openshell-driver-vm/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,9 @@ struct Args {
#[arg(long, env = "OPENSHELL_OTLP_ENDPOINT")]
otlp_endpoint: Option<String>,

#[arg(long, env = "OPENSHELL_GATEWAY_NAME")]
gateway_name: Option<String>,

#[arg(long, env = "OPENSHELL_GRPC_ENDPOINT")]
openshell_endpoint: Option<String>,

Expand Down Expand Up @@ -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())
Expand Down Expand Up @@ -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(),
Expand Down
62 changes: 41 additions & 21 deletions crates/openshell-driver-vm/src/otel_tracing.rs
Original file line number Diff line number Diff line change
Expand Up @@ -80,14 +80,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<SdkTracerProvider>, Option<SetupError>) {
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<SdkTracerProvider>, Option<SetupError>) {
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(),
}
}))
}

Expand All @@ -114,6 +128,7 @@ mod tests {
struct Received {
spans: Vec<Span>,
service_names: Vec<String>,
gateway_names: Vec<String>,
}

#[derive(Clone)]
Expand All @@ -132,18 +147,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);
Expand Down Expand Up @@ -202,7 +218,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();
Expand All @@ -224,7 +240,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));
Expand Down Expand Up @@ -263,5 +282,6 @@ mod tests {
"VM spans should use a distinct service name, got {:?}",
received.service_names
);
assert_eq!(received.gateway_names, ["production-us-west"]);
}
}
37 changes: 36 additions & 1 deletion crates/openshell-server/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -52,6 +52,14 @@ struct RunArgs {
#[arg(long, env = "OPENSHELL_GATEWAY_CONFIG")]
config: Option<PathBuf>,

/// 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,
Expand Down Expand Up @@ -307,7 +315,13 @@ fn prepare_server_config(args: &mut RunArgs, matches: &ArgMatches) -> Result<Ser
.clone()
.expect("runtime defaults populate db_url");

let name = args.name.trim();
if name.is_empty() {
return Err(miette::miette!("gateway name must not be empty"));
}

let mut config = openshell_core::Config::new(tls)
.with_name(name)
.with_bind_address(bind)
.with_log_level(&args.log_level);
if let Some(auth) = file.as_ref().and_then(|f| f.openshell.gateway.auth.clone()) {
Expand Down Expand Up @@ -460,11 +474,16 @@ async fn run_from_args(mut args: RunArgs, matches: ArgMatches) -> Result<()> {
.config_file
.as_ref()
.and_then(|f| f.openshell.gateway.otlp.as_ref());
let gateway_resource = crate::otel_tracing::GatewayResourceAttributes::new(
Some(prepared.config.name.as_str()),
prepared.config.compute_drivers.first().map(String::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
Expand Down Expand Up @@ -615,6 +634,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();
Expand Down Expand Up @@ -1344,12 +1368,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"
"#,
Expand All @@ -1359,6 +1385,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]
Expand All @@ -1368,23 +1395,28 @@ 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",
"--db-url",
"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]
Expand All @@ -1393,18 +1425,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]
Expand Down
Loading
Loading