Files
OpenShell/tasks/scripts/gateway.sh
T
Roland Huss fdce6ef260 feat(supervisor): relay agent OTLP traces to the collector (#4276)
* feat(supervisor): relay agent OTLP traces to the collector

Serve the reserved endpoint 192.0.0.8:4318 inside the supervisor, attribute each batch with openshell.sandbox.id and openshell.telemetry.source=agent at the protobuf wire level, buffer it with fixed bounds, and forward it over OTLP/gRPC to the collector the supervisor already uses, flushing before the exit report and keeping the relay up for exec sessions.

Signed-off-by: Roland Huß <rhuss@redhat.com>

* refactor(supervisor): let the OTLP relay exporter take a prepared channel

Expose Exporter::with_channel as the seam for a collector transport built once in openshell-otel and shared by the gateway, the supervisor's own spans, and the relay. Exporter::new keeps building the plaintext channel for now.

Signed-off-by: Roland Huß <rhuss@redhat.com>

* feat(sandbox): leave caller-set OTLP endpoints alone

Inject the relay variables only when the sandbox creation request names no OTLP endpoint, generic or traces-specific. A caller who names one has chosen where the agent exports; overwriting it would silently redirect an agent that deliberately exports elsewhere. A protocol or compression setting alone does not suppress injection. Applies to both spawn paths and to exec children, with the docs updated to match.

Signed-off-by: Roland Huß <rhuss@redhat.com>

* fix(supervisor): address OTLP relay review feedback

Inject only the generic OTEL_EXPORTER_OTLP_ENDPOINT and
OTEL_EXPORTER_OTLP_PROTOCOL as lowest-precedence defaults, and only
while the relay is active. The supervisor now starts the relay before
attach and signals it to the sandbox runtime through
AgentSpec::otlp_relay_active. An endpoint in the creation request, the
image ENV, or an exec environment suppresses the defaults, and a
provider-supplied traces endpoint is removed.

Harden the relay itself:
- skip the exit flush and shutdown wait when the last export failed
- count batches still pending on Drop as discarded and log them
- validate the collector endpoint with the shared openshell-otel helper
- accept Content-Encoding: identity
- fix the 413 and shutdown 503 messages and the memory bound docs
- narrow the attribution claim to resource attributes

Document best-effort delivery and the 404 for metrics and logs.

Signed-off-by: Roland Huß <rhuss@redhat.com>

* feat(supervisor): gate the agent trace relay behind [otlp.agent]

The relay started whenever a sandbox supervisor had a trace endpoint, so
enabling gateway trace export also changed every workload's environment.
Make it opt-in: `[openshell.gateway.otlp.agent]` with `enabled = true`
turns it on, and its optional `endpoint` falls back to the shared
`[openshell.gateway.otlp]` endpoint, like the OCSF table.

The gateway validates the endpoint and drivers pass it to supervisors as
OPENSHELL_OTLP_AGENT_ENDPOINT, separate from OPENSHELL_OTLP_ENDPOINT.
Docker, Podman (including the Podman Machine host alias), Kubernetes and
VM drivers carry it, in-process and as standalone binaries. The shared
Kubernetes supervisor still does not relay.

The relay now acknowledges OTLP metrics and logs exports with an empty
success and discards them, so agent SDKs that export every signal to the
generic endpoint stop retrying. Discards are counted and logged once.

Local dev gateway tasks, the Skaffold values and the e2e harness enable
the table; OPENSHELL_E2E_OTLP_AGENT=0 runs the inert case with trace
export still on.

Signed-off-by: Roland Huß <rhuss@redhat.com>

* docs(supervisor): describe the agent trace relay architecture

Add an "Agent traces" section to the architecture page and document the
relay in the crate READMEs: the reserved-destination hook in the network
supervisor, the OTEL defaults the sandbox runtime injects, and how the
Docker, Podman, Kubernetes, and VM drivers pass the agent endpoint.

Signed-off-by: Roland Huß <rhuss@redhat.com>

* fix(sandbox): disable OTLP compression in the relay env and cover exec children

The relay refuses compressed bodies, so an image or creation request that
sets gzip compression without naming an endpoint received the relay default
and lost every trace export to a 415. The relay default now also sets
OTEL_EXPORTER_OTLP_COMPRESSION and OTEL_EXPORTER_OTLP_TRACES_COMPRESSION to
none, as #4275 asks.

Boundary exec children start from a cleared environment and never see the
image ENV, yet the relay default was suppressed when the sandbox's own
environment named an endpoint, which left exec children with no endpoint at
all. apply_otlp_relay_env now takes a ChildEnvironment so only children that
inherit the sandbox environment consult it.

Add a probe test for both TLS environment modes together with the relay
default, now that the mode-aware TLS layering lives in
compose_canonical_process_environment, and describe the whole composition
at its call sites.

Signed-off-by: Roland Huß <rhuss@redhat.com>

* fix(supervisor): flush the agent trace relay before suspending

The suspend lifecycle from #4765 returned without shutting the relay down,
so Drop discarded batches the relay had already acknowledged to the agent,
which does not resend them after resume. Shut the relay down, bounded by
FLUSH_TIMEOUT, once the workload is frozen and before networking is
dropped.

Also tidy what the rebases left behind: drop a stray blank line inside the
upstream startup block, rename SandboxInstanceConfig::agent_otlp_endpoint
to otlp_agent_endpoint to match every other identifier, serialize the OCSF
table through the generic serialize_enabled, correct the UnsupportedScheme
doc comment (the supervisor's own exporter does speak TLS), and build the
reserved-destination proxy test identities with host-native paths.

Signed-off-by: Roland Huß <rhuss@redhat.com>

* test(e2e): scope the Podman OTLP default and restore upstream lockfiles

The Podman wrapper enabled OTLP export and the agent relay for every e2e
run, including single-target CI jobs such as additional_ca. Enable it only
for the full suite or a selection that includes otlp_relay, following the
SPIFFE fixture pattern; the Docker wrapper stays opt-in.

The e2e and features lockfiles had downgraded libsqlite3-sys,
crossbeam-queue and hybrid-array against upstream with no manifest change
requiring it. Restore upstream's lockfiles and add only the packages the
new e2e dependencies pull in.

Signed-off-by: Roland Huß <rhuss@redhat.com>

---------

Signed-off-by: Roland Huß <rhuss@redhat.com>
2026-10-28 15:26:52 +00:00

317 lines
8.7 KiB
Bash

#!/usr/bin/env bash
# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
# Start a standalone openshell-gateway using the detected compute driver.
#
# Auto-detection follows the gateway's runtime order:
# Kubernetes -> Podman -> Docker
#
# VM/MicroVM is intentionally explicit-only because it requires runtime setup.
# Use either:
# OPENSHELL_COMPUTE_DRIVER=vm mise run gateway
# mise run gateway:vm
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
# shellcheck source=tasks/scripts/gateway-pull-policy.sh
source "${ROOT}/tasks/scripts/gateway-pull-policy.sh"
GATEWAY_BIN="${OPENSHELL_GATEWAY_BIN:-${ROOT}/target/debug/openshell-gateway}"
usage() {
cat <<'EOF'
Usage: mise run gateway [-- --driver DRIVER]
Start a local OpenShell gateway with the detected compute driver.
Driver detection order:
kubernetes -> podman -> docker
Options:
--driver DRIVER Override detection. Accepted values:
kubernetes, podman, docker, vm, microvm
-h, --help Show this help.
Environment:
OPENSHELL_COMPUTE_DRIVER Driver override used by openshell-gateway.
OPENSHELL_GATEWAY_NAME Gateway name for delegated or Kubernetes runs.
OPENSHELL_BIND_ADDRESS Gateway listener address. Defaults to 127.0.0.1,
or ::1 for Podman Machine on macOS.
OPENSHELL_SERVER_PORT Gateway port. Defaults to 8080 for Kubernetes,
18080 for Podman/Docker, and 18081 for VM.
Docker, Podman, and VM runs delegate to their gateway:<driver> setup scripts.
EOF
}
normalize_driver() {
local driver
driver="$(printf '%s' "$1" | tr '[:upper:]' '[:lower:]')"
case "${driver}" in
kubernetes|k8s) echo "kubernetes" ;;
podman) echo "podman" ;;
docker) echo "docker" ;;
vm|microvm) echo "vm" ;;
"")
echo "ERROR: empty driver value" >&2
exit 2
;;
*)
echo "ERROR: unsupported driver '$1' (expected kubernetes, podman, docker, vm, or microvm)" >&2
exit 2
;;
esac
}
command_available() {
command -v "$1" >/dev/null 2>&1
}
require_mise() {
if ! command_available mise; then
echo "ERROR: mise is required to build local gateway artifacts" >&2
exit 1
fi
}
run_mise_task() {
require_mise
mise run "$@"
}
podman_available() {
command_available podman && podman info >/dev/null 2>&1
}
docker_available() {
command_available docker && docker info >/dev/null 2>&1
}
detect_driver() {
if [[ -n "${KUBERNETES_SERVICE_HOST:-}" ]]; then
echo "kubernetes"
return
fi
if podman_available; then
echo "podman"
return
fi
if docker_available; then
echo "docker"
return
fi
echo "ERROR: no compute driver detected." >&2
echo " Start Podman or Docker, run inside Kubernetes, or set OPENSHELL_COMPUTE_DRIVER." >&2
exit 2
}
port_is_in_use() {
local port=$1
if command_available lsof; then
lsof -nP -iTCP:"${port}" -sTCP:LISTEN >/dev/null 2>&1
return $?
fi
if command_available nc; then
nc -z 127.0.0.1 "${port}" >/dev/null 2>&1
return $?
fi
(echo >/dev/tcp/127.0.0.1/"${port}") >/dev/null 2>&1
}
register_gateway_metadata() {
local name=$1
local endpoint=$2
local port=$3
local config_home gateway_dir
config_home="${XDG_CONFIG_HOME:-${HOME}/.config}"
gateway_dir="${config_home}/openshell/gateways/${name}"
mkdir -p "${gateway_dir}"
cat >"${gateway_dir}/metadata.json" <<EOF
{
"name": "${name}",
"gateway_endpoint": "${endpoint}",
"is_remote": false,
"gateway_port": ${port},
"auth_mode": "plaintext"
}
EOF
printf '%s' "${name}" >"${config_home}/openshell/active_gateway"
}
explicit_driver=""
while [[ "$#" -gt 0 ]]; do
case "$1" in
--driver)
if [[ "$#" -lt 2 ]]; then
echo "ERROR: --driver requires a value" >&2
exit 2
fi
explicit_driver="$(normalize_driver "$2")"
shift 2
;;
--driver=*)
explicit_driver="$(normalize_driver "${1#--driver=}")"
shift
;;
-h|--help)
usage
exit 0
;;
*)
echo "ERROR: unknown gateway option '$1'" >&2
echo >&2
usage >&2
exit 2
;;
esac
done
if [[ -n "${explicit_driver}" && -n "${OPENSHELL_COMPUTE_DRIVER:-}" ]]; then
echo "ERROR: use either --driver or OPENSHELL_COMPUTE_DRIVER, not both" >&2
exit 2
fi
if [[ -z "${explicit_driver}" && -n "${OPENSHELL_COMPUTE_DRIVER:-}" ]]; then
if [[ "${OPENSHELL_COMPUTE_DRIVER}" == *,* ]]; then
echo "ERROR: mise run gateway supports one driver; got OPENSHELL_COMPUTE_DRIVER=${OPENSHELL_COMPUTE_DRIVER}" >&2
exit 2
fi
explicit_driver="$(normalize_driver "${OPENSHELL_COMPUTE_DRIVER}")"
fi
DRIVER="${explicit_driver:-$(detect_driver)}"
case "${DRIVER}" in
docker)
export OPENSHELL_DOCKER_GATEWAY_NAME="${OPENSHELL_DOCKER_GATEWAY_NAME:-${OPENSHELL_GATEWAY_NAME:-docker-dev}}"
exec bash "${ROOT}/tasks/scripts/gateway-docker.sh"
;;
podman)
export OPENSHELL_PODMAN_GATEWAY_NAME="${OPENSHELL_PODMAN_GATEWAY_NAME:-${OPENSHELL_GATEWAY_NAME:-podman-dev}}"
exec bash "${ROOT}/tasks/scripts/gateway-podman.sh"
;;
vm)
export OPENSHELL_VM_GATEWAY_NAME="${OPENSHELL_VM_GATEWAY_NAME:-${OPENSHELL_GATEWAY_NAME:-vm-dev}}"
exec bash "${ROOT}/tasks/scripts/gateway-vm.sh"
;;
esac
PORT="${OPENSHELL_SERVER_PORT:-8080}"
GATEWAY_NAME="${OPENSHELL_GATEWAY_NAME:-${DRIVER}-dev}"
STATE_DIR="${OPENSHELL_GATEWAY_STATE_DIR:-${ROOT}/.cache/gateway-${DRIVER}}"
SANDBOX_NAMESPACE="${OPENSHELL_SANDBOX_NAMESPACE:-${DRIVER}-dev}"
SANDBOX_IMAGE="${OPENSHELL_SANDBOX_IMAGE:-nvcr.io/nvidia/base/ubuntu:24.04}"
SANDBOX_IMAGE_PULL_POLICY="$(normalize_image_pull_policy "${OPENSHELL_SANDBOX_IMAGE_PULL_POLICY:-if_not_present}")"
GRPC_ENDPOINT="${OPENSHELL_GRPC_ENDPOINT:-}"
LOG_LEVEL="${OPENSHELL_LOG_LEVEL:-info}"
PRIMARY_BIND_IP="${OPENSHELL_BIND_ADDRESS:-127.0.0.1}"
if [[ ! "${GATEWAY_NAME}" =~ ^[A-Za-z0-9._-]+$ ]]; then
echo "ERROR: OPENSHELL_GATEWAY_NAME must contain only letters, numbers, dots, underscores, or dashes" >&2
exit 2
fi
if port_is_in_use "${PORT}"; then
echo "ERROR: port ${PORT} is already in use; free it or set OPENSHELL_SERVER_PORT" >&2
exit 2
fi
echo "Building openshell-gateway..."
run_mise_task build:gateway
if [[ ! -x "${GATEWAY_BIN}" ]]; then
echo "ERROR: expected gateway binary at ${GATEWAY_BIN}" >&2
exit 1
fi
TLS_DIR="${STATE_DIR}/tls"
echo "Generating local gateway credentials..."
"${GATEWAY_BIN}" generate-certs \
--output-dir "${TLS_DIR}" \
--server-san "127.0.0.1" \
--server-san "localhost" \
--server-san "host.openshell.internal"
mkdir -p "${STATE_DIR}"
CONFIG_PATH="${STATE_DIR}/gateway.toml"
# The config may reference credential-bearing material (e.g. proxy_auth_file);
# keep it owner-only regardless of the ambient umask.
install -m 600 /dev/null "${CONFIG_PATH}"
# Kubernetes is a shared deployment, so its sandbox JWTs must expire. Local
# drivers omit ttl_secs and inherit the gateway's non-expiring default, so a
# sandbox restarted while the gateway is down can still reconnect.
GATEWAY_JWT_TTL_CONFIG=""
if [[ "${DRIVER}" == "kubernetes" ]]; then
GATEWAY_JWT_TTL_CONFIG="ttl_secs = 3600"
fi
cat >"${CONFIG_PATH}" <<EOF
[openshell]
version = 2
[openshell.gateway]
name = "${GATEWAY_NAME}"
compute_driver = "${DRIVER}"
disable_tls = true
[openshell.gateway.otlp]
endpoint = "http://127.0.0.1:4317"
[openshell.gateway.otlp.ocsf]
enabled = true
[openshell.gateway.otlp.agent]
enabled = true
[openshell.gateway.auth]
allow_unauthenticated_users = true
[openshell.gateway.gateway_jwt]
signing_key_path = "${TLS_DIR}/jwt/signing.pem"
public_key_path = "${TLS_DIR}/jwt/public.pem"
kid_path = "${TLS_DIR}/jwt/kid"
gateway_id = "${GATEWAY_NAME}"
${GATEWAY_JWT_TTL_CONFIG}
EOF
cat >>"${CONFIG_PATH}" <<EOF
[openshell.drivers.kubernetes]
namespace = "${SANDBOX_NAMESPACE}"
default_image = "${SANDBOX_IMAGE}"
image_pull_policy = "${SANDBOX_IMAGE_PULL_POLICY}"
EOF
if [[ -n "${GRPC_ENDPOINT}" ]]; then
printf 'grpc_endpoint = "%s"\n' "${GRPC_ENDPOINT}" >>"${CONFIG_PATH}"
fi
GATEWAY_ENDPOINT="http://127.0.0.1:${PORT}"
register_gateway_metadata "${GATEWAY_NAME}" "${GATEWAY_ENDPOINT}" "${PORT}"
echo "Starting standalone ${DRIVER} gateway..."
echo " gateway: ${GATEWAY_NAME}"
echo " endpoint: ${GATEWAY_ENDPOINT}"
echo " bind: ${PRIMARY_BIND_IP}:${PORT}"
echo " namespace: ${SANDBOX_NAMESPACE}"
echo " state dir: ${STATE_DIR}"
echo
echo "Active gateway set to '${GATEWAY_NAME}'. The CLI now targets this gateway by default."
echo
exec "${GATEWAY_BIN}" \
--config "${CONFIG_PATH}" \
--bind-address "${PRIMARY_BIND_IP}" \
--port "${PORT}" \
--log-level "${LOG_LEVEL}" \
--compute-driver "${DRIVER}" \
--disable-tls \
--db-url "sqlite:${STATE_DIR}/gateway.db?mode=rwc"