diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 451cc93980..24a570f7bf 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -3,12 +3,16 @@ name: CI Build on: push: branches: [main, "release/**"] - paths-ignore: - - "**/*.md" + paths: + - "**" + - "!**/*.md" + - "docs/env.md" pull_request: branches: [main, "release/**"] - paths-ignore: - - "**/*.md" + paths: + - "**" + - "!**/*.md" + - "docs/env.md" merge_group: workflow_dispatch: inputs: @@ -134,7 +138,6 @@ jobs: --set controller.substrate.enabled=true --set controller.substrate.ateApiEndpoint=dns:///api.ate-system.svc:443 --set controller.substrate.atenetRouterURL=http://atenet-router.ate-system.svc:80 - --set controller.substrate.defaultWorkerPool.name=kagent-default --set substrateWorkerPool.create=true --set substrateWorkerPool.replicas=4 --set-string substrateWorkerPool.workerImage=ghcr.io/kagent-dev/substrate/ateom-gvisor:v${{ env.SUBSTRATE_VERSION }} @@ -154,7 +157,7 @@ jobs: ' _ # Resolve the guest on the runner: localhost:5001 is not reachable # from the controller pod. Substrate rewrites registry pulls on workers. - GUEST_DIGEST=$(docker buildx imagetools inspect "localhost:5001/kagent-dev/kagent/sandbox-guest:${VERSION}" | awk '$1 == "Digest:" { print $2; exit }') + GUEST_DIGEST=$(docker buildx imagetools inspect "localhost:5001/kagent-dev/kagent/sandbox-guest:${VERSION}" | awk '$1 == "Digest:" { print $2 }') test -n "$GUEST_DIGEST" export KAGENT_HELM_EXTRA_ARGS="${KAGENT_HELM_EXTRA_ARGS} --set-string controller.sandbox.guestImage.digest=${GUEST_DIGEST}" make helm-install-provider @@ -166,16 +169,17 @@ jobs: working-directory: go shell: bash run: | - RUNTIME_DIGEST=$(docker buildx imagetools inspect "localhost:5001/kagent-dev/kagent/golang-adk:${VERSION}" | awk '$1 == "Digest:" { print $2; exit }') + # Read all output so Docker does not get a broken pipe under pipefail. + RUNTIME_DIGEST=$(docker buildx imagetools inspect "localhost:5001/kagent-dev/kagent/golang-adk:${VERSION}" | awk '$1 == "Digest:" { print $2 }') test -n "$RUNTIME_DIGEST" export KAGENT_E2E_RUNTIME_IMAGE="localhost:5001/kagent-dev/kagent/golang-adk@${RUNTIME_DIGEST}" - BYO_DIGEST=$(docker buildx imagetools inspect "localhost:5001/kagent-dev/kagent/byo-a2a:${VERSION}" | awk '$1 == "Digest:" { print $2; exit }') + BYO_DIGEST=$(docker buildx imagetools inspect "localhost:5001/kagent-dev/kagent/byo-a2a:${VERSION}" | awk '$1 == "Digest:" { print $2 }') test -n "$BYO_DIGEST" export KAGENT_E2E_BYO_IMAGE="localhost:5001/kagent-dev/kagent/byo-a2a@${BYO_DIGEST}" - CLAUDE_DIGEST=$(docker buildx imagetools inspect "localhost:5001/kagent-dev/kagent/claude-harness:${VERSION}" | awk '$1 == "Digest:" { print $2; exit }') + CLAUDE_DIGEST=$(docker buildx imagetools inspect "localhost:5001/kagent-dev/kagent/claude-harness:${VERSION}" | awk '$1 == "Digest:" { print $2 }') test -n "$CLAUDE_DIGEST" export KAGENT_E2E_CLAUDE_IMAGE="localhost:5001/kagent-dev/kagent/claude-harness@${CLAUDE_DIGEST}" - CODEX_DIGEST=$(docker buildx imagetools inspect "localhost:5001/kagent-dev/kagent/codex-harness:${VERSION}" | awk '$1 == "Digest:" { print $2; exit }') + CODEX_DIGEST=$(docker buildx imagetools inspect "localhost:5001/kagent-dev/kagent/codex-harness:${VERSION}" | awk '$1 == "Digest:" { print $2 }') test -n "$CODEX_DIGEST" export KAGENT_E2E_CODEX_IMAGE="localhost:5001/kagent-dev/kagent/codex-harness@${CODEX_DIGEST}" envsubst < core/test/e2e/manifests/lifecycle.yaml.tmpl | kubectl apply -f - @@ -192,7 +196,7 @@ jobs: export KAGENT_E2E_CLI="$PWD/core/bin/kagent-local" envsubst < core/test/e2e/manifests/tracing.yaml.tmpl | kubectl apply -f - export KAGENT_E2E_API_URL="$KAGENT_API_URL" - export KAGENT_LOCAL_HOST=172.17.0.1 + export KAGENT_E2E_LOCAL_HOST=172.17.0.1 # TestMain keeps the receiver alive for every runtime's response flush. export KAGENT_E2E_OTLP_LISTEN_ADDRESS=:14317 # Concurrent actors can push an earlier failure out of the final log tail. @@ -608,6 +612,9 @@ jobs: cache: true cache-dependency-path: go/go.sum + - name: Check environment documentation + run: make env-docs-check + - name: Generate controller manifests run: make controller-manifests diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index a9fbc3c527..ecd10cf4ca 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -1,5 +1,40 @@ # Development +The [environment variable reference](docs/env.md) is generated from +`go/core/pkg/env`. Register user-configurable settings there, including settings +consumed by Python, the UI, or standalone runtimes. Exclude controller-generated +payloads, credentials, private paths, and other internal process wiring. +Keep defaults and descriptions aligned with +their readers, then run `make env-docs`. Use `make env-docs-check` to run the same +freshness check as CI. + +Register shared settings once, passing each consuming component to +`RegisterStringVar`, `RegisterBoolVar`, `RegisterIntVar`, or +`RegisterDurationVar`, for example: + +```go +RegisterStringVar("KAGENT_LOG_LEVEL", "info", "Logging level.", ComponentController, ComponentCLI, ComponentAgentRuntime) +``` + +The reference lists shared settings under each component. `kagent env --component` +matches any registered component; JSON output includes a `components` array. + +Kagent-owned settings use the `KAGENT_` prefix. Keep names defined by upstream +SDKs and tools, such as `OTEL_*`, provider credentials, and `KUBECONFIG`, unchanged. +The pre-release rename replaces the old unprefixed names: for example, +`LOG_LEVEL` becomes `KAGENT_LOG_LEVEL`, `HTTP_BIND_ADDRESS` becomes +`KAGENT_HTTP_BIND_ADDRESS`, and the Go ADK's `PORT` becomes `KAGENT_PORT`. +The old names are no longer read by kagent. + +Both ADKs use `KAGENT_PORT` for their A2A listener; Python's former +`KAGENT_A2A_GRPC_ADDRESS` is removed. The controller sets `80`. Standalone +defaults remain `8080` for Go's shared HTTP/gRPC listener and `80` for Python's +gRPC listener; Python's HTTP port is configured separately with `--port`. + +UI containers and Vite development use the same `KAGENT_*` inputs, documented in +`ui/.env.example`. UI build-time switches use `KAGENT_UI_VITE_*`; extension settings +use `KAGENT_UI_EXTENSION_*`. Other `KAGENT_*` settings are not exposed to the browser. + To understand how to develop for kagent, it's important to understand the architecture of the project. Please refer to the [README.md](README.md#architecture) file for an overview of the project. When making changes to `kagent`, the most important thing is to figure out which piece of the project is affected by the change, and then make the change in the appropriate folder. Each piece of the project has its own README with more information about how to setup the development environment and run that piece of the project. diff --git a/Makefile b/Makefile index 0e53fcf5b9..bcd49bee65 100644 --- a/Makefile +++ b/Makefile @@ -160,6 +160,25 @@ proto-check: proto-lint proto-generate ## Regenerate protobuf artifacts and fail exit 1; \ fi +##@ Environment documentation + +ENV_DOCS_COMMAND = cd go && go run ./core/cli/cmd/kagent env --format markdown + +.PHONY: env-docs +env-docs: ## Generate docs/env.md from the environment variable registry + @set -eu; generated=$$(mktemp); trap 'rm -f "$$generated"' EXIT; \ + ($(ENV_DOCS_COMMAND)) > "$$generated"; \ + cp "$$generated" docs/env.md + +.PHONY: env-docs-check +env-docs-check: ## Check docs/env.md matches the environment variable registry + @set -eu; generated=$$(mktemp); trap 'rm -f "$$generated"' EXIT; \ + ($(ENV_DOCS_COMMAND)) > "$$generated"; \ + if ! diff -u docs/env.md "$$generated"; then \ + echo "Environment documentation is out of date. Run 'make env-docs' and commit docs/env.md."; \ + exit 1; \ + fi + ##@ Telemetry contract include telemetry/versions.env @@ -563,7 +582,7 @@ install-previous-release: ## Install the previous released kagent + kagent-crds # Later Goose releases test previous-release behavior after the target migrations, # data survival, schema equality, previous/current controller startup, and a # complete application and schema rollback to the previous release. -# KAGENT_LOCAL_HOST lets the agent reach the local mock LLM. +# KAGENT_E2E_LOCAL_HOST lets the agent reach the local mock LLM. # Prerequisite (provided by CI as a separate step; run it locally first): a kind # cluster (make create-kind-cluster). .PHONY: announce-upgrade-from @@ -575,15 +594,15 @@ run-upgrade-tests: announce-upgrade-from build install-previous-release ## Test @echo "=== Upgrade test: $(UPGRADE_FROM_VERSION) -> $(VERSION) (registry=$(DOCKER_REGISTRY)) ===" @set -e; \ kind_gw="$$($(CONTAINER_RUNTIME) network inspect kind -f '{{range .IPAM.Config}}{{if .Gateway}}{{.Gateway}}{{"\n"}}{{end}}{{end}}' | grep -E '^[0-9]+\.' | head -1)"; \ - echo "kind gateway (KAGENT_LOCAL_HOST): $$kind_gw"; \ + echo "kind gateway (KAGENT_E2E_LOCAL_HOST): $$kind_gw"; \ cd go && \ - RUN_UPGRADE_TESTS=true \ - REPO_ROOT=$(CURDIR) \ - KAGENT_LOCAL_HOST="$$kind_gw" \ - UPGRADE_FROM_VERSION=$(UPGRADE_FROM_VERSION) \ - VERSION=$(VERSION) \ - DOCKER_REGISTRY=$(DOCKER_REGISTRY) \ - KIND_CLUSTER_NAME=$(KIND_CLUSTER_NAME) \ + KAGENT_E2E_RUN_UPGRADE_TESTS=true \ + KAGENT_E2E_REPO_ROOT=$(CURDIR) \ + KAGENT_E2E_LOCAL_HOST="$$kind_gw" \ + KAGENT_E2E_UPGRADE_FROM_VERSION=$(UPGRADE_FROM_VERSION) \ + KAGENT_E2E_VERSION=$(VERSION) \ + KAGENT_E2E_DOCKER_REGISTRY=$(DOCKER_REGISTRY) \ + KAGENT_E2E_KIND_CLUSTER_NAME=$(KIND_CLUSTER_NAME) \ OPENAI_API_KEY="$${OPENAI_API_KEY:-test}" \ go test ./core/test/upgrade -run TestUpgrade -count=1 -timeout=45m -v @@ -595,12 +614,12 @@ run-rolling-upgrade-tests: UPGRADE_PREV_EXTRA_ARGS = --set controller.replicas=2 run-rolling-upgrade-tests: announce-upgrade-from build install-previous-release ## Install the previous release with 2 controller replicas, build the current images, and run the rolling upgrade e2e test @echo "=== Rolling upgrade test: $(UPGRADE_FROM_VERSION) -> $(VERSION) (registry=$(DOCKER_REGISTRY)) ===" cd go && \ - RUN_ROLLING_UPGRADE_TESTS=true \ - REPO_ROOT=$(CURDIR) \ - UPGRADE_FROM_VERSION=$(UPGRADE_FROM_VERSION) \ - VERSION=$(VERSION) \ - DOCKER_REGISTRY=$(DOCKER_REGISTRY) \ - KIND_CLUSTER_NAME=$(KIND_CLUSTER_NAME) \ + KAGENT_E2E_RUN_ROLLING_UPGRADE_TESTS=true \ + KAGENT_E2E_REPO_ROOT=$(CURDIR) \ + KAGENT_E2E_UPGRADE_FROM_VERSION=$(UPGRADE_FROM_VERSION) \ + KAGENT_E2E_VERSION=$(VERSION) \ + KAGENT_E2E_DOCKER_REGISTRY=$(DOCKER_REGISTRY) \ + KAGENT_E2E_KIND_CLUSTER_NAME=$(KIND_CLUSTER_NAME) \ OPENAI_API_KEY="$${OPENAI_API_KEY:-test}" \ go test ./core/test/upgrade -run TestRollingUpgradeCompatibility -count=1 -timeout=20m -v diff --git a/STYLE.md b/STYLE.md index 5bd51e1938..a333a72cba 100644 --- a/STYLE.md +++ b/STYLE.md @@ -205,10 +205,23 @@ Go code lives in the `go/` workspace (`go/api`, `go/core`, `go/adk`). Run HTTP calls use `http.NewRequestWithContext`. Never drop a `cancel` function. - Context keys use unexported struct types, never strings. +### Environment variables + +- Read user-configurable settings through registered accessors in `core/pkg/env`. + Register each setting once, listing every consuming component. +- Use `Get()` for the registered default and `Lookup()` when a caller must + distinguish unset values. Use boolean/integer `LookupWithError()` when invalid + input needs a diagnostic; it distinguishes invalid input from unset or empty. +- Registered booleans accept `true`, `t`, `1`, `false`, `f`, and `0`, ignoring case + and surrounding whitespace. Upstream settings with a different grammar use + string registrations and their own parser. +- Direct process reads require an explained lint exception, such as resolving + controller-generated payloads or arbitrary credential placeholders. + ### Logging - Use standard-library `log/slog`. Binaries write JSON to stderr and read the - minimum level from `LOG_LEVEL` (`debug`, `info`, `warn`, or `error`). + minimum level from `KAGENT_LOG_LEVEL` (`debug`, `info`, `warn`, or `error`). - Carry loggers in `context.Context` with `pkg/logging`; do not add logger parameters, package-global loggers, or `NewXxxWithLogger` constructors. - Use `DebugContext`, `InfoContext`, `WarnContext`, or `ErrorContext` whenever @@ -315,7 +328,7 @@ All new CRD API surface goes in `v1alpha3`. Side effects and async logic belong in the reconciler (or a dedicated controller). - Every behavioral change to a translator **SHOULD** come with golden tests - (`UPDATE_GOLDEN=true go test ...` to regenerate). + (`KAGENT_TEST_UPDATE_GOLDEN=true go test ...` to regenerate). ### Database diff --git a/docs/architecture/configuration-and-compilation.md b/docs/architecture/configuration-and-compilation.md index e21a9948e7..298e7f6620 100644 --- a/docs/architecture/configuration-and-compilation.md +++ b/docs/architecture/configuration-and-compilation.md @@ -194,7 +194,9 @@ Dedicated agent bindings are not compiled yet. The Python ADK image's default entrypoint runs a named Python agent module. To consume the kagent compiler's configuration, set `spec.workload.command` to `["/.kagent/.venv/bin/kagent-adk", "static", "--host", "0.0.0.0", "--port", "8080"]`. -The compiler supplies the private A2A gRPC address separately. +The compiler sets `KAGENT_PORT=80` for private A2A traffic in both ADKs. Go uses +that port for its shared HTTP/gRPC listener; Python uses it for gRPC and keeps +the HTTP port separate. `spec.kagent.compaction` on the Harness is runtime policy, like `spec.kagent.memory`: it belongs to the runner that drives the root agent and is not part of the diff --git a/docs/architecture/oidc-proxy-authentication.md b/docs/architecture/oidc-proxy-authentication.md index ccd2bf0d65..20cd7c2477 100644 --- a/docs/architecture/oidc-proxy-authentication.md +++ b/docs/architecture/oidc-proxy-authentication.md @@ -119,19 +119,16 @@ differs. | `oauth2-proxy.enabled` | `false` | Install the authentication proxy | | `oauth2-proxy.config.existingSecret` | Empty | Reference client and cookie credentials | | `ui.auth.ssoRedirectPath` | `/oauth2/start` | Start or restart browser sign-in | -| `controller.auth.mode` | `unsecure` | Render the controller's `AUTH_MODE` environment variable | -| `controller.auth.userIdClaim` | Empty | Render `AUTH_USER_ID_CLAIM` when supplied | -The last two settings remain in the chart, but the current controller does not -consume them. Setting `controller.auth.mode: trusted-proxy` alone does not -activate `ProxyAuthenticator`, and the controller has no `--auth-mode` or -`--auth-user-id-claim` flags. A library integration must supply its own -`auth.AuthProvider` through `app.Options.Authenticator`. +Enabling the proxy does not activate controller authentication. A library +integration must supply its own `auth.AuthProvider` through +`app.Options.Authenticator`. The UI reads `SSO_REDIRECT_PATH` from `window.environmentVariables` at runtime and applies its configured base path. Helm supplies that value from -`ui.auth.ssoRedirectPath`. The chart allows unauthenticated access to the login -page, health checks, and the assets required to render the login page. +`ui.auth.ssoRedirectPath` through `KAGENT_UI_SSO_REDIRECT_PATH`. The chart allows +unauthenticated access to the login page, health checks, and the assets required +to render the login page. ## Trust boundary diff --git a/docs/architecture/sandboxes.md b/docs/architecture/sandboxes.md index 13c9d64ada..1200dac58a 100644 --- a/docs/architecture/sandboxes.md +++ b/docs/architecture/sandboxes.md @@ -103,7 +103,7 @@ pin the runtime artifacts they need. Sandbox execution is enabled by default. Lifecycle calls run on the serving API replica; expiration workers coordinate through PostgreSQL. -Configure `SANDBOX_GUEST_IMAGE` with an `image@sha256:...` reference, or set +Configure `KAGENT_SANDBOX_GUEST_IMAGE` with an `image@sha256:...` reference, or set `controller.sandbox.guestImage.digest` in Helm alongside its registry and repository. The guest image is required for sandbox preparation and passes unchanged to Substrate. The controller does not resolve tags or contact image diff --git a/docs/env.md b/docs/env.md new file mode 100644 index 0000000000..80caae1368 --- /dev/null +++ b/docs/env.md @@ -0,0 +1,218 @@ +# Kagent Environment Variables + + + +Generated from `go/core/pkg/env`. Edit the registrations there, run `make env-docs`, and commit the result. CI runs `make env-docs-check`. + +This reference covers user-configurable settings for the controller, CLI, standalone agent runtimes, UI, and tests. Controller-generated runtime payloads, credentials, private paths, and other internal process wiring are excluded. Build scripts, sample applications, and third-party SDK settings not configured by kagent have their own documentation. Defaults describe the application without deployment overrides; Helm or a Harness may supply different values. `(none)` means no fixed default; see the description for required values and fallbacks. Shared variables appear under each consuming component. Only registered metadata is exported, never values from the current process environment. + +## agent-runtime + +| Variable | Type | Default | Description | +|----------|------|---------|-------------| +| `ADK_CAPTURE_MESSAGE_CONTENT_IN_SPANS` | String | `(none)` | Python Google ADK span content capture. When absent, derived from OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT (true for SPAN_ONLY or SPAN_AND_EVENT, false otherwise). | +| `ADK_TELEMETRY_SCHEMA_VERSION_OPT_IN` | String | `2` | Python Google ADK telemetry schema version; set by kagent when absent. | +| `ANTHROPIC_API_KEY` | String | `(none)` | API key for Anthropic. | +| `AWS_ACCESS_KEY_ID` | String | `(none)` | AWS access key ID for IAM authentication with Bedrock. | +| `AWS_BEARER_TOKEN_BEDROCK` | String | `(none)` | Bearer token for authentication with AWS Bedrock. | +| `AWS_DEFAULT_REGION` | String | `(none)` | Preferred region for Python Bedrock models and Go/Python Bedrock embeddings, before AWS_REGION and the us-east-1 fallback. | +| `AWS_REGION` | String | `(none)` | AWS region for Bedrock. Python Bedrock and Go Bedrock embeddings prefer AWS_DEFAULT_REGION, then AWS_REGION, then us-east-1. | +| `AWS_SECRET_ACCESS_KEY` | String | `(none)` | AWS secret access key for IAM authentication with Bedrock. | +| `AWS_SESSION_TOKEN` | String | `(none)` | AWS session token for temporary/SSO credentials with Bedrock. | +| `AZURE_AD_TOKEN` | String | `(none)` | Azure Active Directory authentication token for Azure OpenAI. | +| `AZURE_OPENAI_API_KEY` | String | `(none)` | API key for Azure OpenAI. | +| `AZURE_OPENAI_ENDPOINT` | String | `(none)` | Endpoint URL for Azure OpenAI service. | +| `FOUNDRY_API_KEY` | String | `(none)` | API key for Azure AI Foundry. | +| `FOUNDRY_API_VERSION` | String | `2024-10-21` | Azure AI Foundry OpenAI-compatible data-plane API version. | +| `FOUNDRY_DEPLOYMENT` | String | `(none)` | Azure AI Foundry model deployment name. | +| `FOUNDRY_ENDPOINT` | String | `(none)` | Endpoint URL for Azure AI Foundry or an Azure AI Services account. | +| `GEMINI_API_KEY` | String | `(none)` | Fallback Gemini API key when GOOGLE_API_KEY is unset; supported by the CLI and Go/Python ADKs. | +| `GOOGLE_API_KEY` | String | `(none)` | API key for Google Gemini. | +| `GOOGLE_APPLICATION_CREDENTIALS` | String | `(none)` | Path to Google Cloud service account JSON key file. | +| `GOOGLE_CLOUD_LOCATION` | String | `(none)` | Google Cloud region/location for Vertex AI. | +| `GOOGLE_CLOUD_PROJECT` | String | `(none)` | Google Cloud project ID for Vertex AI. | +| `GOOGLE_CLOUD_REGION` | String | `(none)` | Go ADK Vertex AI region fallback when GOOGLE_CLOUD_LOCATION is unset. | +| `GOOGLE_GENAI_USE_VERTEXAI` | String | `(none)` | When set to 'true', use Vertex AI for Gemini models. | +| `KAGENT_A2A_MAX_CONTENT_LENGTH` | String | `10485760` | Maximum A2A request size in bytes for Go/Python servers. 0, none, or unlimited disables the limit; invalid values use the default. | +| `KAGENT_API_URL` | String | `(none)` | Base URL for kagent control-plane API calls. Required by Python runtimes and supplied by the controller in managed runtimes; also used as the E2E test URL when KAGENT_E2E_API_URL is unset. | +| `KAGENT_BASH_VENV_PATH` | String | `(none)` | Virtual environment used for Python skills shell commands; its bin directory is prepended to PATH and VIRTUAL_ENV is set. | +| `KAGENT_CONFIG_DIR` | String | `/config` | Go ADK configuration directory; --filepath takes precedence. | +| `KAGENT_ENABLE_FILE_SEARCH_TOOLS` | Boolean | `false` | When true, t, or 1 (case-insensitive), enables the list_files and grep_file skills tools, which let an agent enumerate and search the filesystem under its session/skills roots without a shell. Disabled by default; set in Harness env to opt in. | +| `KAGENT_GATEWAY_URL` | String | `(none)` | Base URL for A2A and MCP traffic. The controller falls back to http://127.0.0.1:8083; Python runtimes require a value. | +| `KAGENT_LOG_LEVEL` | String | `info` | Logging level for the controller, CLI, and Go/Python runtimes, including the Python ADK HTTP server: debug, info, warn, or error. Python also accepts standard Python logging levels. | +| `KAGENT_NAME` | String | `(none)` | Agent name for standalone runtimes. Required by Python runtimes; supplied by the controller in managed runtimes. | +| `KAGENT_NAMESPACE` | String | `kagent` | Kubernetes namespace where kagent resources are deployed. The controller injects the agent namespace into runtimes; Python runtimes require it. | +| `KAGENT_OPENAI_AGENTS_NATIVE_TRACING` | Boolean | `false` | Keep the OpenAI Agents SDK native tracing processor alongside kagent OpenTelemetry export in the Python OpenAI runtime. | +| `KAGENT_PORT` | String | `(none)` | ADK A2A listen port: the Go HTTP/gRPC listener defaults to 8080; the Python gRPC listener defaults to 80. Explicit Go --port/AppConfig.Port or Python a2a_grpc_address takes precedence. The controller sets 80 for managed kagent runtimes. Python's HTTP --port is separate. | +| `KAGENT_PROPAGATE_TOKEN` | String | `(none)` | Set to true to propagate authentication tokens to downstream services. Unset or any other value disables propagation. | +| `KAGENT_SKILLS_FOLDER` | String | `/skills` | Skills directory for standalone Python skills tools. The Python ADK adds skills tools when set; managed Go ADK runtimes use their compiled skill configuration. | +| `KAGENT_STS_AUDIENCE` | String | `(none)` | Comma-separated RFC 8693 audiences sent on STS token-exchange requests. Alternate to KAGENT_STS_RESOURCE for servers that key on audience. | +| `KAGENT_STS_RESOURCE` | String | `(none)` | Comma-separated RFC 8707 resource indicators sent on STS token-exchange requests to scope issued tokens to target backends. | +| `KAGENT_STS_WELL_KNOWN_URI` | String | `(none)` | Well-known endpoint for the Security Token Service (STS) used for token exchange. | +| `MISTRAL_API_BASE` | String | `(none)` | Custom base URL for the Mistral AI API (defaults to https://api.mistral.ai/v1). | +| `MISTRAL_API_KEY` | String | `(none)` | API key for Mistral AI. | +| `OLLAMA_API_BASE` | String | `(none)` | Base URL for the Ollama API endpoint; falls back to http://localhost:11434 when model configuration and this variable are unset. | +| `OLLAMA_API_KEY` | String | `(none)` | API key for Ollama Cloud. When set, a cloud-tagged model reaches api.ollama.com directly. | +| `OPENAI_AGENTS_DISABLE_TRACING` | Boolean | `false` | Disable OpenAI Agents SDK tracing, including the kagent bridge. The Python OpenAI runtime accepts true or 1. | +| `OPENAI_API_BASE` | String | `(none)` | Custom base URL for the OpenAI API. | +| `OPENAI_API_KEY` | String | `(none)` | API key for OpenAI. Upgrade tests fall back to a placeholder when unset or empty. | +| `OPENAI_API_VERSION` | String | `(none)` | Azure OpenAI API version. The Go and Python ADKs fall back to 2024-02-15-preview when model configuration and this variable are unset. | +| `OPENAI_ORGANIZATION` | String | `(none)` | OpenAI organization identifier. | +| `OTEL_EXPORTER_OTLP_COMPRESSION` | String | `gzip` | OTLP compression default applied by kagent. The native Codex process has this variable removed because its exporter does not support gzip. | +| `OTEL_EXPORTER_OTLP_ENDPOINT` | String | `(none)` | OTLP endpoint for every signal. `OTEL_EXPORTER_OTLP__ENDPOINT` overrides it for one signal. | +| `OTEL_EXPORTER_OTLP_LOGS_ENDPOINT` | String | `(none)` | Log endpoint override. Falls back to OTEL_EXPORTER_OTLP_ENDPOINT; an HTTP override must include its signal path. | +| `OTEL_EXPORTER_OTLP_LOGS_PROTOCOL` | String | `(none)` | Log protocol override: grpc or http/protobuf. Falls back to OTEL_EXPORTER_OTLP_PROTOCOL. | +| `OTEL_EXPORTER_OTLP_LOGS_TIMEOUT` | String | `(none)` | Log SDK timeout in milliseconds, overriding OTEL_EXPORTER_OTLP_TIMEOUT. Not forwarded by the controller. | +| `OTEL_EXPORTER_OTLP_METRICS_DEFAULT_HISTOGRAM_AGGREGATION` | String | `base2_exponential_bucket_histogram` | Default SDK histogram aggregation applied by kagent and supplied to managed runtimes. | +| `OTEL_EXPORTER_OTLP_METRICS_ENDPOINT` | String | `(none)` | Metric endpoint override. Falls back to OTEL_EXPORTER_OTLP_ENDPOINT; an HTTP override must include its signal path. | +| `OTEL_EXPORTER_OTLP_METRICS_PROTOCOL` | String | `(none)` | Metric protocol override: grpc or http/protobuf. Falls back to OTEL_EXPORTER_OTLP_PROTOCOL. | +| `OTEL_EXPORTER_OTLP_METRICS_TIMEOUT` | String | `(none)` | Metric SDK timeout in milliseconds, overriding OTEL_EXPORTER_OTLP_TIMEOUT. Not forwarded by the controller. | +| `OTEL_EXPORTER_OTLP_PROTOCOL` | String | `grpc` | OTLP protocol, grpc or http/protobuf. `OTEL_EXPORTER_OTLP__PROTOCOL` overrides it for one signal. | +| `OTEL_EXPORTER_OTLP_TIMEOUT` | String | `(none)` | OTLP export timeout in milliseconds. The controller forwards positive integers; absent values use each SDK's default (normally 10000 ms). | +| `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT` | String | `(none)` | Trace endpoint override. Falls back to OTEL_EXPORTER_OTLP_ENDPOINT; an HTTP override must include its signal path. | +| `OTEL_EXPORTER_OTLP_TRACES_PROTOCOL` | String | `(none)` | Trace protocol override: grpc or http/protobuf. Falls back to OTEL_EXPORTER_OTLP_PROTOCOL. | +| `OTEL_EXPORTER_OTLP_TRACES_TIMEOUT` | String | `(none)` | Trace SDK timeout in milliseconds, overriding OTEL_EXPORTER_OTLP_TIMEOUT. Not forwarded by the controller. | +| `OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT` | String | `NO_CONTENT` | SPAN_ONLY records prompts and responses on agent spans. NO_CONTENT disables capture. Managed runtimes support these two modes; standalone Python ADK also recognizes SPAN_AND_EVENT. Captured content may be sensitive. | +| `OTEL_LOGS_EXPORTER` | String | `(none)` | Log exporter, otlp or none. Managed runtime export requires explicit otlp and an endpoint; unset disables forwarding. Standalone SDKs may default to otlp. | +| `OTEL_METRICS_EXPORTER` | String | `(none)` | Metric exporter, otlp or none. Managed runtime export requires explicit otlp and an endpoint; unset disables forwarding. Standalone SDKs may default to otlp. | +| `OTEL_PROPAGATORS` | String | `tracecontext` | SDK trace propagators. Kagent defaults to W3C tracecontext without baggage and supplies that default to managed runtimes. | +| `OTEL_RESOURCE_ATTRIBUTES` | String | `(none)` | Comma-separated SDK resource attributes for the current process. Helm injects controller identity; kagent constructs runtime identity separately. Use KAGENT_OTEL_RESOURCE_ATTRIBUTES for attributes shared with managed agents. | +| `OTEL_SDK_DISABLED` | String | `false` | Disable SDK telemetry and forwarding to managed runtimes when true (case-insensitive). Other values are treated as false. | +| `OTEL_SEMCONV_STABILITY_OPT_IN` | String | `gen_ai_latest_experimental` | Python Google ADK semantic-convention opt-in; set by kagent when absent. | +| `OTEL_SERVICE_NAME` | String | `(none)` | SDK service name for the current process. Defaults to kagent-controller in the controller; the controller supplies the agent name to managed runtimes. | +| `OTEL_TRACES_EXPORTER` | String | `(none)` | Trace exporter, otlp or none. Managed runtime export requires explicit otlp and an endpoint; unset disables forwarding. Standalone SDKs may default to otlp. | +| `SAP_AI_CORE_CLIENT_ID` | String | `(none)` | OAuth2 client ID for SAP AI Core authentication. | +| `SAP_AI_CORE_CLIENT_SECRET` | String | `(none)` | OAuth2 client secret for SAP AI Core authentication. | + +## cli + +| Variable | Type | Default | Description | +|----------|------|---------|-------------| +| `ANTHROPIC_API_KEY` | String | `(none)` | API key for Anthropic. | +| `AZURE_OPENAI_API_KEY` | String | `(none)` | API key for Azure OpenAI. | +| `GEMINI_API_KEY` | String | `(none)` | Fallback Gemini API key when GOOGLE_API_KEY is unset; supported by the CLI and Go/Python ADKs. | +| `GOOGLE_API_KEY` | String | `(none)` | API key for Google Gemini. | +| `KAGENT_DATABASE_VECTOR_ENABLED` | Boolean | `false` | Enable vector database migrations and vector-backed database functionality. The controller defaults to false. When unset in the CLI, migrations read the controller ConfigMap and fall back to true if it is unavailable. | +| `KAGENT_DEFAULT_MODEL_PROVIDER` | String | `openAI` | Default LLM provider for agents (e.g. openAI, anthropic, ollama, azureOpenAI). | +| `KAGENT_HELM_EXTRA_ARGS` | String | `(none)` | Additional arguments to pass to Helm commands. | +| `KAGENT_HELM_REPO` | String | `oci://ghcr.io/kagent-dev/kagent/helm/` | Helm repository URL for kagent charts. | +| `KAGENT_HELM_VERSION` | String | `(none)` | Helm chart version to deploy. When unset, the CLI uses its own version. | +| `KAGENT_LOG_LEVEL` | String | `info` | Logging level for the controller, CLI, and Go/Python runtimes, including the Python ADK HTTP server: debug, info, warn, or error. Python also accepts standard Python logging levels. | +| `KAGENT_POSTGRES_DATABASE_URL` | String | `postgres://postgres:kagent@kagent-postgresql.kagent.svc.cluster.local:5432/postgres` | PostgreSQL connection URL. The default applies only to the controller; kagent db requires this variable or --db-url. Helm supplies its configured connection URL. | +| `KUBECONFIG` | String | `(none)` | Kubernetes client configuration file list for the controller, CLI Kubernetes operations, and tests. When unset, client-go uses its normal in-cluster or user kubeconfig discovery. | +| `OLLAMA_API_KEY` | String | `(none)` | API key for Ollama Cloud. When set, a cloud-tagged model reaches api.ollama.com directly. | +| `OPENAI_API_KEY` | String | `(none)` | API key for OpenAI. Upgrade tests fall back to a placeholder when unset or empty. | + +## controller + +| Variable | Type | Default | Description | +|----------|------|---------|-------------| +| `KAGENT_CONTROLLER_NAME` | String | `kagent-controller` | Name of the kagent controller service. | +| `KAGENT_DATABASE_VECTOR_ENABLED` | Boolean | `false` | Enable vector database migrations and vector-backed database functionality. The controller defaults to false. When unset in the CLI, migrations read the controller ConfigMap and fall back to true if it is unavailable. | +| `KAGENT_GATEWAY_URL` | String | `(none)` | Base URL for A2A and MCP traffic. The controller falls back to http://127.0.0.1:8083; Python runtimes require a value. | +| `KAGENT_GRPC_REFLECTION` | Boolean | `false` | Enable gRPC server reflection on the controller. | +| `KAGENT_HTTP_BIND_ADDRESS` | String | `:8083` | Listen address for the controller HTTP, gRPC, A2A, and MCP server. | +| `KAGENT_LEADER_ELECT` | Boolean | `true` | Enable controller leader election, including during single-replica rolling updates. Required for sandbox lifecycle coordination. | +| `KAGENT_LOG_LEVEL` | String | `info` | Logging level for the controller, CLI, and Go/Python runtimes, including the Python ADK HTTP server: debug, info, warn, or error. Python also accepts standard Python logging levels. | +| `KAGENT_METRICS_BIND_ADDRESS` | String | `0` | Address the controller-runtime metrics server binds to, e.g. :8080. "0" (the default) serves no metrics, so an installation that does not set this is unchanged. The Helm chart renders this variable, and its ServiceMonitor, from controller.metrics. | +| `KAGENT_METRICS_SECURE` | Boolean | `false` | Serve the metrics endpoint over HTTPS with authentication and authorization. A scraper then needs a token bound to the metrics-reader ClusterRole. | +| `KAGENT_NAMESPACE` | String | `kagent` | Kubernetes namespace where kagent resources are deployed. The controller injects the agent namespace into runtimes; Python runtimes require it. | +| `KAGENT_OTEL_CAPTURE_RAW_API_BODIES` | Boolean | `false` | Set to true, t, or 1 (case-insensitive) to enable native Claude raw API body logging when log export is enabled. Independent of span content capture; bodies may contain sensitive data. | +| `KAGENT_OTEL_MAX_CAPTURE_BYTES` | Integer | `16384` | Per-input/output content capture budget in bytes when capture is enabled. Valid values are 1 through 65536; absent or invalid values use 16384. | +| `KAGENT_OTEL_RESOURCE_ATTRIBUTES` | String | `(none)` | Resource attributes, as key=value pairs, added to every agent runtime. | +| `KAGENT_POSTGRES_DATABASE_URL` | String | `postgres://postgres:kagent@kagent-postgresql.kagent.svc.cluster.local:5432/postgres` | PostgreSQL connection URL. The default applies only to the controller; kagent db requires this variable or --db-url. Helm supplies its configured connection URL. | +| `KAGENT_POSTGRES_DATABASE_URL_FILE` | String | `(none)` | File containing the PostgreSQL connection URL; takes precedence over KAGENT_POSTGRES_DATABASE_URL in the controller. | +| `KAGENT_SANDBOX_CPU` | String | `1` | CPU limit for standalone sandbox runtimes. | +| `KAGENT_SANDBOX_DEFAULT_TTL` | Duration | `1h0m0s` | Default standalone sandbox lifetime. | +| `KAGENT_SANDBOX_GUEST_IMAGE` | String | `(none)` | Guest package image pinned by sha256 digest. Required for sandbox preparation and passed unchanged to Substrate. | +| `KAGENT_SANDBOX_MAX_TTL` | Duration | `24h0m0s` | Maximum standalone sandbox lifetime, at most 24h. | +| `KAGENT_SANDBOX_MEMORY` | String | `1Gi` | Memory limit for standalone sandbox runtimes. | +| `KAGENT_SKIP_MIGRATIONS` | Boolean | `false` | Verify required database migrations at startup without applying them. | +| `KAGENT_SUBSTRATE_ATENET_ROUTER_URL` | String | `http://atenet-router.ate-system.svc:80` | Substrate router endpoint for agent and sandbox guest traffic. | +| `KAGENT_SUBSTRATE_ATE_API_CA_FILE` | String | `(none)` | PEM CA bundle used to verify the Substrate API server. Empty uses system trust roots. | +| `KAGENT_SUBSTRATE_ATE_API_CLIENT_CERT_FILE` | String | `(none)` | PEM bundle containing both the client certificate and private key for Substrate API mTLS. Reloaded for each TLS handshake. | +| `KAGENT_SUBSTRATE_ATE_API_ENDPOINT` | String | `dns:///api.ate-system.svc:443` | Substrate control-plane gRPC endpoint. | +| `KAGENT_WATCH_NAMESPACES` | String | `(none)` | Comma-separated namespaces to watch. Empty watches all namespaces. | +| `KUBECONFIG` | String | `(none)` | Kubernetes client configuration file list for the controller, CLI Kubernetes operations, and tests. When unset, client-go uses its normal in-cluster or user kubeconfig discovery. | +| `OTEL_EXPORTER_OTLP_COMPRESSION` | String | `gzip` | OTLP compression default applied by kagent. The native Codex process has this variable removed because its exporter does not support gzip. | +| `OTEL_EXPORTER_OTLP_ENDPOINT` | String | `(none)` | OTLP endpoint for every signal. `OTEL_EXPORTER_OTLP__ENDPOINT` overrides it for one signal. | +| `OTEL_EXPORTER_OTLP_LOGS_ENDPOINT` | String | `(none)` | Log endpoint override. Falls back to OTEL_EXPORTER_OTLP_ENDPOINT; an HTTP override must include its signal path. | +| `OTEL_EXPORTER_OTLP_LOGS_PROTOCOL` | String | `(none)` | Log protocol override: grpc or http/protobuf. Falls back to OTEL_EXPORTER_OTLP_PROTOCOL. | +| `OTEL_EXPORTER_OTLP_LOGS_TIMEOUT` | String | `(none)` | Log SDK timeout in milliseconds, overriding OTEL_EXPORTER_OTLP_TIMEOUT. Not forwarded by the controller. | +| `OTEL_EXPORTER_OTLP_METRICS_DEFAULT_HISTOGRAM_AGGREGATION` | String | `base2_exponential_bucket_histogram` | Default SDK histogram aggregation applied by kagent and supplied to managed runtimes. | +| `OTEL_EXPORTER_OTLP_METRICS_ENDPOINT` | String | `(none)` | Metric endpoint override. Falls back to OTEL_EXPORTER_OTLP_ENDPOINT; an HTTP override must include its signal path. | +| `OTEL_EXPORTER_OTLP_METRICS_PROTOCOL` | String | `(none)` | Metric protocol override: grpc or http/protobuf. Falls back to OTEL_EXPORTER_OTLP_PROTOCOL. | +| `OTEL_EXPORTER_OTLP_METRICS_TIMEOUT` | String | `(none)` | Metric SDK timeout in milliseconds, overriding OTEL_EXPORTER_OTLP_TIMEOUT. Not forwarded by the controller. | +| `OTEL_EXPORTER_OTLP_PROTOCOL` | String | `grpc` | OTLP protocol, grpc or http/protobuf. `OTEL_EXPORTER_OTLP__PROTOCOL` overrides it for one signal. | +| `OTEL_EXPORTER_OTLP_TIMEOUT` | String | `(none)` | OTLP export timeout in milliseconds. The controller forwards positive integers; absent values use each SDK's default (normally 10000 ms). | +| `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT` | String | `(none)` | Trace endpoint override. Falls back to OTEL_EXPORTER_OTLP_ENDPOINT; an HTTP override must include its signal path. | +| `OTEL_EXPORTER_OTLP_TRACES_PROTOCOL` | String | `(none)` | Trace protocol override: grpc or http/protobuf. Falls back to OTEL_EXPORTER_OTLP_PROTOCOL. | +| `OTEL_EXPORTER_OTLP_TRACES_TIMEOUT` | String | `(none)` | Trace SDK timeout in milliseconds, overriding OTEL_EXPORTER_OTLP_TIMEOUT. Not forwarded by the controller. | +| `OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT` | String | `NO_CONTENT` | SPAN_ONLY records prompts and responses on agent spans. NO_CONTENT disables capture. Managed runtimes support these two modes; standalone Python ADK also recognizes SPAN_AND_EVENT. Captured content may be sensitive. | +| `OTEL_LOGS_EXPORTER` | String | `(none)` | Log exporter, otlp or none. Managed runtime export requires explicit otlp and an endpoint; unset disables forwarding. Standalone SDKs may default to otlp. | +| `OTEL_METRICS_EXPORTER` | String | `(none)` | Metric exporter, otlp or none. Managed runtime export requires explicit otlp and an endpoint; unset disables forwarding. Standalone SDKs may default to otlp. | +| `OTEL_PROPAGATORS` | String | `tracecontext` | SDK trace propagators. Kagent defaults to W3C tracecontext without baggage and supplies that default to managed runtimes. | +| `OTEL_RESOURCE_ATTRIBUTES` | String | `(none)` | Comma-separated SDK resource attributes for the current process. Helm injects controller identity; kagent constructs runtime identity separately. Use KAGENT_OTEL_RESOURCE_ATTRIBUTES for attributes shared with managed agents. | +| `OTEL_SDK_DISABLED` | String | `false` | Disable SDK telemetry and forwarding to managed runtimes when true (case-insensitive). Other values are treated as false. | +| `OTEL_SERVICE_NAME` | String | `(none)` | SDK service name for the current process. Defaults to kagent-controller in the controller; the controller supplies the agent name to managed runtimes. | +| `OTEL_TRACES_EXPORTER` | String | `(none)` | Trace exporter, otlp or none. Managed runtime export requires explicit otlp and an endpoint; unset disables forwarding. Standalone SDKs may default to otlp. | + +## database + +| Variable | Type | Default | Description | +|----------|------|---------|-------------| +| `KAGENT_DATABASE_VECTOR_ENABLED` | Boolean | `false` | Enable vector database migrations and vector-backed database functionality. The controller defaults to false. When unset in the CLI, migrations read the controller ConfigMap and fall back to true if it is unavailable. | +| `KAGENT_POSTGRES_DATABASE_URL` | String | `postgres://postgres:kagent@kagent-postgresql.kagent.svc.cluster.local:5432/postgres` | PostgreSQL connection URL. The default applies only to the controller; kagent db requires this variable or --db-url. Helm supplies its configured connection URL. | +| `KAGENT_POSTGRES_DATABASE_URL_FILE` | String | `(none)` | File containing the PostgreSQL connection URL; takes precedence over KAGENT_POSTGRES_DATABASE_URL in the controller. | +| `KAGENT_SKIP_MIGRATIONS` | Boolean | `false` | Verify required database migrations at startup without applying them. | + +## testing + +| Variable | Type | Default | Description | +|----------|------|---------|-------------| +| `KAGENT_API_URL` | String | `(none)` | Base URL for kagent control-plane API calls. Required by Python runtimes and supplied by the controller in managed runtimes; also used as the E2E test URL when KAGENT_E2E_API_URL is unset. | +| `KAGENT_E2E_API_URL` | String | `(none)` | Controller URL for Go E2E tests. Falls back to KAGENT_API_URL; tests skip when neither is set. | +| `KAGENT_E2E_BYO_IMAGE` | String | `(none)` | Digest-pinned BYO image substituted into E2E lifecycle manifests. | +| `KAGENT_E2E_CLAUDE_IMAGE` | String | `(none)` | Digest-pinned Claude harness image substituted into E2E lifecycle manifests. | +| `KAGENT_E2E_CLI` | String | `(none)` | CLI executable for catalog lifecycle E2E tests; make -C go e2e builds and supplies it. | +| `KAGENT_E2E_CODEX_IMAGE` | String | `(none)` | Digest-pinned Codex harness image substituted into E2E lifecycle manifests. | +| `KAGENT_E2E_DOCKER_REGISTRY` | String | `localhost:5001` | Image registry for upgrade tests; supplied by make from DOCKER_REGISTRY. | +| `KAGENT_E2E_KIND_CLUSTER_NAME` | String | `kagent` | Kind cluster used by upgrade tests; supplied by make from KIND_CLUSTER_NAME. | +| `KAGENT_E2E_KUBE_CONTEXT` | String | `(none)` | Kubernetes context for upgrade tests. Defaults to kind- followed by KAGENT_E2E_KIND_CLUSTER_NAME. | +| `KAGENT_E2E_LOCAL_HOST` | String | `(none)` | Host reachable from E2E runtimes for local mock servers. Defaults to 172.17.0.1 on Linux and host.docker.internal on macOS; required on other systems. | +| `KAGENT_E2E_NAMESPACE` | String | `kagent` | Kubernetes namespace used by upgrade tests. | +| `KAGENT_E2E_OTLP_LISTEN_ADDRESS` | String | `(none)` | Listen address for the E2E suite's OTLP trace receiver. Unset disables the shared receiver. | +| `KAGENT_E2E_REPO_ROOT` | String | `(none)` | Repository root for upgrade tests. Falls back to git rev-parse --show-toplevel. | +| `KAGENT_E2E_REQUIRE_TRACING` | String | `false` | Fail instead of skip when expected native harness tracing support is unavailable. Enabled by true, ignoring case and surrounding whitespace. | +| `KAGENT_E2E_RUNTIME_IMAGE` | String | `(none)` | Required digest-pinned Go ADK image for sandbox E2E tests and lifecycle manifests. | +| `KAGENT_E2E_RUN_ROLLING_UPGRADE_TESTS` | String | `false` | Run rolling upgrade integration tests when exactly true. | +| `KAGENT_E2E_RUN_UPGRADE_TESTS` | String | `false` | Run upgrade integration tests when exactly true. | +| `KAGENT_E2E_SANDBOX_NAMESPACE` | String | `kagent` | Namespace for sandbox E2E resources. | +| `KAGENT_E2E_SANDBOX_WORKER_POOL` | String | `kagent-default` | Worker pool used by sandbox E2E resources. | +| `KAGENT_E2E_UI_LOOP_EXTENSION_PORT` | String | `(none)` | Example-extension browser-test port. Defaults to KAGENT_E2E_UI_LOOP_PORT plus 50. | +| `KAGENT_E2E_UI_LOOP_LIVE` | Boolean | `false` | Run UI browser tests against a real cluster when exactly true. | +| `KAGENT_E2E_UI_LOOP_LIVE_PORT` | String | `8301` | Live-cluster UI browser-test port. | +| `KAGENT_E2E_UI_LOOP_PORT` | String | `8001` | Vite development/preview port and UI browser-test port. | +| `KAGENT_E2E_UPGRADE_FROM_VERSION` | String | `(none)` | Required previous release version for upgrade tests; supplied by make from UPGRADE_FROM_VERSION. | +| `KAGENT_E2E_VERSION` | String | `(none)` | Required current image tag for upgrade tests; supplied by make from VERSION. | +| `KAGENT_TEST_LLM_PORT` | String | `8090` | Port for the mock LLM server. | +| `KAGENT_TEST_PYTHON` | String | `(none)` | Python executable enabling the Python TaskStore/PostgreSQL interoperability test. That subtest is not run when unset. | +| `KAGENT_TEST_STS_PORT` | String | `8091` | Port for the mock STS (Security Token Service) server. | +| `KAGENT_TEST_UPDATE_GOLDEN` | Boolean | `false` | When true, update golden test files instead of comparing. | +| `KAGENT_UI_VITE_API_MODE` | String | `(none)` | Build-time API mode override, mock or live, used by UI tests. Overrides KAGENT_UI_ENABLE_MOCK; leave unset for normal development. | +| `KUBECONFIG` | String | `(none)` | Kubernetes client configuration file list for the controller, CLI Kubernetes operations, and tests. When unset, client-go uses its normal in-cluster or user kubeconfig discovery. | +| `OPENAI_API_KEY` | String | `(none)` | API key for OpenAI. Upgrade tests fall back to a placeholder when unset or empty. | + +## ui + +| Variable | Type | Default | Description | +|----------|------|---------|-------------| +| `KAGENT_UI_API_BASE_URL` | String | `/api` | Browser API base URL for UI containers and Vite development. | +| `KAGENT_UI_BASE_PATH` | String | `(none)` | UI public path prefix, such as /ui; empty serves at the root. Applies in containers and Vite development; the container falls back to the root for invalid or reserved prefixes. | +| `KAGENT_UI_DEV_CONTROLLER_URL` | String | `http://127.0.0.1:8083` | Vite development proxy target for /api and /a2a; not sent to the browser. | +| `KAGENT_UI_ENABLE_MOCK` | Boolean | `false` | Serve the development UI from in-browser fixtures when true. Overrides backend settings; no user is signed in. Release bundles do not include the mock backend. | +| `KAGENT_UI_EXTENSION_` | String | `(none)` | UI extension settings forwarded to the browser at runtime. Each installed extension owns its keys and defaults; these values are public. | +| `KAGENT_UI_SSO_REDIRECT_PATH` | String | `/oauth2/start` | UI path used by Sign in with SSO. | +| `KAGENT_UI_STREAM_TIMEOUT_MS` | String | `1800000` | UI chat stream inactivity timeout in milliseconds. 0 disables the timeout. Applies in containers and Vite development. | +| `KAGENT_UI_VITE_API_MODE` | String | `(none)` | Build-time API mode override, mock or live, used by UI tests. Overrides KAGENT_UI_ENABLE_MOCK; leave unset for normal development. | +| `KAGENT_UI_VITE_EXAMPLE_EXTENSION` | Boolean | `false` | Build-time switch enabling the bundled example UI extension. | diff --git a/go/.golangci.yaml b/go/.golangci.yaml index 453041b96f..afa9b29986 100644 --- a/go/.golangci.yaml +++ b/go/.golangci.yaml @@ -90,9 +90,12 @@ linters: - shadow enable-all: true forbidigo: + analyze-types: true forbid: - pattern: ^log\.(Fatal|Fatalf|Fatalln|Print|Printf|Println|Panic|Panicf|Panicln)$ msg: Use log/slog with the shared logging package + - pattern: ^os\.(Getenv|LookupEnv)$ + msg: Use a registered accessor from core/pkg/env for configurable settings importas: alias: - pkg: k8s.io/api/core/v1 @@ -157,6 +160,15 @@ linters: - zz_generated.*\.go$ - .*conversion.*\.go$ rules: + - linters: + - forbidigo + text: Use a registered accessor from core/pkg/env + path: '(^|/)core/pkg/env/' + - linters: + - forbidigo + text: Use a registered accessor from core/pkg/env + # Unit tests may inspect process state; core/test E2E settings use the registry. + path: '(^|/)(api|adk|harness|pkg|sandbox|core/(cli|internal|pkg))/.*_test\.go$' - linters: - kubeapilinter path-except: "api/v1alpha" diff --git a/go/Makefile b/go/Makefile index 0e23a79f2b..b54e5569e5 100644 --- a/go/Makefile +++ b/go/Makefile @@ -128,7 +128,7 @@ run: fmt vet ## Run a controller from your host. .PHONY: test test: ## Run all unit tests. - UPDATE_GOLDEN=$(UPDATE_GOLDEN) go test -race -skip 'TestE2E.*' -v ./... + KAGENT_TEST_UPDATE_GOLDEN=$(KAGENT_TEST_UPDATE_GOLDEN) go test -race -skip 'TestE2E.*' -v ./... # Pin e2e tests to the kind cluster so a stray current-context (or an # ambient KUBECONFIG) can't leak resources onto a remote cluster. Override diff --git a/go/adk/cmd/main.go b/go/adk/cmd/main.go index a56781ed11..1555411bd9 100644 --- a/go/adk/cmd/main.go +++ b/go/adk/cmd/main.go @@ -32,10 +32,10 @@ const ( ) func main() { - logLevel := flag.String("log-level", cmp.Or(os.Getenv("LOG_LEVEL"), "info"), "Set the logging level (debug, info, warn, error)") + logLevel := flag.String("log-level", cmp.Or(env.LogLevel.Get(), env.LogLevel.DefaultValue()), "Set the logging level (debug, info, warn, error)") host := flag.String("host", "", "Set the host address to bind to (default: empty, binds to all interfaces)") - portFlag := flag.String("port", "", "Set the port to listen on (overrides PORT environment variable)") - filepathFlag := flag.String("filepath", "", "Set the config directory path (overrides CONFIG_DIR environment variable)") + portFlag := flag.String("port", "", "Set the port to listen on (overrides KAGENT_PORT environment variable)") + filepathFlag := flag.String("filepath", "", "Set the config directory path (overrides KAGENT_CONFIG_DIR environment variable)") flag.Parse() logger, err := logging.New(os.Stderr, *logLevel) @@ -46,20 +46,9 @@ func main() { slog.SetDefault(logger) logger.Info("logger initialized", "level", *logLevel) - port := *portFlag - if port == "" { - port = os.Getenv("PORT") - } - - configDir := *filepathFlag - if configDir == "" { - configDir = os.Getenv("CONFIG_DIR") - } - if configDir == "" { - configDir = "/config" - } + configDir := cmp.Or(*filepathFlag, env.KagentConfigDir.Get(), env.KagentConfigDir.DefaultValue()) - if err := run(logger, *host, port, configDir); err != nil { + if err := run(logger, *host, *portFlag, configDir); err != nil { logger.Error("agent stopped", "error", err) os.Exit(1) } diff --git a/go/adk/examples/byo/main.go b/go/adk/examples/byo/main.go index 0725ddd569..5dc3c08a83 100644 --- a/go/adk/examples/byo/main.go +++ b/go/adk/examples/byo/main.go @@ -11,7 +11,7 @@ // // - KAGENT_NAMESPACE / KAGENT_NAME: used to derive the app name for session // scoping. Falls back to the agent card name. -// - PORT: the port to listen on (default "8080"). +// - KAGENT_PORT: the port to listen on (default "8080"). // // Required environment variables: // @@ -47,7 +47,7 @@ func main() { logger, _ := logging.New(os.Stderr, "info") slog.SetDefault(logger) - modelName := os.Getenv("MODEL_NAME") + modelName := os.Getenv("MODEL_NAME") //nolint:forbidigo // Application-specific setting in this standalone BYO example. if modelName == "" { modelName = "gpt-4o-mini" } diff --git a/go/adk/pkg/a2a/server/server.go b/go/adk/pkg/a2a/server/server.go index 9cc16fd035..b3f012d141 100644 --- a/go/adk/pkg/a2a/server/server.go +++ b/go/adk/pkg/a2a/server/server.go @@ -19,6 +19,7 @@ import ( a2agrpc "github.com/a2aproject/a2a-go/v2/a2agrpc/v1" a2apb "github.com/a2aproject/a2a-go/v2/a2apb/v1" "github.com/a2aproject/a2a-go/v2/a2asrv" + "github.com/kagent-dev/kagent/go/core/pkg/env" "go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc" "go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc/filters" "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" @@ -32,8 +33,7 @@ import ( ) const ( - a2aMaxContentLengthEnvVar = "A2A_MAX_CONTENT_LENGTH" - defaultMaxContentLength = int64(10 * 1024 * 1024) + defaultMaxContentLength = int64(10 * 1024 * 1024) ) // ServerConfig holds configuration for the A2A server. @@ -201,7 +201,7 @@ func (h rpcEndSignal) HandleRPC(ctx context.Context, rpcStats stats.RPCStats) { } func getMaxContentLength(logger *slog.Logger) *int64 { - value, ok := os.LookupEnv(a2aMaxContentLengthEnvVar) + value, ok := env.KagentA2AMaxContentLength.Lookup() if !ok { maxContentLength := defaultMaxContentLength return &maxContentLength @@ -217,7 +217,7 @@ func getMaxContentLength(logger *slog.Logger) *int64 { if err != nil || maxContentLength < 0 { logger.Info( "invalid A2A request size limit, using default", - "environment_variable", a2aMaxContentLengthEnvVar, + "environment_variable", env.KagentA2AMaxContentLength.Name(), "value", value, "default", defaultMaxContentLength, ) diff --git a/go/adk/pkg/a2a/server/server_test.go b/go/adk/pkg/a2a/server/server_test.go index 1ab5a706a6..8fafe92338 100644 --- a/go/adk/pkg/a2a/server/server_test.go +++ b/go/adk/pkg/a2a/server/server_test.go @@ -18,6 +18,7 @@ import ( a2apb "github.com/a2aproject/a2a-go/v2/a2apb/v1" "github.com/a2aproject/a2a-go/v2/a2apb/v1/pbconv" "github.com/a2aproject/a2a-go/v2/a2asrv" + "github.com/kagent-dev/kagent/go/core/pkg/env" "github.com/kagent-dev/kagent/go/pkg/telemetry" "go.opentelemetry.io/otel" sdktrace "go.opentelemetry.io/otel/sdk/trace" @@ -305,7 +306,7 @@ func TestA2ARequestSizeLimit(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - t.Setenv(a2aMaxContentLengthEnvVar, "5") + t.Setenv(env.KagentA2AMaxContentLength.Name(), "5") srv, err := NewA2AServer(a2atype.AgentCard{}, substrateExecutor{}, slog.New(slog.DiscardHandler), ServerConfig{Port: "0"}) if err != nil { t.Fatalf("NewA2AServer: %v", err) @@ -328,7 +329,7 @@ func TestA2ARequestSizeLimit(t *testing.T) { } func TestA2ARequestSizeLimitDisabled(t *testing.T) { - t.Setenv(a2aMaxContentLengthEnvVar, "unlimited") + t.Setenv(env.KagentA2AMaxContentLength.Name(), "unlimited") srv, err := NewA2AServer(a2atype.AgentCard{}, substrateExecutor{}, slog.New(slog.DiscardHandler), ServerConfig{Port: "0"}) if err != nil { t.Fatalf("NewA2AServer: %v", err) @@ -378,7 +379,7 @@ func TestGetMaxContentLength(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - t.Setenv(a2aMaxContentLengthEnvVar, tt.value) + t.Setenv(env.KagentA2AMaxContentLength.Name(), tt.value) got := getMaxContentLength(slog.New(slog.DiscardHandler)) if tt.unlimited { if got != nil { diff --git a/go/adk/pkg/agent/agent.go b/go/adk/pkg/agent/agent.go index c7f02b20d7..9fc6b3f002 100644 --- a/go/adk/pkg/agent/agent.go +++ b/go/adk/pkg/agent/agent.go @@ -15,6 +15,7 @@ import ( "github.com/kagent-dev/kagent/go/adk/pkg/sts" "github.com/kagent-dev/kagent/go/adk/pkg/tools" "github.com/kagent-dev/kagent/go/api/adk" + "github.com/kagent-dev/kagent/go/core/pkg/env" "github.com/kagent-dev/kagent/go/pkg/logging" "google.golang.org/adk/v2/agent" "google.golang.org/adk/v2/agent/llmagent" @@ -51,7 +52,7 @@ func createGoogleADKAgent(ctx context.Context, agentConfig *adk.AgentConfig, age return nil, fmt.Errorf("agent config is required") } - propagateToken := strings.ToLower(os.Getenv("KAGENT_PROPAGATE_TOKEN")) == "true" + propagateToken := strings.EqualFold(strings.TrimSpace(env.KagentPropagateToken.Get()), "true") var dynamicHeaderProvider mcp.DynamicHeaderProvider if stsPlugin != nil { dynamicHeaderProvider = stsPlugin.HeaderProvider @@ -59,7 +60,9 @@ func createGoogleADKAgent(ctx context.Context, agentConfig *adk.AgentConfig, age toolsets := mcp.CreateToolsets(ctx, agentConfig.HttpTools, agentConfig.SseTools, agentConfig.StdioTools, propagateToken, dynamicHeaderProvider) skillsDirectory := agentConfig.SkillsDirectory if skillsDirectory == "" && legacySkillsEnv { - skillsDirectory = strings.TrimSpace(os.Getenv("KAGENT_SKILLS_FOLDER")) + if folder, set := env.KagentSkillsFolder.Lookup(); set { + skillsDirectory = strings.TrimSpace(folder) + } } if skillsDirectory != "" { skillsSource := skill.NewFileSystemSource(os.DirFS(skillsDirectory)) @@ -282,9 +285,9 @@ func CreateLLM(ctx context.Context, m adk.Model) (adkmodel.LLM, error) { return models.NewAzureOpenAIModel(ctx, cfg) case *adk.Gemini: - apiKey := os.Getenv("GOOGLE_API_KEY") + apiKey := env.GoogleAPIKey.Get() if apiKey == "" { - apiKey = os.Getenv("GEMINI_API_KEY") + apiKey = env.GeminiAPIKey.Get() } if apiKey == "" { return nil, fmt.Errorf("gemini model requires GOOGLE_API_KEY or GEMINI_API_KEY environment variable") @@ -303,10 +306,10 @@ func CreateLLM(ctx context.Context, m adk.Model) (adkmodel.LLM, error) { }) case *adk.GeminiVertexAI: - project := os.Getenv("GOOGLE_CLOUD_PROJECT") - location := os.Getenv("GOOGLE_CLOUD_LOCATION") + project := env.GoogleCloudProject.Get() + location := env.GoogleCloudLocation.Get() if location == "" { - location = os.Getenv("GOOGLE_CLOUD_REGION") + location = env.GoogleCloudRegion.Get() } if project == "" || location == "" { return nil, fmt.Errorf("GeminiVertexAI requires GOOGLE_CLOUD_PROJECT and GOOGLE_CLOUD_LOCATION (or GOOGLE_CLOUD_REGION) environment variables") @@ -354,7 +357,7 @@ func CreateLLM(ctx context.Context, m adk.Model) (adkmodel.LLM, error) { // then applies the cloud/local routing. Defaulting it to localhost here // would look like an operator-chosen endpoint and pin every cloud model // to the local daemon. - baseURL := os.Getenv("OLLAMA_API_BASE") + baseURL := env.OllamaAPIBase.Get() modelName := m.Model if modelName == "" { modelName = DefaultOllamaModel @@ -365,7 +368,7 @@ func CreateLLM(ctx context.Context, m adk.Model) (adkmodel.LLM, error) { Model: modelName, Host: baseURL, // The environment holds only the gateway credential placeholder. - APIKey: os.Getenv("OLLAMA_API_KEY"), + APIKey: env.OllamaAPIKey.Get(), Options: m.Options, } return models.NewOllamaModel(ctx, cfg) @@ -373,7 +376,7 @@ func CreateLLM(ctx context.Context, m adk.Model) (adkmodel.LLM, error) { case *adk.Bedrock: region := m.Region if region == "" { - region = os.Getenv("AWS_REGION") + region = env.AWSRegion.Get() } if region == "" { return nil, fmt.Errorf("bedrock requires AWS_REGION environment variable or region in model config") @@ -406,10 +409,10 @@ func CreateLLM(ctx context.Context, m adk.Model) (adkmodel.LLM, error) { case *adk.GeminiAnthropic: // GeminiAnthropic = Claude models accessed through Google Cloud Vertex AI. // Uses the Anthropic SDK's built-in Vertex AI support with Application Default Credentials. - project := os.Getenv("GOOGLE_CLOUD_PROJECT") - region := os.Getenv("GOOGLE_CLOUD_LOCATION") + project := env.GoogleCloudProject.Get() + region := env.GoogleCloudLocation.Get() if region == "" { - region = os.Getenv("GOOGLE_CLOUD_REGION") + region = env.GoogleCloudRegion.Get() } if project == "" || region == "" { return nil, fmt.Errorf("GeminiAnthropic (Anthropic on Vertex AI) requires GOOGLE_CLOUD_PROJECT and GOOGLE_CLOUD_LOCATION environment variables") diff --git a/go/adk/pkg/app/app.go b/go/adk/pkg/app/app.go index 95f916bb3e..eaac8a2fca 100644 --- a/go/adk/pkg/app/app.go +++ b/go/adk/pkg/app/app.go @@ -41,7 +41,7 @@ type AppConfig struct { // Host is the address to bind to. Empty string binds to all interfaces. Host string - // Port is the port to listen on. Defaults to the PORT env var, then "8080". + // Port is the port to listen on. Defaults to the KAGENT_PORT env var, then "8080". Port string // AppName identifies this application for session and tracing purposes. @@ -90,7 +90,7 @@ func New(cfg AppConfig, executor a2asrv.AgentExecutor) (*KAgentApp, error) { if cfg.Logger == nil { logger, err := logging.NewFromEnv(os.Stderr) if err != nil { - return nil, fmt.Errorf("parse LOG_LEVEL: %w", err) + return nil, fmt.Errorf("parse KAGENT_LOG_LEVEL: %w", err) } cfg.Logger = logger } @@ -181,7 +181,7 @@ func (a *KAgentApp) Logger() *slog.Logger { // applyDefaults fills in zero-value fields with sensible defaults. func applyDefaults(cfg AppConfig) AppConfig { if cfg.Port == "" { - cfg.Port = os.Getenv("PORT") + cfg.Port = env.KagentPort.Get() } if cfg.Port == "" { cfg.Port = defaultPort @@ -209,10 +209,10 @@ func applyDefaults(cfg AppConfig) AppConfig { // buildAppName derives the app name from environment variables or agent card, // following the same convention as the Python KAgentConfig. func buildAppName(agentCard *a2atype.AgentCard) string { - kagentName := os.Getenv("KAGENT_NAME") - kagentNamespace := os.Getenv("KAGENT_NAMESPACE") + kagentName := env.KagentName.Get() + kagentNamespace, namespaceSet := env.KagentNamespace.Lookup() - if kagentNamespace != "" && kagentName != "" { + if namespaceSet && kagentNamespace != "" && kagentName != "" { namespace := strings.ReplaceAll(kagentNamespace, "-", "_") name := strings.ReplaceAll(kagentName, "-", "_") return namespace + "__NS__" + name diff --git a/go/adk/pkg/app/app_test.go b/go/adk/pkg/app/app_test.go index 3db845d2a7..489dc30ca0 100644 --- a/go/adk/pkg/app/app_test.go +++ b/go/adk/pkg/app/app_test.go @@ -3,6 +3,7 @@ package app import ( "context" "iter" + "os" "slices" "testing" "time" @@ -76,7 +77,7 @@ func TestNew_ProvidedController(t *testing.T) { } func TestApplyDefaults_Port(t *testing.T) { - t.Setenv("PORT", "") + t.Setenv("KAGENT_PORT", "") cfg := applyDefaults(AppConfig{}) if cfg.Port != defaultPort { t.Errorf("expected port %q, got %q", defaultPort, cfg.Port) @@ -84,7 +85,7 @@ func TestApplyDefaults_Port(t *testing.T) { } func TestApplyDefaults_PortFromEnv(t *testing.T) { - t.Setenv("PORT", "9090") + t.Setenv("KAGENT_PORT", "9090") cfg := applyDefaults(AppConfig{}) if cfg.Port != "9090" { t.Errorf("expected port %q, got %q", "9090", cfg.Port) @@ -92,7 +93,7 @@ func TestApplyDefaults_PortFromEnv(t *testing.T) { } func TestApplyDefaults_PortExplicit(t *testing.T) { - t.Setenv("PORT", "9090") + t.Setenv("KAGENT_PORT", "9090") cfg := applyDefaults(AppConfig{Port: "3000"}) if cfg.Port != "3000" { t.Errorf("expected port %q, got %q", "3000", cfg.Port) @@ -131,6 +132,13 @@ func TestBuildAppName_FromAgentCard(t *testing.T) { } } +func TestBuildAppName_UnsetNamespace(t *testing.T) { + t.Setenv(env.KagentName.Name(), "my-agent") + t.Setenv(env.KagentNamespace.Name(), "") + require.NoError(t, os.Unsetenv(env.KagentNamespace.Name())) + require.Equal(t, "card-name", buildAppName(&a2atype.AgentCard{Name: "card-name"})) +} + func TestBuildAppName_Default(t *testing.T) { t.Setenv("KAGENT_NAME", "") t.Setenv("KAGENT_NAMESPACE", "") diff --git a/go/adk/pkg/config/config_materialize.go b/go/adk/pkg/config/config_materialize.go index cd62680cf8..f744b77a7e 100644 --- a/go/adk/pkg/config/config_materialize.go +++ b/go/adk/pkg/config/config_materialize.go @@ -32,7 +32,7 @@ func MaterializeFromEnv(configDir string) error { } func materializeEnvToFile(envKey, path string) error { - value := strings.TrimSpace(os.Getenv(envKey)) + value := strings.TrimSpace(os.Getenv(envKey)) //nolint:forbidigo // Controller-generated payloads and credentials, not user configuration. if value == "" { return nil } @@ -67,7 +67,7 @@ func expandConfigEnv(raw string) (string, error) { const prefix, suffix = "__KAGENT_ENV[", "]__" if strings.HasPrefix(value, prefix) && strings.HasSuffix(value, suffix) { name := strings.TrimSuffix(strings.TrimPrefix(value, prefix), suffix) - resolved, ok := os.LookupEnv(name) + resolved, ok := os.LookupEnv(name) //nolint:forbidigo // Resolve arbitrary credential placeholders in compiled configuration. if !ok { return nil, fmt.Errorf("required environment variable %s is not set", name) } diff --git a/go/adk/pkg/embedding/embedding.go b/go/adk/pkg/embedding/embedding.go index dad54c3d17..576956e040 100644 --- a/go/adk/pkg/embedding/embedding.go +++ b/go/adk/pkg/embedding/embedding.go @@ -7,7 +7,6 @@ import ( "math" "net/http" "net/url" - "os" "sync" "time" @@ -19,6 +18,7 @@ import ( "github.com/kagent-dev/kagent/go/adk/pkg/internal/azureai" "github.com/kagent-dev/kagent/go/adk/pkg/models" "github.com/kagent-dev/kagent/go/api/adk" + "github.com/kagent-dev/kagent/go/core/pkg/env" "github.com/kagent-dev/kagent/go/pkg/logging" "github.com/ollama/ollama/api" "github.com/openai/openai-go/v3" @@ -99,7 +99,7 @@ type openAIProvider struct { } func newOpenAIProvider(cfg *adk.EmbeddingConfig) (*openAIProvider, error) { - apiKey := os.Getenv("OPENAI_API_KEY") + apiKey := env.OpenAIAPIKey.Get() httpClient, err := embeddingHTTPClient(cfg) if err != nil { return nil, err @@ -167,7 +167,7 @@ type azureOpenAIProvider struct { func newAzureOpenAIProvider(cfg *adk.EmbeddingConfig, cred azureai.TokenCredential) (*azureOpenAIProvider, error) { apiVersion := cfg.APIVersion if apiVersion == "" { - apiVersion = os.Getenv("OPENAI_API_VERSION") + apiVersion = env.OpenAIAPIVersion.Get() } if apiVersion == "" { apiVersion = "2024-02-15-preview" @@ -178,7 +178,7 @@ func newAzureOpenAIProvider(cfg *adk.EmbeddingConfig, cred azureai.TokenCredenti endpoint = cfg.BaseUrl } if endpoint == "" { - endpoint = os.Getenv("AZURE_OPENAI_ENDPOINT") + endpoint = env.AzureOpenAIEndpoint.Get() } if endpoint == "" { return nil, fmt.Errorf("Azure OpenAI endpoint must be set via endpoint, base_url, or AZURE_OPENAI_ENDPOINT env var") //nolint:staticcheck // ST1005: keep product name readable @@ -203,7 +203,7 @@ func newAzureOpenAIProvider(cfg *adk.EmbeddingConfig, cred azureai.TokenCredenti // token when APIKeyPassthrough is enabled (a placeholder Api-Key is // overwritten per request by embeddingPassthroughOpts), otherwise the // AZURE_OPENAI_API_KEY Api-Key header, otherwise DefaultAzureCredential. - apiKey := os.Getenv("AZURE_OPENAI_API_KEY") + apiKey := env.AzureOpenAIAPIKey.Get() if cfg.APIKeyPassthrough { apiKey = "passthrough" } @@ -236,7 +236,7 @@ type ollamaProvider struct { func newOllamaProvider(cfg *adk.EmbeddingConfig) (*ollamaProvider, error) { host := cfg.BaseUrl if host == "" { - host = os.Getenv("OLLAMA_API_BASE") + host = env.OllamaAPIBase.Get() } if host == "" { host = "http://localhost:11434" @@ -282,7 +282,7 @@ func (p *geminiProvider) generate(ctx context.Context, texts []string) ([][]floa p.once.Do(func() { client, err := genai.NewClient(ctx, &genai.ClientConfig{ - APIKey: os.Getenv("GOOGLE_API_KEY"), + APIKey: env.GoogleAPIKey.Get(), }) if err != nil { p.initErr = fmt.Errorf("failed to create genai client: %w", err) @@ -325,9 +325,9 @@ type bedrockProvider struct { func (p *bedrockProvider) generate(ctx context.Context, texts []string) ([][]float32, error) { log := logging.FromContext(ctx) - region := os.Getenv("AWS_DEFAULT_REGION") + region := env.AWSDefaultRegion.Get() if region == "" { - region = os.Getenv("AWS_REGION") + region = env.AWSRegion.Get() } if region == "" { region = "us-east-1" @@ -460,7 +460,7 @@ func newFoundryProvider(cfg *adk.EmbeddingConfig, cred azureai.TokenCredential) } // See newAzureOpenAIProvider - the passthrough placeholder short-circuits // past DefaultAzureCredential resolution the same way it does for chat. - apiKey := os.Getenv(azureai.FoundryAPIKeyEnvVar) + apiKey := env.FoundryAPIKey.Get() if cfg.APIKeyPassthrough { apiKey = "passthrough" } diff --git a/go/adk/pkg/internal/azureai/azureai.go b/go/adk/pkg/internal/azureai/azureai.go index 8d91d80798..0a7e3c66eb 100644 --- a/go/adk/pkg/internal/azureai/azureai.go +++ b/go/adk/pkg/internal/azureai/azureai.go @@ -11,7 +11,6 @@ import ( "fmt" "net/http" "net/url" - "os" "strings" "github.com/Azure/azure-sdk-for-go/sdk/azcore" @@ -19,6 +18,7 @@ import ( "github.com/Azure/azure-sdk-for-go/sdk/azidentity" "github.com/anthropics/anthropic-sdk-go" anthropicoption "github.com/anthropics/anthropic-sdk-go/option" + "github.com/kagent-dev/kagent/go/core/pkg/env" "github.com/openai/openai-go/v3" "github.com/openai/openai-go/v3/option" ) @@ -41,37 +41,24 @@ func resolveScope(scope string) string { return scope } -// Foundry-specific configuration conventions. These are the environment variables -// the controller injects for a Foundry ModelConfig plus the default api-version. -const ( - FoundryEndpointEnvVar = "FOUNDRY_ENDPOINT" - FoundryDeploymentEnvVar = "FOUNDRY_DEPLOYMENT" - FoundryAPIVersionEnvVar = "FOUNDRY_API_VERSION" - FoundryAPIKeyEnvVar = "FOUNDRY_API_KEY" -) - -// FoundryDefaultAPIVersion is the Foundry OpenAI-compatible data-plane API -// version used when none is configured. -const FoundryDefaultAPIVersion = "2024-10-21" - // ResolveFoundry applies FOUNDRY_* environment-variable fallbacks and the default // api-version. Empty endpoint/deployment are returned as-is so callers can // produce context-specific validation errors. func ResolveFoundry(endpoint, deployment, apiVersion string) (ep, dep, ver string) { ep = endpoint if ep == "" { - ep = os.Getenv(FoundryEndpointEnvVar) + ep = env.FoundryEndpoint.Get() } dep = deployment if dep == "" { - dep = os.Getenv(FoundryDeploymentEnvVar) + dep = env.FoundryDeployment.Get() } ver = apiVersion if ver == "" { - ver = os.Getenv(FoundryAPIVersionEnvVar) + ver = env.FoundryAPIVersion.Get() } if ver == "" { - ver = FoundryDefaultAPIVersion + ver = env.FoundryAPIVersion.DefaultValue() } return ep, dep, ver } diff --git a/go/adk/pkg/internal/azureai/azureai_test.go b/go/adk/pkg/internal/azureai/azureai_test.go index 7db13465c5..c9c989699f 100644 --- a/go/adk/pkg/internal/azureai/azureai_test.go +++ b/go/adk/pkg/internal/azureai/azureai_test.go @@ -11,6 +11,7 @@ import ( "github.com/Azure/azure-sdk-for-go/sdk/azcore" "github.com/Azure/azure-sdk-for-go/sdk/azcore/policy" "github.com/anthropics/anthropic-sdk-go" + "github.com/kagent-dev/kagent/go/core/pkg/env" "github.com/openai/openai-go/v3" ) @@ -245,9 +246,9 @@ func TestNewAnthropicClientWorkloadIdentity(t *testing.T) { } func TestResolveFoundryUsesProvidedValues(t *testing.T) { - t.Setenv(FoundryEndpointEnvVar, "env-endpoint") - t.Setenv(FoundryDeploymentEnvVar, "env-deployment") - t.Setenv(FoundryAPIVersionEnvVar, "env-version") + t.Setenv(env.FoundryEndpoint.Name(), "env-endpoint") + t.Setenv(env.FoundryDeployment.Name(), "env-deployment") + t.Setenv(env.FoundryAPIVersion.Name(), "env-version") ep, dep, ver := ResolveFoundry("cfg-endpoint", "cfg-deployment", "cfg-version") if ep != "cfg-endpoint" || dep != "cfg-deployment" || ver != "cfg-version" { @@ -256,9 +257,9 @@ func TestResolveFoundryUsesProvidedValues(t *testing.T) { } func TestResolveFoundryFallsBackToEnv(t *testing.T) { - t.Setenv(FoundryEndpointEnvVar, "env-endpoint") - t.Setenv(FoundryDeploymentEnvVar, "env-deployment") - t.Setenv(FoundryAPIVersionEnvVar, "env-version") + t.Setenv(env.FoundryEndpoint.Name(), "env-endpoint") + t.Setenv(env.FoundryDeployment.Name(), "env-deployment") + t.Setenv(env.FoundryAPIVersion.Name(), "env-version") ep, dep, ver := ResolveFoundry("", "", "") if ep != "env-endpoint" || dep != "env-deployment" || ver != "env-version" { @@ -267,12 +268,12 @@ func TestResolveFoundryFallsBackToEnv(t *testing.T) { } func TestResolveFoundryDefaultsAPIVersion(t *testing.T) { - t.Setenv(FoundryEndpointEnvVar, "") - t.Setenv(FoundryDeploymentEnvVar, "") - t.Setenv(FoundryAPIVersionEnvVar, "") + t.Setenv(env.FoundryEndpoint.Name(), "") + t.Setenv(env.FoundryDeployment.Name(), "") + t.Setenv(env.FoundryAPIVersion.Name(), "") ep, dep, ver := ResolveFoundry("e", "d", "") - if ep != "e" || dep != "d" || ver != FoundryDefaultAPIVersion { + if ep != "e" || dep != "d" || ver != env.FoundryAPIVersion.DefaultValue() { t.Fatalf("ResolveFoundry() = (%q, %q, %q), want default api-version", ep, dep, ver) } } diff --git a/go/adk/pkg/models/anthropic.go b/go/adk/pkg/models/anthropic.go index 231a2089f8..50b8bf11a5 100644 --- a/go/adk/pkg/models/anthropic.go +++ b/go/adk/pkg/models/anthropic.go @@ -4,13 +4,13 @@ import ( "context" "fmt" "log/slog" - "os" "github.com/anthropics/anthropic-sdk-go" "github.com/anthropics/anthropic-sdk-go/bedrock" "github.com/anthropics/anthropic-sdk-go/option" "github.com/anthropics/anthropic-sdk-go/vertex" awsconfig "github.com/aws/aws-sdk-go-v2/config" + "github.com/kagent-dev/kagent/go/core/pkg/env" "github.com/kagent-dev/kagent/go/pkg/logging" ) @@ -47,7 +47,7 @@ type AnthropicModel struct { func NewAnthropicModel(ctx context.Context, config *AnthropicConfig) (*AnthropicModel, error) { apiKey := "passthrough" // placeholder; real auth set per-request by transport if !config.APIKeyPassthrough { - apiKey = os.Getenv("ANTHROPIC_API_KEY") + apiKey = env.AnthropicAPIKey.Get() if apiKey == "" { return nil, fmt.Errorf("ANTHROPIC_API_KEY environment variable is not set") } diff --git a/go/adk/pkg/models/foundry.go b/go/adk/pkg/models/foundry.go index 520f847de0..679c3416f7 100644 --- a/go/adk/pkg/models/foundry.go +++ b/go/adk/pkg/models/foundry.go @@ -3,10 +3,10 @@ package models import ( "context" "fmt" - "os" "strings" "github.com/kagent-dev/kagent/go/adk/pkg/internal/azureai" + "github.com/kagent-dev/kagent/go/core/pkg/env" "github.com/kagent-dev/kagent/go/pkg/logging" ) @@ -68,7 +68,7 @@ func NewFoundryModel(ctx context.Context, config *FoundryConfig) (*OpenAIModel, // otherwise the API key when provided, otherwise DefaultAzureCredential // (Workload Identity in-cluster, az CLI in dev), eagerly probed so a // misconfigured identity fails readiness at startup. - apiKey := os.Getenv(azureai.FoundryAPIKeyEnvVar) + apiKey := env.FoundryAPIKey.Get() if config.APIKeyPassthrough { apiKey = "passthrough" } @@ -141,7 +141,7 @@ func NewFoundryAnthropicModel(ctx context.Context, config *AnthropicConfig, endp HTTPClient: httpClient, } - apiKey := os.Getenv(azureai.FoundryAPIKeyEnvVar) + apiKey := env.FoundryAPIKey.Get() if config.APIKeyPassthrough { // Placeholder x-api-key overwritten per request by anthropicPassthroughOpts. apiKey = "passthrough" diff --git a/go/adk/pkg/models/mistral.go b/go/adk/pkg/models/mistral.go index e84a0a6481..ab9e0711f2 100644 --- a/go/adk/pkg/models/mistral.go +++ b/go/adk/pkg/models/mistral.go @@ -4,8 +4,8 @@ import ( "context" "fmt" "log/slog" - "os" + "github.com/kagent-dev/kagent/go/core/pkg/env" "github.com/kagent-dev/kagent/go/pkg/logging" "github.com/openai/openai-go/v3" "github.com/openai/openai-go/v3/option" @@ -45,7 +45,7 @@ func NewMistralModel(ctx context.Context, config *MistralConfig) (*MistralModel, logger := logging.FromContext(ctx) apiKey := "passthrough" // placeholder; real auth set per-request by transport if !config.APIKeyPassthrough { - apiKey = os.Getenv("MISTRAL_API_KEY") + apiKey = env.MistralAPIKey.Get() if apiKey == "" { return nil, fmt.Errorf("MISTRAL_API_KEY environment variable is not set") } @@ -53,7 +53,7 @@ func NewMistralModel(ctx context.Context, config *MistralConfig) (*MistralModel, baseURL := config.BaseUrl if baseURL == "" { - baseURL = os.Getenv("MISTRAL_API_BASE") + baseURL = env.MistralAPIBase.Get() } if baseURL == "" { baseURL = DefaultMistralBaseURL diff --git a/go/adk/pkg/models/ollama.go b/go/adk/pkg/models/ollama.go index 8389c2abea..424e7929df 100644 --- a/go/adk/pkg/models/ollama.go +++ b/go/adk/pkg/models/ollama.go @@ -5,10 +5,10 @@ import ( "fmt" "log/slog" "net/url" - "os" "strconv" "strings" + "github.com/kagent-dev/kagent/go/core/pkg/env" "github.com/kagent-dev/kagent/go/pkg/logging" "github.com/ollama/ollama/api" ) @@ -288,7 +288,7 @@ func NewOllamaModel(ctx context.Context, config *OllamaConfig) (*OllamaModel, er logger := logging.FromContext(ctx) host := config.Host if host == "" { - host = os.Getenv("OLLAMA_API_BASE") + host = env.OllamaAPIBase.Get() } host = resolveOllamaEndpoint(config, host) diff --git a/go/adk/pkg/models/openai.go b/go/adk/pkg/models/openai.go index 58d6ca70f6..bb0cd2e106 100644 --- a/go/adk/pkg/models/openai.go +++ b/go/adk/pkg/models/openai.go @@ -4,9 +4,9 @@ import ( "context" "fmt" "log/slog" - "os" "github.com/kagent-dev/kagent/go/adk/pkg/internal/azureai" + "github.com/kagent-dev/kagent/go/core/pkg/env" "github.com/kagent-dev/kagent/go/pkg/logging" "github.com/openai/openai-go/v3" "github.com/openai/openai-go/v3/option" @@ -107,7 +107,7 @@ func resolveOpenAIAPIKey(ctx context.Context, config *OpenAIConfig) (string, err if config.APIKey != "" { return config.APIKey, nil } - if apiKey := os.Getenv("OPENAI_API_KEY"); apiKey != "" { + if apiKey := env.OpenAIAPIKey.Get(); apiKey != "" { return apiKey, nil } if config.BaseUrl == "" { @@ -135,7 +135,7 @@ func NewAzureOpenAIModel(ctx context.Context, config *AzureOpenAIConfig) (*OpenA logger := logging.FromContext(ctx) endpoint := config.Endpoint if endpoint == "" { - endpoint = os.Getenv("AZURE_OPENAI_ENDPOINT") + endpoint = env.AzureOpenAIEndpoint.Get() } if endpoint == "" { return nil, fmt.Errorf("AZURE_OPENAI_ENDPOINT environment variable is not set") @@ -143,7 +143,7 @@ func NewAzureOpenAIModel(ctx context.Context, config *AzureOpenAIConfig) (*OpenA apiVersion := config.APIVersion if apiVersion == "" { - apiVersion = os.Getenv("OPENAI_API_VERSION") + apiVersion = env.OpenAIAPIVersion.Get() } if apiVersion == "" { apiVersion = "2024-02-15-preview" @@ -170,7 +170,7 @@ func NewAzureOpenAIModel(ctx context.Context, config *AzureOpenAIConfig) (*OpenA // (a placeholder Api-Key is overwritten per request by openAIPassthroughOpts), // otherwise the AZURE_OPENAI_API_KEY Api-Key header, otherwise // DefaultAzureCredential (Workload Identity), eagerly probed for readiness. - apiKey := os.Getenv("AZURE_OPENAI_API_KEY") + apiKey := env.AzureOpenAIAPIKey.Get() if config.APIKeyPassthrough { apiKey = "passthrough" } diff --git a/go/adk/pkg/models/sapaicore.go b/go/adk/pkg/models/sapaicore.go index ea30154021..dc149a2d5a 100644 --- a/go/adk/pkg/models/sapaicore.go +++ b/go/adk/pkg/models/sapaicore.go @@ -6,13 +6,13 @@ import ( "fmt" "net/http" "net/url" - "os" "strings" "sync" "time" "log/slog" + "github.com/kagent-dev/kagent/go/core/pkg/env" "github.com/kagent-dev/kagent/go/pkg/logging" ) @@ -58,8 +58,8 @@ func (m *SAPAICoreModel) ensureToken(ctx context.Context) (string, error) { return m.token, nil } - clientID := os.Getenv("SAP_AI_CORE_CLIENT_ID") - clientSecret := os.Getenv("SAP_AI_CORE_CLIENT_SECRET") + clientID := env.SAPAICoreClientID.Get() + clientSecret := env.SAPAICoreClientSecret.Get() if m.Config.AuthUrl == "" || clientID == "" || clientSecret == "" { return "", fmt.Errorf("SAP AI Core requires auth_url + SAP_AI_CORE_CLIENT_ID/SECRET env vars") } diff --git a/go/adk/pkg/runner/adapter.go b/go/adk/pkg/runner/adapter.go index 6d38b05f28..1d32161fcc 100644 --- a/go/adk/pkg/runner/adapter.go +++ b/go/adk/pkg/runner/adapter.go @@ -3,7 +3,6 @@ package runner import ( "context" "fmt" - "os" "strings" "log/slog" @@ -13,6 +12,7 @@ import ( kagentmemory "github.com/kagent-dev/kagent/go/adk/pkg/memory" "github.com/kagent-dev/kagent/go/adk/pkg/sts" "github.com/kagent-dev/kagent/go/api/adk" + "github.com/kagent-dev/kagent/go/core/pkg/env" "github.com/kagent-dev/kagent/go/pkg/logging" adkmemory "google.golang.org/adk/v2/memory" adkplugin "google.golang.org/adk/v2/plugin" @@ -107,8 +107,8 @@ func CreateRunnerConfig( } func buildTokenPropagationPlugin(ctx context.Context, log *slog.Logger) (*sts.TokenPropagationPlugin, error) { - propagateToken := strings.EqualFold(strings.TrimSpace(os.Getenv("KAGENT_PROPAGATE_TOKEN")), "true") - stsWellKnownURI := strings.TrimSpace(os.Getenv("STS_WELL_KNOWN_URI")) + propagateToken := strings.EqualFold(strings.TrimSpace(env.KagentPropagateToken.Get()), "true") + stsWellKnownURI := strings.TrimSpace(env.StsWellKnownURI.Get()) if !propagateToken && stsWellKnownURI == "" { return nil, nil } @@ -135,8 +135,8 @@ func buildTokenPropagationPlugin(ctx context.Context, log *slog.Logger) (*sts.To // RFC 8707 resource / RFC 8693 audience scope the exchanged token to a // backend. Both are repeatable; empty values are omitted from the request. - resource := splitCSV(os.Getenv("KAGENT_STS_RESOURCE")) - audience := splitCSV(os.Getenv("KAGENT_STS_AUDIENCE")) + resource := splitCSV(env.KagentSTSResource.Get()) + audience := splitCSV(env.KagentSTSAudience.Get()) log.InfoContext(ctx, "enabling STS token propagation plugin", "well_known_uri", stsWellKnownURI) return sts.NewTokenPropagationPlugin(integration, log, resource, audience), nil diff --git a/go/adk/pkg/tools/skills.go b/go/adk/pkg/tools/skills.go index ab77bab733..bb77690d76 100644 --- a/go/adk/pkg/tools/skills.go +++ b/go/adk/pkg/tools/skills.go @@ -6,38 +6,12 @@ import ( "path/filepath" "strings" + "github.com/kagent-dev/kagent/go/core/pkg/env" adkagent "google.golang.org/adk/v2/agent" "google.golang.org/adk/v2/tool" "google.golang.org/adk/v2/tool/functiontool" ) -// enableFileSearchToolsEnv gates the list_files and grep_file tools, which -// are opt-in (disabled by default): they let an agent enumerate and search -// the filesystem under its session/skills roots without invoking a shell, so -// deployments that want to grant that visibility deliberately can, rather -// than having it enabled implicitly. -// -// Also registered (separately, for `kagent env` CLI discoverability only, -// not read here) as KagentEnableFileSearchTools in go/core/pkg/env/kagent.go. -// TestEnableFileSearchToolsEnvMatchesRegistry pins the two literals together -// so they cannot drift. -const enableFileSearchToolsEnv = "KAGENT_ENABLE_FILE_SEARCH_TOOLS" - -// fileSearchToolsEnabled accepts the same case-insensitive true-values as -// Python's file_search_tools_enabled() (kagent-skills/shell.py), so the -// same literal env var value behaves identically in either runtime rather -// than relying on Go's strconv.ParseBool grammar, which Python doesn't -// replicate exactly (e.g. ParseBool requires the exact casing "True", not -// "tRue"). -func fileSearchToolsEnabled() bool { - switch strings.ToLower(strings.TrimSpace(os.Getenv(enableFileSearchToolsEnv))) { - case "1", "t", "true": - return true - default: - return false - } -} - const ( readFileDescription = `Reads a file from the filesystem with line numbers. @@ -233,7 +207,7 @@ func NewSkillExecutionTools(skillsDirectory string) ([]tool.Tool, error) { // list_files/grep_file are opt-in: they give an agent broad filesystem // visibility, so deployments enable them deliberately. Note this gate is // theirs alone -- bash below is always registered. - fileSearchEnabled := fileSearchToolsEnabled() + fileSearchEnabled := env.KagentEnableFileSearchTools.Get() if fileSearchEnabled { listFilesTool, err := functiontool.New(functiontool.Config{ Name: "list_files", diff --git a/go/adk/pkg/tools/skills_test.go b/go/adk/pkg/tools/skills_test.go index a0b20442b4..c97c4299d1 100644 --- a/go/adk/pkg/tools/skills_test.go +++ b/go/adk/pkg/tools/skills_test.go @@ -8,27 +8,11 @@ import ( "testing" "unicode/utf8" - "github.com/kagent-dev/kagent/go/core/pkg/env" adkagent "google.golang.org/adk/v2/agent" "google.golang.org/adk/v2/tool" "google.golang.org/adk/v2/tool/toolconfirmation" ) -// TestEnableFileSearchToolsEnvMatchesRegistry pins this package's env var -// literal to the `kagent env` registry entry in go/core/pkg/env. The name is -// declared twice on purpose -- the runtime reads the raw variable here, while -// the registry exists solely so `kagent env` can document it -- so without -// this assertion nothing would catch the two drifting apart. -// -// This is a test-only import: it does not put a dependency on the -// control-plane module into the shipped agent runtime. -func TestEnableFileSearchToolsEnvMatchesRegistry(t *testing.T) { - if got, want := enableFileSearchToolsEnv, env.KagentEnableFileSearchTools.Name(); got != want { - t.Fatalf("env var name drift: %s has %q, go/core/pkg/env registry has %q", - "go/adk/pkg/tools/skills.go", got, want) - } -} - // fakeToolContext is a minimal agent.Context for directly invoking tools in // tests, bypassing the full ADK flow engine. Embeds StrictContextMock (an // ADK test double) and overrides only the methods functiontool.Run() calls. diff --git a/go/core/cli/internal/commands/const.go b/go/core/cli/internal/commands/const.go index 09b16935a1..7cda463f8b 100644 --- a/go/core/cli/internal/commands/const.go +++ b/go/core/cli/internal/commands/const.go @@ -1,17 +1,13 @@ package commands import ( - "os" "strings" "github.com/kagent-dev/kagent/go/api/v1alpha3" "github.com/kagent-dev/kagent/go/core/pkg/env" ) -const ( - DefaultModelProvider = v1alpha3.ModelProviderOpenAI - DefaultHelmOciRegistry = "oci://ghcr.io/kagent-dev/kagent/helm/" -) +const DefaultModelProvider = v1alpha3.ModelProviderOpenAI // GetModelProvider returns the model provider from KAGENT_DEFAULT_MODEL_PROVIDER environment variable func GetModelProvider() v1alpha3.ModelProvider { @@ -50,41 +46,24 @@ func GetModelProviderHelmValuesKey(provider v1alpha3.ModelProvider) string { return helmKey } -// GetProviderAPIKey returns the env var name for the provider's API key. -// Returns "" for providers that use cloud credentials instead of an API key -// (Bedrock, GeminiVertexAI, AnthropicVertexAI). Ollama returns OLLAMA_API_KEY: -// it is needed only for Ollama Cloud, so callers treat it as optional. -func GetProviderAPIKey(provider v1alpha3.ModelProvider) string { +// providerAPIKey selects the registered API key for a provider. Cloud credential +// providers have no API-key setting. +func providerAPIKey(provider v1alpha3.ModelProvider) (env.StringVar, bool) { switch provider { case v1alpha3.ModelProviderOpenAI: - return env.OpenAIAPIKey.Name() + return env.OpenAIAPIKey, true case v1alpha3.ModelProviderAnthropic: - return env.AnthropicAPIKey.Name() + return env.AnthropicAPIKey, true case v1alpha3.ModelProviderAzureOpenAI: - return env.AzureOpenAIAPIKey.Name() + return env.AzureOpenAIAPIKey, true case v1alpha3.ModelProviderOllama: - // Ollama Cloud authenticates with OLLAMA_API_KEY. A local daemon needs - // none, so the key is optional here rather than required; the runtime - // falls back to the daemon when it is absent. - return env.OllamaAPIKey.Name() + return env.OllamaAPIKey, true case v1alpha3.ModelProviderGemini: - // Prefer GOOGLE_API_KEY, fall back to GEMINI_API_KEY to match the - // runtime behaviour in go/adk/pkg/agent/agent.go. - if _, ok := os.LookupEnv(env.GoogleAPIKey.Name()); ok { - return env.GoogleAPIKey.Name() + if _, set := env.GoogleAPIKey.Lookup(); set { + return env.GoogleAPIKey, true } - return "GEMINI_API_KEY" + return env.GeminiAPIKey, true default: - // Bedrock, GeminiVertexAI, AnthropicVertexAI use cloud credentials - // rather than a simple API key, so no check is needed. - return "" - } -} - -// GetEnvVarWithDefault returns the value of the environment variable if it exists, otherwise returns the default value -func GetEnvVarWithDefault(envVar, defaultValue string) string { - if value, exists := os.LookupEnv(envVar); exists { - return value + return env.StringVar{}, false } - return defaultValue } diff --git a/go/core/cli/internal/commands/db/db.go b/go/core/cli/internal/commands/db/db.go index a3ce67f12a..8d53798fa5 100644 --- a/go/core/cli/internal/commands/db/db.go +++ b/go/core/cli/internal/commands/db/db.go @@ -20,7 +20,7 @@ import ( ) // vectorEnabledKey names two lookups that deliberately share it: the CLI's -// own DATABASE_VECTOR_ENABLED env var (a local operator override), and the +// own KAGENT_DATABASE_VECTOR_ENABLED env var (a local operator override), and the // controller-configmap key the chart renders — the value the controller pod // itself consumes via envFrom. Same name, two different places. var vectorEnabledKey = kagentenv.DatabaseVectorEnabled.Name() @@ -45,20 +45,17 @@ func NewDBCmd() *cobra.Command { // migrationSources resolves the built-in migration tracks when a db // subcommand runs (never during command construction, so unrelated commands // do no work and print no warnings). The vector track is gated, in order of -// precedence, on: the DATABASE_VECTOR_ENABLED env var in the CLI's own +// precedence, on: the KAGENT_DATABASE_VECTOR_ENABLED env var in the CLI's own // environment (explicit operator intent, works without a cluster), the // controller's configmap on the live cluster (the same value the server -// reads), and finally the controller's default (enabled). +// reads), and finally the CLI's fallback (enabled). func migrationSources(namespace *string) dbmigrate.SourcesFunc { return func(ctx context.Context) ([]migrations.Source, error) { vectorEnabled := true - if v := os.Getenv(vectorEnabledKey); v != "" { - b, err := strconv.ParseBool(v) - if err != nil { - fmt.Fprintf(os.Stderr, "warning: invalid %s=%q; assuming true\n", vectorEnabledKey, v) - } else { - vectorEnabled = b - } + if b, set, err := kagentenv.DatabaseVectorEnabled.LookupWithError(); err != nil { + fmt.Fprintf(os.Stderr, "warning: %v; assuming true\n", err) + } else if set { + vectorEnabled = b } else if b, ok := clusterVectorEnabled(ctx, *namespace); ok { vectorEnabled = b } diff --git a/go/core/cli/internal/commands/db/db_test.go b/go/core/cli/internal/commands/db/db_test.go new file mode 100644 index 0000000000..4992c0d3f7 --- /dev/null +++ b/go/core/cli/internal/commands/db/db_test.go @@ -0,0 +1,31 @@ +package db + +import ( + "testing" + + "github.com/kagent-dev/kagent/go/core/pkg/env" + "github.com/stretchr/testify/require" +) + +func TestMigrationSourcesFromEnvironment(t *testing.T) { + for _, tt := range []struct { + input string + want []string + }{ + {input: "false", want: []string{"core"}}, + {input: " TrUe ", want: []string{"core", "vector"}}, + {input: "invalid", want: []string{"core", "vector"}}, + } { + t.Run(tt.input, func(t *testing.T) { + t.Setenv(env.DatabaseVectorEnabled.Name(), tt.input) + namespace := "unused" + sources, err := migrationSources(&namespace)(t.Context()) + require.NoError(t, err) + var names []string + for _, source := range sources { + names = append(names, source.Name) + } + require.Equal(t, tt.want, names) + }) + } +} diff --git a/go/core/cli/internal/commands/env.go b/go/core/cli/internal/commands/env.go index c0a680638d..cc37d23914 100644 --- a/go/core/cli/internal/commands/env.go +++ b/go/core/cli/internal/commands/env.go @@ -29,7 +29,7 @@ func NewEnvCmd() *cobra.Command { } cmd.Flags().StringVar(&format, "format", "markdown", "Output format: markdown, json") - cmd.Flags().StringVar(&component, "component", "all", "Filter by component: controller, cli, agent-runtime, database, testing, all") + cmd.Flags().StringVar(&component, "component", "all", "Filter by component: controller, cli, agent-runtime, database, testing, ui, all") return cmd } diff --git a/go/core/cli/internal/commands/install.go b/go/core/cli/internal/commands/install.go index 15b0d53eaf..c6afca4feb 100644 --- a/go/core/cli/internal/commands/install.go +++ b/go/core/cli/internal/commands/install.go @@ -78,15 +78,15 @@ func runInstall(ctx context.Context, options connection.Options, cfg *InstallCfg // get model provider from KAGENT_DEFAULT_MODEL_PROVIDER environment variable or use DefaultModelProvider modelProvider := GetModelProvider() - // If model provider is openai, check if the API key is set - apiKeyName := GetProviderAPIKey(modelProvider) - apiKeyValue := os.Getenv(apiKeyName) - - if apiKeyName != "" && apiKeyValue == "" { - fmt.Fprintf(os.Stderr, "%s is not set\n", apiKeyName) - fmt.Fprintf(os.Stderr, "Please set the %s environment variable\n", apiKeyName) - fmt.Fprintf(os.Stderr, "To use a different provider set KAGENT_DEFAULT_MODEL_PROVIDER (e.g. ollama, anthropic, gemini)\n") - return nil + apiKeyValue := "" + if apiKey, ok := providerAPIKey(modelProvider); ok { + apiKeyValue = apiKey.Get() + if apiKeyValue == "" { + fmt.Fprintf(os.Stderr, "%s is not set\n", apiKey.Name()) + fmt.Fprintf(os.Stderr, "Please set the %s environment variable\n", apiKey.Name()) + fmt.Fprintf(os.Stderr, "To use a different provider set KAGENT_DEFAULT_MODEL_PROVIDER (e.g. ollama, anthropic, gemini)\n") + return nil + } } helmConfig := setupHelmConfig(modelProvider, apiKeyValue) @@ -125,9 +125,12 @@ func setupHelmConfig(modelProvider v1alpha3.ModelProvider, apiKeyValue string) h } // allow user to set the helm registry and version - helmRegistry := GetEnvVarWithDefault(env.KagentHelmRepo.Name(), DefaultHelmOciRegistry) - helmVersion := GetEnvVarWithDefault(env.KagentHelmVersion.Name(), version.Version) - helmExtraArgs := GetEnvVarWithDefault(env.KagentHelmExtraArgs.Name(), "") + helmRegistry := env.KagentHelmRepo.Get() + helmVersion, versionSet := env.KagentHelmVersion.Lookup() + if !versionSet { + helmVersion = version.Version + } + helmExtraArgs := env.KagentHelmExtraArgs.Get() // split helmExtraArgs by "--set" to get additional values extraValues := strings.Split(helmExtraArgs, "--set") diff --git a/go/core/cli/internal/db/migrate/migrate.go b/go/core/cli/internal/db/migrate/migrate.go index 7a91718785..614836b8c7 100644 --- a/go/core/cli/internal/db/migrate/migrate.go +++ b/go/core/cli/internal/db/migrate/migrate.go @@ -8,12 +8,12 @@ import ( "fmt" "io" "io/fs" - "os" "regexp" "slices" "strconv" "strings" + "github.com/kagent-dev/kagent/go/core/pkg/env" "github.com/pressly/goose/v3" "github.com/spf13/cobra" @@ -21,7 +21,6 @@ import ( ) const ( - dbURLEnv = "POSTGRES_DATABASE_URL" sourceFlag = "source" ) @@ -93,7 +92,7 @@ func NewCommandFromFunc(fn SourcesFunc) *cobra.Command { Use: "migrate", Short: "Apply, roll back, and inspect database migrations", Long: `Apply, roll back, and inspect database migrations. -The command reads POSTGRES_DATABASE_URL when --db-url is empty.`, +The command reads KAGENT_POSTGRES_DATABASE_URL when --db-url is empty.`, } command.PersistentFlags().StringVar(&state.dbURL, "db-url", "", "PostgreSQL connection URL") command.PersistentFlags().StringVar(&state.source, sourceFlag, "", "Migration source for down, goto, or version") @@ -108,10 +107,12 @@ The command reads POSTGRES_DATABASE_URL when --db-url is empty.`, func (s *commandState) resolveDSN() (string, error) { dsn := strings.TrimSpace(s.dbURL) if dsn == "" { - dsn = os.Getenv(dbURLEnv) + if value, set := env.PostgresDatabaseURL.Lookup(); set { + dsn = value + } } if dsn == "" { - return "", fmt.Errorf("set the database URL with --db-url or %s", dbURLEnv) + return "", fmt.Errorf("set the database URL with --db-url or %s", env.PostgresDatabaseURL.Name()) } return dsn, nil } diff --git a/go/core/cli/internal/db/migrate/migrate_test.go b/go/core/cli/internal/db/migrate/migrate_test.go index fd7a885f09..c07fe90f50 100644 --- a/go/core/cli/internal/db/migrate/migrate_test.go +++ b/go/core/cli/internal/db/migrate/migrate_test.go @@ -6,11 +6,13 @@ import ( "encoding/json" "errors" "io" + "os" "strings" "testing" "testing/fstest" "github.com/kagent-dev/kagent/go/core/internal/dbtest" + "github.com/kagent-dev/kagent/go/core/pkg/env" "github.com/kagent-dev/kagent/go/core/pkg/migrations" ) @@ -90,19 +92,26 @@ func TestNewCommandAcceptsHyphenatedNames(t *testing.T) { func TestResolveDSN(t *testing.T) { tests := []struct { - name string - flag string - env string - want string - wantErr bool + name string + flag string + env string + want string + wantErr bool + unsetEnv bool }{ {name: "flag wins over env", flag: "postgres://flag", env: "postgres://env", want: "postgres://flag"}, {name: "env fallback", env: "postgres://env", want: "postgres://env"}, {name: "neither set", wantErr: true}, + {name: "unset env does not use controller default", unsetEnv: true, wantErr: true}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - t.Setenv(dbURLEnv, tt.env) + t.Setenv(env.PostgresDatabaseURL.Name(), tt.env) + if tt.unsetEnv { + if err := os.Unsetenv(env.PostgresDatabaseURL.Name()); err != nil { + t.Fatal(err) + } + } s := &commandState{dbURL: tt.flag} got, err := s.resolveDSN() if (err != nil) != tt.wantErr { @@ -175,7 +184,7 @@ func TestArgValidation(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - t.Setenv(dbURLEnv, "") + t.Setenv(env.PostgresDatabaseURL.Name(), "") _, _, err := runCLI(t, testSources(), tt.args...) if err == nil || !strings.Contains(err.Error(), tt.wantErr) { t.Fatalf("error = %v, want containing %q", err, tt.wantErr) diff --git a/go/core/hack/mockllm/main.go b/go/core/hack/mockllm/main.go index 163f7f5787..50886d9e77 100644 --- a/go/core/hack/mockllm/main.go +++ b/go/core/hack/mockllm/main.go @@ -7,6 +7,7 @@ import ( "os" "strconv" + "github.com/kagent-dev/kagent/go/core/pkg/env" e2emocks "github.com/kagent-dev/kagent/go/core/test/e2e/mocks" "github.com/kagent-dev/kagent/go/pkg/logging" "github.com/kagent-dev/mockllm" @@ -21,7 +22,7 @@ func main() { ctx := logging.IntoContext(context.Background(), logger) agentServiceAccount := "system:serviceaccount:kagent:test-sts" stsPort := 8091 - if port := os.Getenv("STS_PORT"); port != "" { + if port := env.STSPort.Get(); port != "" { stsPort, _ = strconv.Atoi(port) } stsServer := e2emocks.NewMockSTSServer(agentServiceAccount, uint16(stsPort)) @@ -40,7 +41,7 @@ func main() { return } mockllmCfg.ListenAddr = ":8090" - if port := os.Getenv("LLM_PORT"); port != "" { + if port := env.LLMPort.Get(); port != "" { mockllmCfg.ListenAddr = ":" + port } server := mockllm.NewServer(mockllmCfg) diff --git a/go/core/internal/grpcserver/task_store_test.go b/go/core/internal/grpcserver/task_store_test.go index c7fce7db0f..36416969bd 100644 --- a/go/core/internal/grpcserver/task_store_test.go +++ b/go/core/internal/grpcserver/task_store_test.go @@ -16,6 +16,7 @@ import ( "time" "github.com/kagent-dev/kagent/go/core/internal/substrate" + kagentenv "github.com/kagent-dev/kagent/go/core/pkg/env" "google.golang.org/protobuf/proto" "github.com/a2aproject/a2a-go/v2/a2a" @@ -447,7 +448,7 @@ func TestRuntimeTaskStoreThroughGRPC(t *testing.T) { runtimeServer.Stop() _, err = second.GetTask(publicCtx, &a2apb.GetTaskRequest{Tenant: "team-a/assistant", Id: taskID}) require.NoError(t, err) - if python := os.Getenv("KAGENT_TEST_PYTHON"); python != "" { + if python := kagentenv.TestPython.Get(); python != "" { t.Run("python SDK with PostgreSQL", func(t *testing.T) { // Run the Python adapter against this same API and PostgreSQL session. // Only native work is a controlled fixture. Enable with the repository diff --git a/go/core/internal/translator/compiler_test.go b/go/core/internal/translator/compiler_test.go index 8987b467e9..5405f0c1ea 100644 --- a/go/core/internal/translator/compiler_test.go +++ b/go/core/internal/translator/compiler_test.go @@ -70,6 +70,10 @@ func TestCompileAgentPreservesWorkloadOverrides(t *testing.T) { require.NoError(t, err) require.Equal(t, tt.command, result.Command) require.Equal(t, tt.args, result.Args) + require.Contains(t, result.Environment, corev1.EnvVar{Name: "KAGENT_PORT", Value: "80"}) + for _, variable := range result.Environment { + require.NotEqual(t, "KAGENT_A2A_GRPC_ADDRESS", variable.Name) + } revisionID, err := result.Digest() require.NoError(t, err) diff --git a/go/core/internal/translator/kagent/compiler.go b/go/core/internal/translator/kagent/compiler.go index 10011a7c05..d10efcf4c2 100644 --- a/go/core/internal/translator/kagent/compiler.go +++ b/go/core/internal/translator/kagent/compiler.go @@ -56,8 +56,7 @@ func (c *Compiler) Compile(ctx context.Context, input *v2translator.HarnessInput corev1.EnvVar{Name: env.KagentNamespace.Name(), Value: template.Namespace}, corev1.EnvVar{Name: env.KagentAPIURL.Name(), Value: fmt.Sprintf("http://%s.%s:8083", utils.GetControllerName(), utils.GetResourceNamespace())}, corev1.EnvVar{Name: env.KagentGatewayURL.Name(), Value: fmt.Sprintf("http://%s.%s:8083", utils.GetControllerName(), utils.GetResourceNamespace())}, - corev1.EnvVar{Name: "PORT", Value: "80"}, - corev1.EnvVar{Name: "KAGENT_A2A_GRPC_ADDRESS", Value: "[::]:80"}, + corev1.EnvVar{Name: env.KagentPort.Name(), Value: "80"}, ) environment = append(environment, telemetryConfig.TelemetryEnvironment(tracing.RuntimeTelemetry{ AgentName: input.AgentName, AgentNamespace: template.Namespace, diff --git a/go/core/internal/translator/otel.go b/go/core/internal/translator/otel.go index dec034ed4d..35d20306c9 100644 --- a/go/core/internal/translator/otel.go +++ b/go/core/internal/translator/otel.go @@ -3,11 +3,11 @@ package translator import ( "fmt" "net/url" - "os" "strconv" "strings" "github.com/kagent-dev/kagent/go/api/v1alpha3" + "github.com/kagent-dev/kagent/go/core/pkg/env" "github.com/kagent-dev/kagent/go/pkg/telemetry" "github.com/kagent-dev/kagent/go/pkg/telemetry/conv" "github.com/kagent-dev/kagent/go/pkg/tracing" @@ -16,22 +16,6 @@ import ( corev1 "k8s.io/api/core/v1" ) -const ( - otelSDKDisabled = "OTEL_SDK_DISABLED" - otelServiceName = "OTEL_SERVICE_NAME" - otelResourceAttributes = "OTEL_RESOURCE_ATTRIBUTES" - otelExporterOTLPEndpoint = "OTEL_EXPORTER_OTLP_ENDPOINT" - otelExporterOTLPProtocol = "OTEL_EXPORTER_OTLP_PROTOCOL" - otelExporterOTLPTimeout = "OTEL_EXPORTER_OTLP_TIMEOUT" - otelCaptureRawAPIBodies = "KAGENT_OTEL_CAPTURE_RAW_API_BODIES" - otelMaxCaptureBytes = "KAGENT_OTEL_MAX_CAPTURE_BYTES" - // OperatorResourceAttributesVariable carries the operator's resource - // attributes. The controller's own OTEL_RESOURCE_ATTRIBUTES also names its - // pod, which no runtime may inherit. - OperatorResourceAttributesVariable = "KAGENT_OTEL_RESOURCE_ATTRIBUTES" - defaultOTLPProtocol = "grpc" -) - // Signal is one OpenTelemetry signal the controller configures. type Signal string @@ -44,16 +28,24 @@ const ( var signals = []Signal{SignalTraces, SignalMetrics, SignalLogs} +var signalVariables = map[Signal]struct { + exporter, endpoint, protocol env.StringVar +}{ + SignalTraces: {env.OtelTracesExporter, env.OtelExporterOTLPTracesEndpoint, env.OtelExporterOTLPTracesProtocol}, + SignalMetrics: {env.OtelMetricsExporter, env.OtelExporterOTLPMetricsEndpoint, env.OtelExporterOTLPMetricsProtocol}, + SignalLogs: {env.OtelLogsExporter, env.OtelExporterOTLPLogsEndpoint, env.OtelExporterOTLPLogsProtocol}, +} + func (s Signal) exporterVariable() string { - return "OTEL_" + strings.ToUpper(string(s)) + "_EXPORTER" + return signalVariables[s].exporter.Name() } func (s Signal) endpointVariable() string { - return "OTEL_EXPORTER_OTLP_" + strings.ToUpper(string(s)) + "_ENDPOINT" + return signalVariables[s].endpoint.Name() } func (s Signal) protocolVariable() string { - return "OTEL_EXPORTER_OTLP_" + strings.ToUpper(string(s)) + "_PROTOCOL" + return signalVariables[s].protocol.Name() } // TelemetryConfig is the controller-owned telemetry configuration compiled @@ -90,12 +82,12 @@ type SignalConfig struct { // observability configuration cannot invalidate AgentTemplates. func TelemetryConfigFromProcess() (TelemetryConfig, []error) { config := TelemetryConfig{ - Endpoint: strings.TrimSpace(os.Getenv(otelExporterOTLPEndpoint)), - Protocol: strings.ToLower(strings.TrimSpace(os.Getenv(otelExporterOTLPProtocol))), - CaptureRawAPIBodies: strings.EqualFold(strings.TrimSpace(os.Getenv(otelCaptureRawAPIBodies)), "true"), + Endpoint: strings.TrimSpace(env.OtelExporterOTLPEndpoint.Get()), + Protocol: strings.ToLower(strings.TrimSpace(env.OtelExporterOTLPProtocol.Get())), + CaptureRawAPIBodies: env.OtelCaptureRawAPIBodies.Get(), } var warnings []error - disabled := strings.EqualFold(strings.TrimSpace(os.Getenv(otelSDKDisabled)), "true") + disabled := strings.EqualFold(strings.TrimSpace(env.OtelSDKDisabled.Get()), "true") for _, signal := range signals { resolved, err := config.signalFromProcess(signal, disabled) if err != nil { @@ -104,11 +96,11 @@ func TelemetryConfigFromProcess() (TelemetryConfig, []error) { *config.signal(signal) = resolved } if config.Protocol == "" { - config.Protocol = defaultOTLPProtocol + config.Protocol = env.OtelExporterOTLPProtocol.DefaultValue() } - if raw := strings.TrimSpace(os.Getenv(otelExporterOTLPTimeout)); raw != "" { + if raw := strings.TrimSpace(env.OtelExporterOTLPTimeout.Get()); raw != "" { if value, err := strconv.Atoi(raw); err != nil || value <= 0 { - warnings = append(warnings, fmt.Errorf("%s must be a positive number of milliseconds", otelExporterOTLPTimeout)) + warnings = append(warnings, fmt.Errorf("%s must be a positive number of milliseconds", env.OtelExporterOTLPTimeout.Name())) } else { config.Timeout = raw } @@ -118,12 +110,12 @@ func TelemetryConfigFromProcess() (TelemetryConfig, []error) { warnings = append(warnings, err) } config.ResourceAttributes = attributes - switch capture := strings.TrimSpace(os.Getenv(tracing.CaptureContentEnvironmentVariable)); capture { + switch capture := strings.TrimSpace(env.OtelCaptureMessageContent.Get()); capture { case tracing.CaptureContentSpanOnly: config.CaptureSensitiveContent = true case "", "NO_CONTENT", tracing.CaptureContentDisabled: default: - warnings = append(warnings, fmt.Errorf("%s must be SPAN_ONLY or NO_CONTENT, not %q", tracing.CaptureContentEnvironmentVariable, capture)) + warnings = append(warnings, fmt.Errorf("%s must be SPAN_ONLY or NO_CONTENT, not %q", env.OtelCaptureMessageContent.Name(), capture)) } maxCaptureBytes, err := maxCaptureBytesFromProcess() if err != nil { @@ -147,15 +139,15 @@ func (c *TelemetryConfig) signal(signal Signal) *SignalConfig { // signalFromProcess resolves one signal. Only an explicit otlp exporter is // forwarded; the chart always renders one. func (c TelemetryConfig) signalFromProcess(signal Signal, disabled bool) (SignalConfig, error) { - switch exporter := strings.TrimSpace(os.Getenv(signal.exporterVariable())); { + switch exporter := strings.TrimSpace(signalVariables[signal].exporter.Get()); { case disabled || exporter == "" || exporter == "none": return SignalConfig{}, nil case exporter != "otlp": return SignalConfig{}, fmt.Errorf("%s must be otlp or none, not %q", signal.exporterVariable(), exporter) } resolved := SignalConfig{ - EndpointOverride: strings.TrimSpace(os.Getenv(signal.endpointVariable())), - ProtocolOverride: strings.ToLower(strings.TrimSpace(os.Getenv(signal.protocolVariable()))), + EndpointOverride: strings.TrimSpace(signalVariables[signal].endpoint.Get()), + ProtocolOverride: strings.ToLower(strings.TrimSpace(signalVariables[signal].protocol.Get())), } endpoint := resolved.EndpointOverride if endpoint == "" { @@ -169,7 +161,7 @@ func (c TelemetryConfig) signalFromProcess(signal Signal, disabled bool) (Signal protocol = c.Protocol } if protocol == "" { - protocol = defaultOTLPProtocol + protocol = env.OtelExporterOTLPProtocol.DefaultValue() } if protocol != "grpc" && protocol != "http/protobuf" { return SignalConfig{}, fmt.Errorf("unsupported OTLP %s protocol %q", signal, protocol) @@ -185,8 +177,11 @@ func (c TelemetryConfig) signalFromProcess(signal Signal, disabled bool) (Signal return resolved, nil } +// resourceAttributesFromProcess reads the operator's shared runtime attributes. +// The controller's own OTEL_RESOURCE_ATTRIBUTES includes its pod identity, which +// must not be inherited by agent runtimes. func resourceAttributesFromProcess() (string, error) { - raw := strings.TrimSpace(os.Getenv(OperatorResourceAttributesVariable)) + raw := strings.TrimSpace(env.OtelResourceAttributes.Get()) if raw == "" { return "", nil } @@ -201,7 +196,7 @@ func resourceAttributesFromProcess() (string, error) { entries = append(entries, strings.TrimSpace(key)+"="+strings.TrimSpace(value)) } if len(invalid) != 0 { - return strings.Join(entries, ","), fmt.Errorf("%s ignores entries that are not key=value: %q", OperatorResourceAttributesVariable, invalid) + return strings.Join(entries, ","), fmt.Errorf("%s ignores entries that are not key=value: %q", env.OtelResourceAttributes.Name(), invalid) } return strings.Join(entries, ","), nil } @@ -210,13 +205,15 @@ func resourceAttributesFromProcess() (string, error) { // value is reported and replaced by the default so an observability setting // cannot invalidate AgentTemplates. func maxCaptureBytesFromProcess() (int, error) { - raw := strings.TrimSpace(os.Getenv(otelMaxCaptureBytes)) - if raw == "" { + value, set, err := env.OtelMaxCaptureBytes.LookupWithError() + if err != nil { + return 0, err + } + if !set { return 0, nil } - value, err := strconv.Atoi(raw) - if err != nil || value <= 0 || value > tracing.MaxCaptureBytes { - return 0, fmt.Errorf("%s must be a positive integer of at most %d bytes", otelMaxCaptureBytes, tracing.MaxCaptureBytes) + if value <= 0 || value > tracing.MaxCaptureBytes { + return 0, fmt.Errorf("%s must be a positive integer of at most %d bytes", env.OtelMaxCaptureBytes.Name(), tracing.MaxCaptureBytes) } return value, nil } @@ -281,11 +278,11 @@ func ProviderName(provider v1alpha3.ModelProvider) string { // sets. The rendered value keeps its entries under the agent identity. func HarnessResourceAttributes(harness *HarnessConfiguration) (string, error) { for _, variable := range harness.Spec.Env { - if variable.Name != otelResourceAttributes { + if variable.Name != env.OtelSDKResourceAttributes.Name() { continue } if variable.Value == nil { - return "", NewValidationError("Harness env %q must be a literal value", otelResourceAttributes) + return "", NewValidationError("Harness env %q must be a literal value", env.OtelSDKResourceAttributes.Name()) } return *variable.Value, nil } @@ -295,16 +292,16 @@ func HarnessResourceAttributes(harness *HarnessConfiguration) (string, error) { // IsResourceAttributesVariable reports whether a Harness variable is the one // TelemetryEnvironment merges instead of copying. func IsResourceAttributesVariable(name string) bool { - return name == otelResourceAttributes + return name == env.OtelSDKResourceAttributes.Name() } // OwnsTelemetryEnvironment reports whether Kagent compiles the variable into // runtime revisions. Other OTEL variables remain available for harness tuning. func OwnsTelemetryEnvironment(name string) bool { switch name { - case otelSDKDisabled, otelServiceName, - otelExporterOTLPEndpoint, otelExporterOTLPProtocol, otelExporterOTLPTimeout, - tracing.CaptureContentEnvironmentVariable: + case env.OtelSDKDisabled.Name(), env.OtelServiceName.Name(), + env.OtelExporterOTLPEndpoint.Name(), env.OtelExporterOTLPProtocol.Name(), env.OtelExporterOTLPTimeout.Name(), + env.OtelCaptureMessageContent.Name(): return true } for _, signal := range signals { @@ -321,15 +318,15 @@ func OwnsTelemetryEnvironment(name string) bool { // capture decision is always rendered, so a runtime that reaches a collector // through settings the controller did not render still follows it. func (c TelemetryConfig) TelemetryEnvironment(identity tracing.RuntimeTelemetry, harnessAttributes string) []corev1.EnvVar { - capture := corev1.EnvVar{Name: tracing.CaptureContentEnvironmentVariable, Value: tracing.CaptureContentDisabled} + capture := corev1.EnvVar{Name: env.OtelCaptureMessageContent.Name(), Value: tracing.CaptureContentDisabled} if c.CaptureSensitiveContent { capture.Value = tracing.CaptureContentSpanOnly } - resource := corev1.EnvVar{Name: otelResourceAttributes, Value: tracing.MergeResourceAttributes( + resource := corev1.EnvVar{Name: env.OtelSDKResourceAttributes.Name(), Value: tracing.MergeResourceAttributes( strings.Join([]string{c.ResourceAttributes, harnessAttributes}, ","), resourceIdentity(identity))} if !c.Enabled() { environment := []corev1.EnvVar{ - {Name: otelSDKDisabled, Value: "true"}, + {Name: env.OtelSDKDisabled.Name(), Value: "true"}, {Name: SignalTraces.exporterVariable(), Value: "none"}, {Name: SignalMetrics.exporterVariable(), Value: "none"}, {Name: SignalLogs.exporterVariable(), Value: "none"}, @@ -350,9 +347,9 @@ func (c TelemetryConfig) TelemetryEnvironment(identity tracing.RuntimeTelemetry, } sharedEndpoint, sharedProtocol := c.sharedExporter() if sharedEndpoint { - environment = append(environment, corev1.EnvVar{Name: otelExporterOTLPEndpoint, Value: c.Endpoint}) + environment = append(environment, corev1.EnvVar{Name: env.OtelExporterOTLPEndpoint.Name(), Value: c.Endpoint}) } - environment = append(environment, corev1.EnvVar{Name: otelExporterOTLPProtocol, Value: sharedProtocol}) + environment = append(environment, corev1.EnvVar{Name: env.OtelExporterOTLPProtocol.Name(), Value: sharedProtocol}) for _, signal := range signals { resolved := c.signal(signal) if !resolved.Enabled { @@ -366,9 +363,9 @@ func (c TelemetryConfig) TelemetryEnvironment(identity tracing.RuntimeTelemetry, } } if c.Timeout != "" { - environment = append(environment, corev1.EnvVar{Name: otelExporterOTLPTimeout, Value: c.Timeout}) + environment = append(environment, corev1.EnvVar{Name: env.OtelExporterOTLPTimeout.Name(), Value: c.Timeout}) } - return append(environment, corev1.EnvVar{Name: otelServiceName, Value: identity.AgentName}, resource, capture) + return append(environment, corev1.EnvVar{Name: env.OtelServiceName.Name(), Value: identity.AgentName}, resource, capture) } // sharedExporter reports whether an enabled signal exports to the shared @@ -399,7 +396,7 @@ func (c TelemetryConfig) sharedExporter() (bool, string) { func DefaultsEnvironment() []corev1.EnvVar { environment := make([]corev1.EnvVar, 0, len(telemetry.Defaults)) for _, value := range telemetry.Defaults { - environment = append(environment, corev1.EnvVar{Name: value.Name, Value: value.Value}) + environment = append(environment, corev1.EnvVar{Name: value.Name(), Value: value.DefaultValue()}) } return environment } diff --git a/go/core/internal/translator/otel_test.go b/go/core/internal/translator/otel_test.go index d1d786d2aa..4336df3eb3 100644 --- a/go/core/internal/translator/otel_test.go +++ b/go/core/internal/translator/otel_test.go @@ -6,6 +6,7 @@ import ( "testing" "github.com/kagent-dev/kagent/go/core/internal/translator" + "github.com/kagent-dev/kagent/go/core/pkg/env" "github.com/kagent-dev/kagent/go/pkg/tracing" corev1 "k8s.io/api/core/v1" ) @@ -129,7 +130,7 @@ func TestTelemetryEnvironmentKeepsIdentityOverOperatorAndHarnessAttributes(t *te clearTelemetryEnvironment(t) t.Setenv("OTEL_TRACES_EXPORTER", "otlp") t.Setenv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://collector:4317") - t.Setenv(translator.OperatorResourceAttributesVariable, "deployment.environment.name=prod, service.namespace=forged,k8s.cluster.name=east") + t.Setenv(env.OtelResourceAttributes.Name(), "deployment.environment.name=prod, service.namespace=forged,k8s.cluster.name=east") got, warnings := translator.TelemetryConfigFromProcess() if len(warnings) != 0 { @@ -193,7 +194,7 @@ func TestTelemetryConfigFromProcessRejectsInvalidSignalConfiguration(t *testing. func TestTelemetryConfigFromProcessReportsInvalidOperatorSettings(t *testing.T) { clearTelemetryEnvironment(t) t.Setenv("OTEL_EXPORTER_OTLP_TIMEOUT", "15s") - t.Setenv(translator.OperatorResourceAttributesVariable, "deployment.environment.name=prod,broken") + t.Setenv(env.OtelResourceAttributes.Name(), "deployment.environment.name=prod,broken") got, warnings := translator.TelemetryConfigFromProcess() if len(warnings) != 2 || got.Timeout != "" || got.ResourceAttributes != "deployment.environment.name=prod" { @@ -201,6 +202,61 @@ func TestTelemetryConfigFromProcessReportsInvalidOperatorSettings(t *testing.T) } } +func TestTelemetryCaptureBudgetValidation(t *testing.T) { + for _, tt := range []struct { + input string + want int + wantWarning bool + }{ + {input: ""}, + {input: " "}, + {input: " 4096 ", want: 4096}, + {input: "65536", want: 65536}, + {input: "0", wantWarning: true}, + {input: "-1", wantWarning: true}, + {input: "65537", wantWarning: true}, + {input: "garbage", wantWarning: true}, + {input: "99999999999999999999999999", wantWarning: true}, + } { + t.Run(tt.input, func(t *testing.T) { + clearTelemetryEnvironment(t) + t.Setenv("KAGENT_OTEL_MAX_CAPTURE_BYTES", tt.input) + got, warnings := translator.TelemetryConfigFromProcess() + if got.MaxCaptureBytes != tt.want || (len(warnings) > 0) != tt.wantWarning { + t.Fatalf("MaxCaptureBytes = %d, warnings = %v", got.MaxCaptureBytes, warnings) + } + if tt.wantWarning && !strings.Contains(warnings[0].Error(), "KAGENT_OTEL_MAX_CAPTURE_BYTES") { + t.Fatalf("warning does not identify the setting: %v", warnings) + } + }) + } +} + +func TestTelemetryBooleanGrammars(t *testing.T) { + for _, tt := range []struct { + input string + disabled, capture bool + }{ + {input: " TrUe ", disabled: true, capture: true}, + {input: "1", capture: true}, + {input: "t", capture: true}, + {input: "false"}, + {input: "invalid"}, + } { + t.Run(tt.input, func(t *testing.T) { + clearTelemetryEnvironment(t) + t.Setenv("OTEL_TRACES_EXPORTER", "otlp") + t.Setenv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://collector:4317") + t.Setenv("OTEL_SDK_DISABLED", tt.input) + t.Setenv("KAGENT_OTEL_CAPTURE_RAW_API_BODIES", tt.input) + got, warnings := translator.TelemetryConfigFromProcess() + if len(warnings) != 0 || got.Traces.Enabled == tt.disabled || got.CaptureRawAPIBodies != tt.capture { + t.Fatalf("telemetry = %#v, warnings = %v", got, warnings) + } + }) + } +} + func TestOwnsTelemetryEnvironment(t *testing.T) { for _, name := range []string{ "OTEL_SDK_DISABLED", diff --git a/go/core/pkg/app/app.go b/go/core/pkg/app/app.go index 32d5249aa7..cc6055448e 100644 --- a/go/core/pkg/app/app.go +++ b/go/core/pkg/app/app.go @@ -10,13 +10,13 @@ package app import ( + "cmp" "context" "fmt" "log/slog" "maps" "net/http" "os" - "strconv" "strings" "time" @@ -131,7 +131,7 @@ func (o Options) resolve() (auth.AuthProvider, auth.CollectionAuthorizer) { } // SetupLogger installs the controller-runtime logger, at the level named by -// LOG_LEVEL. +// KAGENT_LOG_LEVEL. // // Run calls this itself, so a library consumer needs it only when it logs // before Run — and it must then call it first, because controller-runtime @@ -145,7 +145,7 @@ func (o Options) resolve() (auth.AuthProvider, auth.CollectionAuthorizer) { func SetupLogger() error { logger, err := logging.NewFromEnv(os.Stderr) if err != nil { - return fmt.Errorf("parse LOG_LEVEL: %w", err) + return fmt.Errorf("parse KAGENT_LOG_LEVEL: %w", err) } slog.SetDefault(logger) ctrl.SetLogger(logging.AsLogr(logger)) @@ -189,7 +189,7 @@ func Run(ctx context.Context, opts Options) error { } }() - dbURL, err := database.ResolveURL(env("POSTGRES_DATABASE_URL", "postgres://postgres:kagent@kagent-postgresql.kagent.svc.cluster.local:5432/postgres"), os.Getenv("POSTGRES_DATABASE_URL_FILE")) + dbURL, err := database.ResolveURL(env(kagentenv.PostgresDatabaseURL), kagentenv.PostgresDatabaseURLFile.Get()) if err != nil { return err } @@ -226,7 +226,7 @@ func Run(ctx context.Context, opts Options) error { utilruntime.Must(kagentv1alpha3.AddToScheme(managerScheme)) utilruntime.Must(atev1alpha1.AddToScheme(managerScheme)) utilruntime.Must(kmcp.AddToScheme(managerScheme)) - watchNamespaces := namespaces(os.Getenv("WATCH_NAMESPACES")) + watchNamespaces := namespaces(kagentenv.WatchNamespaces.Get()) managerClientOptions := client.Options{} managerCacheOptions := cache.Options{DefaultNamespaces: namespaceCache(watchNamespaces)} if len(watchNamespaces) > 0 { @@ -252,7 +252,7 @@ func Run(ctx context.Context, opts Options) error { Metrics: metricsOptions, LeaderElection: kagentenv.LeaderElect.Get(), LeaderElectionID: "0e9f6799.kagent.dev", - LeaderElectionNamespace: env("KAGENT_NAMESPACE", "kagent"), + LeaderElectionNamespace: env(kagentenv.KagentNamespace), }) if err != nil { return fmt.Errorf("create controller manager: %w", err) @@ -262,9 +262,9 @@ func Run(ctx context.Context, opts Options) error { return err } actors, err := substrate.Dial(ctx, substrate.Config{ - AteAPIEndpoint: env("SUBSTRATE_ATE_API_ENDPOINT", "dns:///api.ate-system.svc:443"), - CAFile: os.Getenv("SUBSTRATE_ATE_API_CA_FILE"), - ClientCertFile: os.Getenv("SUBSTRATE_ATE_API_CLIENT_CERT_FILE"), + AteAPIEndpoint: env(kagentenv.SubstrateATEAPIEndpoint), + CAFile: kagentenv.SubstrateATEAPICAFile.Get(), + ClientCertFile: kagentenv.SubstrateATEAPIClientCertFile.Get(), CallTimeout: 30 * time.Second, }) if err != nil { @@ -297,7 +297,7 @@ func Run(ctx context.Context, opts Options) error { } authenticator, authorizer := opts.resolve() - resourceNamespace := env("KAGENT_NAMESPACE", "kagent") + resourceNamespace := env(kagentenv.KagentNamespace) models := modelservice.NewService(manager.GetClient(), authorizer, resourceNamespace) tools := toolservice.NewService(manager.GetClient(), store, authorizer, resourceNamespace, mcpClient) prompts := prompttemplateservice.NewService(manager.GetClient(), authorizer) @@ -319,7 +319,7 @@ func Run(ctx context.Context, opts Options) error { } agents := kubecrud.NewService(manager.GetClient(), authorizer, &kagentv1alpha3.Agent{}, &kagentv1alpha3.AgentList{}, "Agent") interactions := sessionsvc.NewInteractionService(store, agents, sessions) - gateway := a2agateway.New(interactions, gatewayDialer, env("KAGENT_GATEWAY_URL", "http://127.0.0.1:8083")) + gateway := a2agateway.New(interactions, gatewayDialer, cmp.Or(kagentenv.KagentGatewayURL.Get(), "http://127.0.0.1:8083")) schedules := scheduledrun.NewService(store, manager.GetClient(), authorizer) if err := manager.Add(scheduledruncontroller.NewScheduler(store)); err != nil { return fmt.Errorf("add scheduled run scheduler: %w", err) @@ -335,7 +335,7 @@ func Run(ctx context.Context, opts Options) error { } defer guests.Close() policy := substrate.SandboxPolicy{ - GuestImage: env(kagentenv.SandboxGuestImage.Name(), kagentenv.SandboxGuestImage.DefaultValue()), + GuestImage: env(kagentenv.SandboxGuestImage), CPU: kagentenv.SandboxCPU.Get(), Memory: kagentenv.SandboxMemory.Get(), } @@ -371,8 +371,8 @@ func Run(ctx context.Context, opts Options) error { server, err := grpcserver.New(grpcserver.Config{ MethodPolicies: policies, RegisterServices: opts.GRPCServices, - BindAddress: env("HTTP_BIND_ADDRESS", ":8083"), - Reflection: envBool("GRPC_REFLECTION"), + BindAddress: env(kagentenv.HTTPBindAddress), + Reflection: kagentenv.GRPCReflection.Get(), Authenticator: authenticator, RuntimeAuthenticator: &taskstore.Authenticator{}, ShareStore: store, @@ -422,19 +422,15 @@ func mergePolicies(defaults grpcserver.MethodPolicies, extra map[string]auth.Acc return merged, nil } -func env(name, fallback string) string { - if value := os.Getenv(name); value != "" { +// env preserves the controller's default-on-empty behavior for string settings. +func env(variable kagentenv.StringVar) string { + if value := variable.Get(); value != "" { return value } - return fallback + return variable.DefaultValue() } -func envBool(name string) bool { - value, _ := strconv.ParseBool(os.Getenv(name)) - return value -} - -// metricsBindAddress resolves METRICS_BIND_ADDRESS. controller-runtime reads an +// metricsBindAddress resolves KAGENT_METRICS_BIND_ADDRESS. controller-runtime reads an // empty address as "unset" and falls back to :8080, so an empty value would // serve metrics on a port nobody asked for. "0" disables the metrics server. func metricsBindAddress() string { diff --git a/go/core/pkg/app/app_test.go b/go/core/pkg/app/app_test.go index f6b5fb7c6a..cd9881443c 100644 --- a/go/core/pkg/app/app_test.go +++ b/go/core/pkg/app/app_test.go @@ -5,6 +5,7 @@ import ( "fmt" "net/http" "net/url" + "os" "reflect" "testing" @@ -87,11 +88,11 @@ func TestOptionsResolve(t *testing.T) { } func TestLeaderElectionConfiguration(t *testing.T) { - t.Setenv("LEADER_ELECT", "") + t.Setenv("KAGENT_LEADER_ELECT", "") if !kagentenv.LeaderElect.Get() { t.Fatal("leader election must default to enabled") } - t.Setenv("LEADER_ELECT", "false") + t.Setenv("KAGENT_LEADER_ELECT", "false") if kagentenv.LeaderElect.Get() { t.Fatal("an explicit false must be preserved for startup validation") } @@ -110,7 +111,7 @@ func TestMetricsBindAddressNeverFallsBackToTheControllerRuntimeDefault(t *testin } { t.Run(name, func(t *testing.T) { if testCase.value != nil { - t.Setenv("METRICS_BIND_ADDRESS", *testCase.value) + t.Setenv("KAGENT_METRICS_BIND_ADDRESS", *testCase.value) } if got := metricsBindAddress(); got != testCase.want { t.Fatalf("metricsBindAddress() = %q, want %q", got, testCase.want) @@ -121,11 +122,11 @@ func TestMetricsBindAddressNeverFallsBackToTheControllerRuntimeDefault(t *testin func TestMetricsSecureDefaultsOff(t *testing.T) { if kagentenv.MetricsSecure.Get() { - t.Fatal("METRICS_SECURE must default to false") + t.Fatal("KAGENT_METRICS_SECURE must default to false") } - t.Setenv("METRICS_SECURE", "true") + t.Setenv("KAGENT_METRICS_SECURE", "true") if !kagentenv.MetricsSecure.Get() { - t.Fatal("METRICS_SECURE=true must enable secure serving") + t.Fatal("KAGENT_METRICS_SECURE=true must enable secure serving") } } @@ -136,6 +137,41 @@ func TestNamespaces(t *testing.T) { } } +func TestRegisteredStringDefaults(t *testing.T) { + for _, variable := range []kagentenv.StringVar{ + kagentenv.HTTPBindAddress, + kagentenv.PostgresDatabaseURL, + kagentenv.SubstrateATEAPIEndpoint, + kagentenv.KagentNamespace, + } { + t.Run(variable.Name(), func(t *testing.T) { + for _, test := range []struct { + name string + value *string + want string + }{ + {name: "unset", want: variable.DefaultValue()}, + {name: "empty", value: new(""), want: variable.DefaultValue()}, + {name: "override", value: new("override"), want: "override"}, + } { + t.Run(test.name, func(t *testing.T) { + t.Setenv(variable.Name(), "") + if test.value == nil { + if err := os.Unsetenv(variable.Name()); err != nil { + t.Fatal(err) + } + } else { + t.Setenv(variable.Name(), *test.value) + } + if got := env(variable); got != test.want { + t.Errorf("env() = %q, want %q", got, test.want) + } + }) + } + }) + } +} + func TestNamespaceCache(t *testing.T) { if got := namespaceCache(nil); got != nil { t.Fatalf("namespaceCache(nil) = %#v, want nil", got) @@ -172,9 +208,9 @@ func TestExtraMigrationsAppendAfterBuiltins(t *testing.T) { // SetupLogger rejects a bad level before it touches the global logger, so this // case does not disturb whatever logger the rest of the suite runs under. func TestSetupLoggerRejectsBadLevel(t *testing.T) { - t.Setenv("LOG_LEVEL", "not-a-level") + t.Setenv("KAGENT_LOG_LEVEL", "not-a-level") if err := SetupLogger(); err == nil { - t.Fatal("SetupLogger accepted an unparseable LOG_LEVEL") + t.Fatal("SetupLogger accepted an unparseable KAGENT_LOG_LEVEL") } } diff --git a/go/core/pkg/env/cli.go b/go/core/pkg/env/cli.go index 0e6342fcfc..3ae71f4e7d 100644 --- a/go/core/pkg/env/cli.go +++ b/go/core/pkg/env/cli.go @@ -19,7 +19,7 @@ var ( KagentHelmVersion = RegisterStringVar( "KAGENT_HELM_VERSION", "", - "Helm chart version to deploy.", + "Helm chart version to deploy. When unset, the CLI uses its own version.", ComponentCLI, ) diff --git a/go/core/pkg/env/controller.go b/go/core/pkg/env/controller.go new file mode 100644 index 0000000000..f186c4a575 --- /dev/null +++ b/go/core/pkg/env/controller.go @@ -0,0 +1,22 @@ +package env + +var ( + HTTPBindAddress = RegisterStringVar( + "KAGENT_HTTP_BIND_ADDRESS", ":8083", + "Listen address for the controller HTTP, gRPC, A2A, and MCP server.", ComponentController, + ) + GRPCReflection = RegisterBoolVar( + "KAGENT_GRPC_REFLECTION", false, + "Enable gRPC server reflection on the controller.", ComponentController, + ) + WatchNamespaces = RegisterStringVar( + "KAGENT_WATCH_NAMESPACES", "", + "Comma-separated namespaces to watch. Empty watches all namespaces.", ComponentController, + ) +) + +// Shared settings read by logging and Kubernetes libraries. +var ( + LogLevel = RegisterStringVar("KAGENT_LOG_LEVEL", "info", "Logging level for the controller, CLI, and Go/Python runtimes, including the Python ADK HTTP server: debug, info, warn, or error. Python also accepts standard Python logging levels.", ComponentController, ComponentCLI, ComponentAgentRuntime) + Kubeconfig = RegisterStringVar("KUBECONFIG", "", "Kubernetes client configuration file list for the controller, CLI Kubernetes operations, and tests. When unset, client-go uses its normal in-cluster or user kubeconfig discovery.", ComponentController, ComponentCLI, ComponentTesting) +) diff --git a/go/core/pkg/env/database.go b/go/core/pkg/env/database.go new file mode 100644 index 0000000000..1bcfd002c4 --- /dev/null +++ b/go/core/pkg/env/database.go @@ -0,0 +1,12 @@ +package env + +var ( + PostgresDatabaseURL = RegisterStringVar( + "KAGENT_POSTGRES_DATABASE_URL", "postgres://postgres:kagent@kagent-postgresql.kagent.svc.cluster.local:5432/postgres", + "PostgreSQL connection URL. The default applies only to the controller; kagent db requires this variable or --db-url. Helm supplies its configured connection URL.", ComponentDatabase, ComponentController, ComponentCLI, + ) + PostgresDatabaseURLFile = RegisterStringVar( + "KAGENT_POSTGRES_DATABASE_URL_FILE", "", + "File containing the PostgreSQL connection URL; takes precedence over KAGENT_POSTGRES_DATABASE_URL in the controller.", ComponentDatabase, ComponentController, + ) +) diff --git a/go/core/pkg/env/kagent.go b/go/core/pkg/env/kagent.go index fdd2fd576e..b5b9ce65c4 100644 --- a/go/core/pkg/env/kagent.go +++ b/go/core/pkg/env/kagent.go @@ -3,14 +3,14 @@ package env // Core kagent environment variables used by the controller and agent runtime. var ( LeaderElect = RegisterBoolVar( - "LEADER_ELECT", + "KAGENT_LEADER_ELECT", true, "Enable controller leader election, including during single-replica rolling updates. Required for sandbox lifecycle coordination.", ComponentController, ) MetricsBindAddress = RegisterStringVar( - "METRICS_BIND_ADDRESS", + "KAGENT_METRICS_BIND_ADDRESS", "0", "Address the controller-runtime metrics server binds to, e.g. :8080. "+ "\"0\" (the default) serves no metrics, so an installation that does not "+ @@ -20,7 +20,7 @@ var ( ) MetricsSecure = RegisterBoolVar( - "METRICS_SECURE", + "KAGENT_METRICS_SECURE", false, "Serve the metrics endpoint over HTTPS with authentication and authorization. "+ "A scraper then needs a token bound to the metrics-reader ClusterRole.", @@ -30,8 +30,8 @@ var ( KagentNamespace = RegisterStringVar( "KAGENT_NAMESPACE", "kagent", - "Kubernetes namespace where kagent resources are deployed.", - ComponentController, + "Kubernetes namespace where kagent resources are deployed. The controller injects the agent namespace into runtimes; Python runtimes require it.", + ComponentController, ComponentAgentRuntime, ) KagentControllerName = RegisterStringVar( @@ -41,84 +41,54 @@ var ( ComponentController, ) - KagentA2ADebugAddr = RegisterStringVar( - "KAGENT_A2A_DEBUG_ADDR", - "", - "Debug address for the A2A server. When set, all A2A HTTP requests are dialed to this address.", - ComponentController, - ) - - KagentA2AClientTimeout = RegisterDurationVar( - "KAGENT_A2A_CLIENT_TIMEOUT", - 0, - "HTTP client timeout for A2A requests from the controller to agent pods. "+ - "0 (the default) means no timeout, which is recommended for long-running agents "+ - "that stream responses over SSE. Set a positive duration (e.g. 30m) only if you "+ - "need a hard upper bound on individual A2A calls.", - ComponentController, - ) - - // Variables injected into agent pods (not read by the controller itself). + // Standalone runtime settings supplied by the controller in managed runtimes. KagentName = RegisterStringVar( "KAGENT_NAME", "", - "Name of the agent. Injected into agent pods via the controller.", + "Agent name for standalone runtimes. Required by Python runtimes; supplied by the controller in managed runtimes.", ComponentAgentRuntime, ) KagentAPIURL = RegisterStringVar( "KAGENT_API_URL", "", - "Base URL for kagent control-plane API calls.", - ComponentAgentRuntime, + "Base URL for kagent control-plane API calls. Required by Python runtimes and supplied by the controller in managed runtimes; also used as the E2E test URL when KAGENT_E2E_API_URL is unset.", + ComponentAgentRuntime, ComponentTesting, ) KagentGatewayURL = RegisterStringVar( "KAGENT_GATEWAY_URL", "", - "Base URL for A2A and MCP traffic.", - ComponentAgentRuntime, - ) - - KagentUIURL = RegisterStringVar( - "KAGENT_UI_URL", - "", - "Public base URL of the kagent UI (e.g. https://kagent.example.com). "+ - "When set, share link tools return full clickable URLs instead of paths.", - ComponentAgentRuntime, + "Base URL for A2A and MCP traffic. The controller falls back to http://127.0.0.1:8083; Python runtimes require a value.", + ComponentAgentRuntime, ComponentController, ) KagentSkillsFolder = RegisterStringVar( "KAGENT_SKILLS_FOLDER", "/skills", - "Directory path where agent skills are mounted.", + "Skills directory for standalone Python skills tools. The Python ADK adds skills tools when set; managed Go ADK runtimes use their compiled skill configuration.", ComponentAgentRuntime, ) KagentPropagateToken = RegisterStringVar( "KAGENT_PROPAGATE_TOKEN", "", - "When set, propagates the authentication token to downstream services.", + "Set to true to propagate authentication tokens to downstream services. Unset or any other value disables propagation.", ComponentAgentRuntime, ) - // Registered here for `kagent env` CLI discoverability only -- the - // actual gate is read independently (raw os.Getenv, not via this var) - // in go/adk/pkg/tools/skills.go's enableFileSearchToolsEnv. The two - // literals are pinned together by that package's - // TestEnableFileSearchToolsEnvMatchesRegistry. KagentEnableFileSearchTools = RegisterBoolVar( "KAGENT_ENABLE_FILE_SEARCH_TOOLS", false, - "When true, enables the list_files and grep_file skills tools, which let an agent "+ + "When true, t, or 1 (case-insensitive), enables the list_files and grep_file skills tools, which let an agent "+ "enumerate and search the filesystem under its session/skills roots without a "+ - "shell. Disabled by default; set on the Agent's env to opt in.", + "shell. Disabled by default; set in Harness env to opt in.", ComponentAgentRuntime, ) StsWellKnownURI = RegisterStringVar( - "STS_WELL_KNOWN_URI", + "KAGENT_STS_WELL_KNOWN_URI", "", "Well-known endpoint for the Security Token Service (STS) used for token exchange.", ComponentAgentRuntime, @@ -127,28 +97,28 @@ var ( KagentSTSResource = RegisterStringVar( "KAGENT_STS_RESOURCE", "", - "RFC 8707 resource indicator sent on STS token-exchange requests to scope the issued token to a target backend.", + "Comma-separated RFC 8707 resource indicators sent on STS token-exchange requests to scope issued tokens to target backends.", ComponentAgentRuntime, ) KagentSTSAudience = RegisterStringVar( "KAGENT_STS_AUDIENCE", "", - "RFC 8693 audience sent on STS token-exchange requests. Alternate to KAGENT_STS_RESOURCE for servers that key on audience.", + "Comma-separated RFC 8693 audiences sent on STS token-exchange requests. Alternate to KAGENT_STS_RESOURCE for servers that key on audience.", ComponentAgentRuntime, ) DatabaseVectorEnabled = RegisterBoolVar( - "DATABASE_VECTOR_ENABLED", + "KAGENT_DATABASE_VECTOR_ENABLED", false, - "Enable vector database migrations and vector-backed database functionality.", - ComponentDatabase, + "Enable vector database migrations and vector-backed database functionality. The controller defaults to false. When unset in the CLI, migrations read the controller ConfigMap and fall back to true if it is unavailable.", + ComponentDatabase, ComponentController, ComponentCLI, ) SkipMigrations = RegisterBoolVar( - "SKIP_MIGRATIONS", + "KAGENT_SKIP_MIGRATIONS", false, "Verify required database migrations at startup without applying them.", - ComponentDatabase, + ComponentDatabase, ComponentController, ) ) diff --git a/go/core/pkg/env/otel.go b/go/core/pkg/env/otel.go index b1b4e93f38..4844cdfbe5 100644 --- a/go/core/pkg/env/otel.go +++ b/go/core/pkg/env/otel.go @@ -1,48 +1,48 @@ package env -// OpenTelemetry environment variables. The controller exports with them and -// forwards them to every agent runtime. +// OpenTelemetry environment variables. The controller forwards supported signal +// settings to runtimes; SDK-only settings below apply to the receiving process. var ( OtelTracesExporter = RegisterStringVar( "OTEL_TRACES_EXPORTER", "", - "Trace exporter, otlp or none. Only otlp is forwarded to agent runtimes.", - ComponentController, + "Trace exporter, otlp or none. Managed runtime export requires explicit otlp and an endpoint; unset disables forwarding. Standalone SDKs may default to otlp.", + ComponentController, ComponentAgentRuntime, ) OtelMetricsExporter = RegisterStringVar( "OTEL_METRICS_EXPORTER", "", - "Metric exporter, otlp or none. Only otlp is forwarded to agent runtimes.", - ComponentController, + "Metric exporter, otlp or none. Managed runtime export requires explicit otlp and an endpoint; unset disables forwarding. Standalone SDKs may default to otlp.", + ComponentController, ComponentAgentRuntime, ) OtelLogsExporter = RegisterStringVar( "OTEL_LOGS_EXPORTER", "", - "Log exporter, otlp or none. Only otlp is forwarded to agent runtimes.", - ComponentController, + "Log exporter, otlp or none. Managed runtime export requires explicit otlp and an endpoint; unset disables forwarding. Standalone SDKs may default to otlp.", + ComponentController, ComponentAgentRuntime, ) OtelExporterOTLPEndpoint = RegisterStringVar( "OTEL_EXPORTER_OTLP_ENDPOINT", "", - "OTLP endpoint for every signal. OTEL_EXPORTER_OTLP__ENDPOINT overrides it for one signal.", - ComponentController, + "OTLP endpoint for every signal. `OTEL_EXPORTER_OTLP__ENDPOINT` overrides it for one signal.", + ComponentController, ComponentAgentRuntime, ) OtelExporterOTLPProtocol = RegisterStringVar( "OTEL_EXPORTER_OTLP_PROTOCOL", "grpc", - "OTLP protocol, grpc or http/protobuf. OTEL_EXPORTER_OTLP__PROTOCOL overrides it for one signal.", - ComponentController, + "OTLP protocol, grpc or http/protobuf. `OTEL_EXPORTER_OTLP__PROTOCOL` overrides it for one signal.", + ComponentController, ComponentAgentRuntime, ) OtelCaptureMessageContent = RegisterStringVar( "OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT", "NO_CONTENT", - "SPAN_ONLY records prompts and responses on agent spans. This may expose sensitive content.", - ComponentController, + "SPAN_ONLY records prompts and responses on agent spans. NO_CONTENT disables capture. Managed runtimes support these two modes; standalone Python ADK also recognizes SPAN_AND_EVENT. Captured content may be sensitive.", + ComponentController, ComponentAgentRuntime, ) OtelResourceAttributes = RegisterStringVar( @@ -52,3 +52,27 @@ var ( ComponentController, ) ) + +var ( + OtelSDKDisabled = RegisterStringVar("OTEL_SDK_DISABLED", "false", "Disable SDK telemetry and forwarding to managed runtimes when true (case-insensitive). Other values are treated as false.", ComponentController, ComponentAgentRuntime) + OtelServiceName = RegisterStringVar("OTEL_SERVICE_NAME", "", "SDK service name for the current process. Defaults to kagent-controller in the controller; the controller supplies the agent name to managed runtimes.", ComponentController, ComponentAgentRuntime) + OtelSDKResourceAttributes = RegisterStringVar("OTEL_RESOURCE_ATTRIBUTES", "", "Comma-separated SDK resource attributes for the current process. Helm injects controller identity; kagent constructs runtime identity separately. Use KAGENT_OTEL_RESOURCE_ATTRIBUTES for attributes shared with managed agents.", ComponentController, ComponentAgentRuntime) + OtelExporterOTLPTimeout = RegisterStringVar("OTEL_EXPORTER_OTLP_TIMEOUT", "", "OTLP export timeout in milliseconds. The controller forwards positive integers; absent values use each SDK's default (normally 10000 ms).", ComponentController, ComponentAgentRuntime) + OtelExporterOTLPTracesEndpoint = RegisterStringVar("OTEL_EXPORTER_OTLP_TRACES_ENDPOINT", "", "Trace endpoint override. Falls back to OTEL_EXPORTER_OTLP_ENDPOINT; an HTTP override must include its signal path.", ComponentController, ComponentAgentRuntime) + OtelExporterOTLPMetricsEndpoint = RegisterStringVar("OTEL_EXPORTER_OTLP_METRICS_ENDPOINT", "", "Metric endpoint override. Falls back to OTEL_EXPORTER_OTLP_ENDPOINT; an HTTP override must include its signal path.", ComponentController, ComponentAgentRuntime) + OtelExporterOTLPLogsEndpoint = RegisterStringVar("OTEL_EXPORTER_OTLP_LOGS_ENDPOINT", "", "Log endpoint override. Falls back to OTEL_EXPORTER_OTLP_ENDPOINT; an HTTP override must include its signal path.", ComponentController, ComponentAgentRuntime) + OtelExporterOTLPTracesProtocol = RegisterStringVar("OTEL_EXPORTER_OTLP_TRACES_PROTOCOL", "", "Trace protocol override: grpc or http/protobuf. Falls back to OTEL_EXPORTER_OTLP_PROTOCOL.", ComponentController, ComponentAgentRuntime) + OtelExporterOTLPMetricsProtocol = RegisterStringVar("OTEL_EXPORTER_OTLP_METRICS_PROTOCOL", "", "Metric protocol override: grpc or http/protobuf. Falls back to OTEL_EXPORTER_OTLP_PROTOCOL.", ComponentController, ComponentAgentRuntime) + OtelExporterOTLPLogsProtocol = RegisterStringVar("OTEL_EXPORTER_OTLP_LOGS_PROTOCOL", "", "Log protocol override: grpc or http/protobuf. Falls back to OTEL_EXPORTER_OTLP_PROTOCOL.", ComponentController, ComponentAgentRuntime) + _ = RegisterStringVar("OTEL_EXPORTER_OTLP_TRACES_TIMEOUT", "", "Trace SDK timeout in milliseconds, overriding OTEL_EXPORTER_OTLP_TIMEOUT. Not forwarded by the controller.", ComponentController, ComponentAgentRuntime) + _ = RegisterStringVar("OTEL_EXPORTER_OTLP_METRICS_TIMEOUT", "", "Metric SDK timeout in milliseconds, overriding OTEL_EXPORTER_OTLP_TIMEOUT. Not forwarded by the controller.", ComponentController, ComponentAgentRuntime) + _ = RegisterStringVar("OTEL_EXPORTER_OTLP_LOGS_TIMEOUT", "", "Log SDK timeout in milliseconds, overriding OTEL_EXPORTER_OTLP_TIMEOUT. Not forwarded by the controller.", ComponentController, ComponentAgentRuntime) + OtelPropagators = RegisterStringVar("OTEL_PROPAGATORS", "tracecontext", "SDK trace propagators. Kagent defaults to W3C tracecontext without baggage and supplies that default to managed runtimes.", ComponentController, ComponentAgentRuntime) + OtelExporterOTLPCompression = RegisterStringVar("OTEL_EXPORTER_OTLP_COMPRESSION", "gzip", "OTLP compression default applied by kagent. The native Codex process has this variable removed because its exporter does not support gzip.", ComponentController, ComponentAgentRuntime) + OtelExporterOTLPMetricsDefaultHistogramAggregation = RegisterStringVar("OTEL_EXPORTER_OTLP_METRICS_DEFAULT_HISTOGRAM_AGGREGATION", "base2_exponential_bucket_histogram", "Default SDK histogram aggregation applied by kagent and supplied to managed runtimes.", ComponentController, ComponentAgentRuntime) + OtelCaptureRawAPIBodies = RegisterBoolVar("KAGENT_OTEL_CAPTURE_RAW_API_BODIES", false, "Set to true, t, or 1 (case-insensitive) to enable native Claude raw API body logging when log export is enabled. Independent of span content capture; bodies may contain sensitive data.", ComponentController) + OtelMaxCaptureBytes = RegisterIntVar("KAGENT_OTEL_MAX_CAPTURE_BYTES", 16384, "Per-input/output content capture budget in bytes when capture is enabled. Valid values are 1 through 65536; absent or invalid values use 16384.", ComponentController) + _ = RegisterStringVar("ADK_TELEMETRY_SCHEMA_VERSION_OPT_IN", "2", "Python Google ADK telemetry schema version; set by kagent when absent.", ComponentAgentRuntime) + _ = RegisterStringVar("OTEL_SEMCONV_STABILITY_OPT_IN", "gen_ai_latest_experimental", "Python Google ADK semantic-convention opt-in; set by kagent when absent.", ComponentAgentRuntime) + _ = RegisterStringVar("ADK_CAPTURE_MESSAGE_CONTENT_IN_SPANS", "", "Python Google ADK span content capture. When absent, derived from OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT (true for SPAN_ONLY or SPAN_AND_EVENT, false otherwise).", ComponentAgentRuntime) +) diff --git a/go/core/pkg/env/providers.go b/go/core/pkg/env/providers.go index 414aecc968..ea050c1a9b 100644 --- a/go/core/pkg/env/providers.go +++ b/go/core/pkg/env/providers.go @@ -1,15 +1,16 @@ package env -// LLM provider environment variables. These are injected into agent pods -// by the controller and consumed by the agent runtime. +// LLM provider environment variables consumed by runtimes and provider SDKs. +// Managed runtimes receive configuration from the controller; credentials may +// instead be injected into requests at the egress gateway. // OpenAI var ( OpenAIAPIKey = RegisterStringVar( "OPENAI_API_KEY", "", - "API key for OpenAI.", - ComponentAgentRuntime, + "API key for OpenAI. Upgrade tests fall back to a placeholder when unset or empty.", + ComponentAgentRuntime, ComponentCLI, ComponentTesting, ) OpenAIOrganization = RegisterStringVar( @@ -33,7 +34,7 @@ var ( "ANTHROPIC_API_KEY", "", "API key for Anthropic.", - ComponentAgentRuntime, + ComponentAgentRuntime, ComponentCLI, ) ) @@ -43,7 +44,7 @@ var ( "AZURE_OPENAI_API_KEY", "", "API key for Azure OpenAI.", - ComponentAgentRuntime, + ComponentAgentRuntime, ComponentCLI, ) AzureADToken = RegisterStringVar( @@ -56,7 +57,7 @@ var ( OpenAIAPIVersion = RegisterStringVar( "OPENAI_API_VERSION", "", - "Azure OpenAI API version (e.g. 2024-02-15-preview).", + "Azure OpenAI API version. The Go and Python ADKs fall back to 2024-02-15-preview when model configuration and this variable are unset.", ComponentAgentRuntime, ) @@ -74,7 +75,7 @@ var ( "GOOGLE_API_KEY", "", "API key for Google Gemini.", - ComponentAgentRuntime, + ComponentAgentRuntime, ComponentCLI, ) GoogleCloudProject = RegisterStringVar( @@ -111,7 +112,7 @@ var ( AWSRegion = RegisterStringVar( "AWS_REGION", "", - "AWS region for Bedrock.", + "AWS region for Bedrock. Python Bedrock and Go Bedrock embeddings prefer AWS_DEFAULT_REGION, then AWS_REGION, then us-east-1.", ComponentAgentRuntime, ) @@ -149,7 +150,7 @@ var ( OllamaAPIBase = RegisterStringVar( "OLLAMA_API_BASE", "", - "Base URL for the Ollama API endpoint.", + "Base URL for the Ollama API endpoint; falls back to http://localhost:11434 when model configuration and this variable are unset.", ComponentAgentRuntime, ) @@ -157,10 +158,17 @@ var ( "OLLAMA_API_KEY", "", "API key for Ollama Cloud. When set, a cloud-tagged model reaches api.ollama.com directly.", - ComponentAgentRuntime, + ComponentAgentRuntime, ComponentCLI, ) ) +// Provider aliases. +var ( + GeminiAPIKey = RegisterStringVar("GEMINI_API_KEY", "", "Fallback Gemini API key when GOOGLE_API_KEY is unset; supported by the CLI and Go/Python ADKs.", ComponentAgentRuntime, ComponentCLI) + GoogleCloudRegion = RegisterStringVar("GOOGLE_CLOUD_REGION", "", "Go ADK Vertex AI region fallback when GOOGLE_CLOUD_LOCATION is unset.", ComponentAgentRuntime) + AWSDefaultRegion = RegisterStringVar("AWS_DEFAULT_REGION", "", "Preferred region for Python Bedrock models and Go/Python Bedrock embeddings, before AWS_REGION and the us-east-1 fallback.", ComponentAgentRuntime) +) + // SAP AI Core var ( SAPAICoreClientID = RegisterStringVar( diff --git a/go/core/pkg/env/runtime.go b/go/core/pkg/env/runtime.go new file mode 100644 index 0000000000..db6987a216 --- /dev/null +++ b/go/core/pkg/env/runtime.go @@ -0,0 +1,13 @@ +package env + +// Registrations for user-configurable runtime settings. +// Go runtimes read these accessors. Metadata-only entries describe settings +// consumed by Python runtimes or upstream SDKs. +var ( + KagentPort = RegisterStringVar("KAGENT_PORT", "", "ADK A2A listen port: the Go HTTP/gRPC listener defaults to 8080; the Python gRPC listener defaults to 80. Explicit Go --port/AppConfig.Port or Python a2a_grpc_address takes precedence. The controller sets 80 for managed kagent runtimes. Python's HTTP --port is separate.", ComponentAgentRuntime) + KagentConfigDir = RegisterStringVar("KAGENT_CONFIG_DIR", "/config", "Go ADK configuration directory; --filepath takes precedence.", ComponentAgentRuntime) + KagentA2AMaxContentLength = RegisterStringVar("KAGENT_A2A_MAX_CONTENT_LENGTH", "10485760", "Maximum A2A request size in bytes for Go/Python servers. 0, none, or unlimited disables the limit; invalid values use the default.", ComponentAgentRuntime) + _ = RegisterStringVar("KAGENT_BASH_VENV_PATH", "", "Virtual environment used for Python skills shell commands; its bin directory is prepended to PATH and VIRTUAL_ENV is set.", ComponentAgentRuntime) + _ = RegisterBoolVar("KAGENT_OPENAI_AGENTS_NATIVE_TRACING", false, "Keep the OpenAI Agents SDK native tracing processor alongside kagent OpenTelemetry export in the Python OpenAI runtime.", ComponentAgentRuntime) + _ = RegisterBoolVar("OPENAI_AGENTS_DISABLE_TRACING", false, "Disable OpenAI Agents SDK tracing, including the kagent bridge. The Python OpenAI runtime accepts true or 1.", ComponentAgentRuntime) +) diff --git a/go/core/pkg/env/sandbox.go b/go/core/pkg/env/sandbox.go index 8d20a35e65..f6288fac20 100644 --- a/go/core/pkg/env/sandbox.go +++ b/go/core/pkg/env/sandbox.go @@ -3,9 +3,9 @@ package env import "time" var ( - SandboxGuestImage = RegisterStringVar("SANDBOX_GUEST_IMAGE", "", "Guest package image pinned by sha256 digest. Required for sandbox preparation and passed unchanged to Substrate.", ComponentController) - SandboxCPU = RegisterStringVar("SANDBOX_CPU", "1", "CPU limit for standalone sandbox runtimes.", ComponentController) - SandboxMemory = RegisterStringVar("SANDBOX_MEMORY", "1Gi", "Memory limit for standalone sandbox runtimes.", ComponentController) - SandboxDefaultTTL = RegisterDurationVar("SANDBOX_DEFAULT_TTL", time.Hour, "Default standalone sandbox lifetime.", ComponentController) - SandboxMaxTTL = RegisterDurationVar("SANDBOX_MAX_TTL", 24*time.Hour, "Maximum standalone sandbox lifetime, at most 24h.", ComponentController) + SandboxGuestImage = RegisterStringVar("KAGENT_SANDBOX_GUEST_IMAGE", "", "Guest package image pinned by sha256 digest. Required for sandbox preparation and passed unchanged to Substrate.", ComponentController) + SandboxCPU = RegisterStringVar("KAGENT_SANDBOX_CPU", "1", "CPU limit for standalone sandbox runtimes.", ComponentController) + SandboxMemory = RegisterStringVar("KAGENT_SANDBOX_MEMORY", "1Gi", "Memory limit for standalone sandbox runtimes.", ComponentController) + SandboxDefaultTTL = RegisterDurationVar("KAGENT_SANDBOX_DEFAULT_TTL", time.Hour, "Default standalone sandbox lifetime.", ComponentController) + SandboxMaxTTL = RegisterDurationVar("KAGENT_SANDBOX_MAX_TTL", 24*time.Hour, "Maximum standalone sandbox lifetime, at most 24h.", ComponentController) ) diff --git a/go/core/pkg/env/substrate.go b/go/core/pkg/env/substrate.go index d0aecc5abe..3693b9cbe1 100644 --- a/go/core/pkg/env/substrate.go +++ b/go/core/pkg/env/substrate.go @@ -1,7 +1,13 @@ package env +var ( + SubstrateATEAPIEndpoint = RegisterStringVar("KAGENT_SUBSTRATE_ATE_API_ENDPOINT", "dns:///api.ate-system.svc:443", "Substrate control-plane gRPC endpoint.", ComponentController) + SubstrateATEAPICAFile = RegisterStringVar("KAGENT_SUBSTRATE_ATE_API_CA_FILE", "", "PEM CA bundle used to verify the Substrate API server. Empty uses system trust roots.", ComponentController) + SubstrateATEAPIClientCertFile = RegisterStringVar("KAGENT_SUBSTRATE_ATE_API_CLIENT_CERT_FILE", "", "PEM bundle containing both the client certificate and private key for Substrate API mTLS. Reloaded for each TLS handshake.", ComponentController) +) + var SubstrateAtenetRouterURL = RegisterStringVar( - "SUBSTRATE_ATENET_ROUTER_URL", + "KAGENT_SUBSTRATE_ATENET_ROUTER_URL", "http://atenet-router.ate-system.svc:80", "Substrate router endpoint for agent and sandbox guest traffic.", ComponentController, diff --git a/go/core/pkg/env/testing.go b/go/core/pkg/env/testing.go index 2f366d66ee..0fd85a2010 100644 --- a/go/core/pkg/env/testing.go +++ b/go/core/pkg/env/testing.go @@ -3,37 +3,62 @@ package env // Testing environment variables used in E2E tests and mock services. var ( KagentLocalHost = RegisterStringVar( - "KAGENT_LOCAL_HOST", + "KAGENT_E2E_LOCAL_HOST", "", - "Local host override for E2E tests.", - ComponentTesting, - ) - - SkipCleanup = RegisterBoolVar( - "SKIP_CLEANUP", - false, - "When true, skip cleanup after E2E tests for debugging.", + "Host reachable from E2E runtimes for local mock servers. Defaults to 172.17.0.1 on Linux and host.docker.internal on macOS; required on other systems.", ComponentTesting, ) UpdateGolden = RegisterBoolVar( - "UPDATE_GOLDEN", + "KAGENT_TEST_UPDATE_GOLDEN", false, "When true, update golden test files instead of comparing.", ComponentTesting, ) STSPort = RegisterStringVar( - "STS_PORT", - "", + "KAGENT_TEST_STS_PORT", + "8091", "Port for the mock STS (Security Token Service) server.", ComponentTesting, ) LLMPort = RegisterStringVar( - "LLM_PORT", - "", + "KAGENT_TEST_LLM_PORT", + "8090", "Port for the mock LLM server.", ComponentTesting, ) ) + +var ( + TestPython = RegisterStringVar("KAGENT_TEST_PYTHON", "", "Python executable enabling the Python TaskStore/PostgreSQL interoperability test. That subtest is not run when unset.", ComponentTesting) + E2EAPIURL = RegisterStringVar("KAGENT_E2E_API_URL", "", "Controller URL for Go E2E tests. Falls back to KAGENT_API_URL; tests skip when neither is set.", ComponentTesting) + E2ECLI = RegisterStringVar("KAGENT_E2E_CLI", "", "CLI executable for catalog lifecycle E2E tests; make -C go e2e builds and supplies it.", ComponentTesting) + E2ERuntimeImage = RegisterStringVar("KAGENT_E2E_RUNTIME_IMAGE", "", "Required digest-pinned Go ADK image for sandbox E2E tests and lifecycle manifests.", ComponentTesting) + _ = RegisterStringVar("KAGENT_E2E_BYO_IMAGE", "", "Digest-pinned BYO image substituted into E2E lifecycle manifests.", ComponentTesting) + _ = RegisterStringVar("KAGENT_E2E_CLAUDE_IMAGE", "", "Digest-pinned Claude harness image substituted into E2E lifecycle manifests.", ComponentTesting) + _ = RegisterStringVar("KAGENT_E2E_CODEX_IMAGE", "", "Digest-pinned Codex harness image substituted into E2E lifecycle manifests.", ComponentTesting) + E2ESandboxNamespace = RegisterStringVar("KAGENT_E2E_SANDBOX_NAMESPACE", "kagent", "Namespace for sandbox E2E resources.", ComponentTesting) + E2ESandboxWorkerPool = RegisterStringVar("KAGENT_E2E_SANDBOX_WORKER_POOL", "kagent-default", "Worker pool used by sandbox E2E resources.", ComponentTesting) + E2EOTLPListenAddress = RegisterStringVar("KAGENT_E2E_OTLP_LISTEN_ADDRESS", "", "Listen address for the E2E suite's OTLP trace receiver. Unset disables the shared receiver.", ComponentTesting) + E2ERequireTracing = RegisterStringVar("KAGENT_E2E_REQUIRE_TRACING", "false", "Fail instead of skip when expected native harness tracing support is unavailable. Enabled by true, ignoring case and surrounding whitespace.", ComponentTesting) + E2ERunUpgradeTests = RegisterStringVar("KAGENT_E2E_RUN_UPGRADE_TESTS", "false", "Run upgrade integration tests when exactly true.", ComponentTesting) + E2ERunRollingUpgradeTests = RegisterStringVar("KAGENT_E2E_RUN_ROLLING_UPGRADE_TESTS", "false", "Run rolling upgrade integration tests when exactly true.", ComponentTesting) + _ = RegisterStringVar("KAGENT_E2E_UI_LOOP_PORT", "8001", "Vite development/preview port and UI browser-test port.", ComponentTesting) + _ = RegisterStringVar("KAGENT_E2E_UI_LOOP_EXTENSION_PORT", "", "Example-extension browser-test port. Defaults to KAGENT_E2E_UI_LOOP_PORT plus 50.", ComponentTesting) + _ = RegisterBoolVar("KAGENT_E2E_UI_LOOP_LIVE", false, "Run UI browser tests against a real cluster when exactly true.", ComponentTesting) + _ = RegisterStringVar("KAGENT_E2E_UI_LOOP_LIVE_PORT", "8301", "Live-cluster UI browser-test port.", ComponentTesting) +) + +// Upgrade test inputs supplied by the root Make targets. Their Make variable +// names remain shared with build tooling; the Go tests consume these scoped names. +var ( + E2ERepoRoot = RegisterStringVar("KAGENT_E2E_REPO_ROOT", "", "Repository root for upgrade tests. Falls back to git rev-parse --show-toplevel.", ComponentTesting) + E2EUpgradeFromVersion = RegisterStringVar("KAGENT_E2E_UPGRADE_FROM_VERSION", "", "Required previous release version for upgrade tests; supplied by make from UPGRADE_FROM_VERSION.", ComponentTesting) + E2EVersion = RegisterStringVar("KAGENT_E2E_VERSION", "", "Required current image tag for upgrade tests; supplied by make from VERSION.", ComponentTesting) + E2EDockerRegistry = RegisterStringVar("KAGENT_E2E_DOCKER_REGISTRY", "localhost:5001", "Image registry for upgrade tests; supplied by make from DOCKER_REGISTRY.", ComponentTesting) + E2EKindClusterName = RegisterStringVar("KAGENT_E2E_KIND_CLUSTER_NAME", "kagent", "Kind cluster used by upgrade tests; supplied by make from KIND_CLUSTER_NAME.", ComponentTesting) + E2ENamespace = RegisterStringVar("KAGENT_E2E_NAMESPACE", "kagent", "Kubernetes namespace used by upgrade tests.", ComponentTesting) + E2EKubeContext = RegisterStringVar("KAGENT_E2E_KUBE_CONTEXT", "", "Kubernetes context for upgrade tests. Defaults to kind- followed by KAGENT_E2E_KIND_CLUSTER_NAME.", ComponentTesting) +) diff --git a/go/core/pkg/env/ui.go b/go/core/pkg/env/ui.go new file mode 100644 index 0000000000..5ace7d0e72 --- /dev/null +++ b/go/core/pkg/env/ui.go @@ -0,0 +1,14 @@ +package env + +// The container entrypoint and Vite map the same deployment names to browser keys. +var ( + _ = RegisterStringVar("KAGENT_UI_API_BASE_URL", "/api", "Browser API base URL for UI containers and Vite development.", ComponentUI) + _ = RegisterStringVar("KAGENT_UI_STREAM_TIMEOUT_MS", "1800000", "UI chat stream inactivity timeout in milliseconds. 0 disables the timeout. Applies in containers and Vite development.", ComponentUI) + _ = RegisterStringVar("KAGENT_UI_BASE_PATH", "", "UI public path prefix, such as /ui; empty serves at the root. Applies in containers and Vite development; the container falls back to the root for invalid or reserved prefixes.", ComponentUI) + _ = RegisterStringVar("KAGENT_UI_SSO_REDIRECT_PATH", "/oauth2/start", "UI path used by Sign in with SSO.", ComponentUI) + _ = RegisterBoolVar("KAGENT_UI_ENABLE_MOCK", false, "Serve the development UI from in-browser fixtures when true. Overrides backend settings; no user is signed in. Release bundles do not include the mock backend.", ComponentUI) + _ = RegisterStringVar("KAGENT_UI_EXTENSION_", "", "UI extension settings forwarded to the browser at runtime. Each installed extension owns its keys and defaults; these values are public.", ComponentUI) + _ = RegisterStringVar("KAGENT_UI_DEV_CONTROLLER_URL", "http://127.0.0.1:8083", "Vite development proxy target for /api and /a2a; not sent to the browser.", ComponentUI) + _ = RegisterBoolVar("KAGENT_UI_VITE_EXAMPLE_EXTENSION", false, "Build-time switch enabling the bundled example UI extension.", ComponentUI) + _ = RegisterStringVar("KAGENT_UI_VITE_API_MODE", "", "Build-time API mode override, mock or live, used by UI tests. Overrides KAGENT_UI_ENABLE_MOCK; leave unset for normal development.", ComponentUI, ComponentTesting) +) diff --git a/go/core/pkg/env/var.go b/go/core/pkg/env/var.go index d8a9f6725c..47b1381291 100644 --- a/go/core/pkg/env/var.go +++ b/go/core/pkg/env/var.go @@ -1,7 +1,8 @@ // Package env provides a centralized registry for environment variables used // throughout kagent. Variables are self-registering: calling any Register* // function records the variable's metadata (name, default, description, type, -// component) in a process-wide registry and returns a typed accessor. +// components) in a process-wide registry and returns a typed accessor. +// Supply every consuming component in one registration; at least one is required. // // This design is inspired by Istio's pkg/env package and enables automatic // documentation generation via `kagent env`. @@ -62,6 +63,7 @@ const ( ComponentAgentRuntime Component = "agent-runtime" ComponentTesting Component = "testing" ComponentDatabase Component = "database" + ComponentUI Component = "ui" ) // Var holds the metadata for a single registered environment variable. @@ -74,8 +76,8 @@ type Var struct { Description string `json:"description"` // Type is the data type. Type VarType `json:"type"` - // Component identifies which kagent component uses this variable. - Component Component `json:"component"` + // Components identifies the kagent components that use this variable. + Components []Component `json:"components"` // Hidden, when true, excludes the variable from generated documentation. Hidden bool `json:"-"` // Deprecated, when true, marks the variable as deprecated in documentation. @@ -88,6 +90,12 @@ var ( ) func register(v Var) { + if len(v.Components) == 0 || slices.Contains(v.Components, Component("")) { + panic(fmt.Sprintf("environment variable %s requires non-empty components", v.Name)) + } + v.Components = slices.Clone(v.Components) + slices.Sort(v.Components) + v.Components = slices.Compact(v.Components) mu.Lock() defer mu.Unlock() allVars[v.Name] = v @@ -100,6 +108,7 @@ func VarDescriptions() []Var { out := make([]Var, 0, len(allVars)) for _, v := range allVars { + v.Components = slices.Clone(v.Components) out = append(out, v) } slices.SortFunc(out, func(a, b Var) int { @@ -113,6 +122,7 @@ func VarByName(name string) (Var, bool) { mu.Lock() defer mu.Unlock() v, ok := allVars[name] + v.Components = slices.Clone(v.Components) return v, ok } @@ -124,13 +134,13 @@ type StringVar struct { } // RegisterStringVar registers a string environment variable and returns a typed accessor. -func RegisterStringVar(name, defaultValue, description string, component Component) StringVar { +func RegisterStringVar(name, defaultValue, description string, components ...Component) StringVar { v := Var{ Name: name, DefaultValue: defaultValue, Description: description, Type: TypeString, - Component: component, + Components: components, } register(v) return StringVar{v: v} @@ -168,13 +178,13 @@ type BoolVar struct { } // RegisterBoolVar registers a boolean environment variable and returns a typed accessor. -func RegisterBoolVar(name string, defaultValue bool, description string, component Component) BoolVar { +func RegisterBoolVar(name string, defaultValue bool, description string, components ...Component) BoolVar { v := Var{ Name: name, DefaultValue: strconv.FormatBool(defaultValue), Description: description, Type: TypeBool, - Component: component, + Components: components, } register(v) return BoolVar{v: v, defaultValue: defaultValue} @@ -182,26 +192,31 @@ func RegisterBoolVar(name string, defaultValue bool, description string, compone // Get returns the current value of the environment variable, or the default. func (b BoolVar) Get() bool { - if val, ok := os.LookupEnv(b.v.Name); ok { - parsed, err := strconv.ParseBool(val) - if err == nil { - return parsed - } - } - return b.defaultValue + value, _, _ := b.LookupWithError() // Invalid input uses the registered default. + return value } -// Lookup returns the value and whether the variable was set. +// Lookup returns the value and whether a nonempty, valid value was set. func (b BoolVar) Lookup() (bool, bool) { + value, set, err := b.LookupWithError() + return value, set && err == nil +} + +// LookupWithError distinguishes invalid input from an unset or empty value. +// Boolean values are case-insensitive and ignore surrounding whitespace. +// Unset, empty, and invalid values return the registered default; invalid input +// additionally returns set=true and a parsing error. +func (b BoolVar) LookupWithError() (value, set bool, err error) { val, ok := os.LookupEnv(b.v.Name) - if !ok { - return b.defaultValue, false + val = strings.TrimSpace(val) + if !ok || val == "" { + return b.defaultValue, false, nil } - parsed, err := strconv.ParseBool(val) + parsed, err := strconv.ParseBool(strings.ToLower(val)) if err != nil { - return b.defaultValue, false + return b.defaultValue, true, fmt.Errorf("failed to parse %s as a boolean: %w", b.v.Name, err) } - return parsed, true + return parsed, true, nil } // Name returns the environment variable name. @@ -216,13 +231,13 @@ type IntVar struct { } // RegisterIntVar registers an integer environment variable and returns a typed accessor. -func RegisterIntVar(name string, defaultValue int, description string, component Component) IntVar { +func RegisterIntVar(name string, defaultValue int, description string, components ...Component) IntVar { v := Var{ Name: name, DefaultValue: strconv.Itoa(defaultValue), Description: description, Type: TypeInt, - Component: component, + Components: components, } register(v) return IntVar{v: v, defaultValue: defaultValue} @@ -230,26 +245,30 @@ func RegisterIntVar(name string, defaultValue int, description string, component // Get returns the current value of the environment variable, or the default. func (i IntVar) Get() int { - if val, ok := os.LookupEnv(i.v.Name); ok { - parsed, err := strconv.Atoi(val) - if err == nil { - return parsed - } - } - return i.defaultValue + value, _, _ := i.LookupWithError() // Invalid input uses the registered default. + return value } -// Lookup returns the value and whether the variable was set. +// Lookup returns the value and whether a nonempty, valid value was set. func (i IntVar) Lookup() (int, bool) { + value, set, err := i.LookupWithError() + return value, set && err == nil +} + +// LookupWithError distinguishes invalid input from an unset or empty value. +// Integer values ignore surrounding whitespace. Unset, empty, and invalid values +// return the registered default; invalid input also returns set=true and an error. +func (i IntVar) LookupWithError() (value int, set bool, err error) { val, ok := os.LookupEnv(i.v.Name) - if !ok { - return i.defaultValue, false + val = strings.TrimSpace(val) + if !ok || val == "" { + return i.defaultValue, false, nil } parsed, err := strconv.Atoi(val) if err != nil { - return i.defaultValue, false + return i.defaultValue, true, fmt.Errorf("failed to parse %s as an integer: %w", i.v.Name, err) } - return parsed, true + return parsed, true, nil } // Name returns the environment variable name. @@ -264,13 +283,13 @@ type DurationVar struct { } // RegisterDurationVar registers a duration environment variable and returns a typed accessor. -func RegisterDurationVar(name string, defaultValue time.Duration, description string, component Component) DurationVar { +func RegisterDurationVar(name string, defaultValue time.Duration, description string, components ...Component) DurationVar { v := Var{ Name: name, DefaultValue: defaultValue.String(), Description: description, Type: TypeDuration, - Component: component, + Components: components, } register(v) return DurationVar{v: v, defaultValue: defaultValue} @@ -311,6 +330,16 @@ func ExportMarkdown(component string) string { var sb strings.Builder sb.WriteString("# Kagent Environment Variables\n\n") + sb.WriteString("\n\n") + sb.WriteString("Generated from `go/core/pkg/env`. Edit the registrations there, run `make env-docs`, " + + "and commit the result. CI runs `make env-docs-check`.\n\n") + sb.WriteString("This reference covers user-configurable settings for the controller, CLI, standalone agent runtimes, UI, and tests. " + + "Controller-generated runtime payloads, credentials, private paths, and other internal process wiring are excluded. " + + "Build scripts, sample applications, and third-party SDK settings not configured by kagent have their own documentation. " + + "Defaults describe the application without deployment overrides; Helm or a Harness may supply different values. " + + "`(none)` means no fixed default; see the description for required values and fallbacks. " + + "Shared variables appear under each consuming component. " + + "Only registered metadata is exported, never values from the current process environment.\n\n") // Group by component grouped := make(map[Component][]Var) @@ -318,10 +347,12 @@ func ExportMarkdown(component string) string { if v.Hidden { continue } - if component != "" && component != "all" && string(v.Component) != component { - continue + for _, comp := range v.Components { + if component != "" && component != "all" && string(comp) != component { + continue + } + grouped[comp] = append(grouped[comp], v) } - grouped[v.Component] = append(grouped[v.Component], v) } // Sort component keys for deterministic output @@ -353,7 +384,7 @@ func ExportMarkdown(component string) string { sb.WriteString("\n") } - return sb.String() + return strings.TrimSuffix(sb.String(), "\n") } // ExportJSON generates a JSON array of all registered variables. @@ -364,7 +395,7 @@ func ExportJSON(component string) string { if v.Hidden { continue } - if component != "" && component != "all" && string(v.Component) != component { + if component != "" && component != "all" && !slices.Contains(v.Components, Component(component)) { continue } out = append(out, v) diff --git a/go/core/pkg/env/var_test.go b/go/core/pkg/env/var_test.go index 88c51c14d3..c3ac4354d5 100644 --- a/go/core/pkg/env/var_test.go +++ b/go/core/pkg/env/var_test.go @@ -1,10 +1,13 @@ package env import ( + "encoding/json" "os" "strings" "testing" "time" + + "github.com/stretchr/testify/require" ) func TestRegisterStringVar(t *testing.T) { @@ -69,6 +72,81 @@ func TestRegisterBoolVar(t *testing.T) { } } +func TestBoolLookupWithError(t *testing.T) { + v := RegisterBoolVar("TEST_BOOL_LOOKUP_ERROR", true, "test", ComponentTesting) + for _, tt := range []struct { + name, input string + unset, want, set, wantErr bool + }{ + {name: "unset", unset: true, want: true}, + {name: "empty", want: true}, + {name: "whitespace", input: " \t", want: true}, + {name: "mixed case", input: " tRuE ", want: true, set: true}, + {name: "short true", input: " T ", want: true, set: true}, + {name: "one", input: "1", want: true, set: true}, + {name: "false", input: " FaLsE ", set: true}, + {name: "short false", input: "F", set: true}, + {name: "zero", input: "0", set: true}, + {name: "invalid", input: "yes", want: true, set: true, wantErr: true}, + } { + t.Run(tt.name, func(t *testing.T) { + t.Setenv(v.Name(), tt.input) + if tt.unset { + require.NoError(t, os.Unsetenv(v.Name())) + } + got, set, err := v.LookupWithError() + require.Equal(t, tt.want, got) + require.Equal(t, tt.set, set) + if tt.wantErr { + require.ErrorContains(t, err, v.Name()) + } else { + require.NoError(t, err) + } + require.Equal(t, tt.want, v.Get()) + got, valid := v.Lookup() + require.Equal(t, tt.want, got) + require.Equal(t, tt.set && !tt.wantErr, valid) + }) + } +} + +func TestIntLookupWithError(t *testing.T) { + v := RegisterIntVar("TEST_INT_LOOKUP_ERROR", 42, "test", ComponentTesting) + for _, tt := range []struct { + name, input string + want int + unset, set, wantErr bool + }{ + {name: "unset", unset: true, want: 42}, + {name: "empty", want: 42}, + {name: "whitespace", input: " \t", want: 42}, + {name: "valid", input: " 4096 ", want: 4096, set: true}, + {name: "zero", input: "0", set: true}, + {name: "negative", input: "-1", want: -1, set: true}, + {name: "invalid", input: "many", want: 42, set: true, wantErr: true}, + {name: "overflow", input: "99999999999999999999999999", want: 42, set: true, wantErr: true}, + } { + t.Run(tt.name, func(t *testing.T) { + t.Setenv(v.Name(), tt.input) + if tt.unset { + require.NoError(t, os.Unsetenv(v.Name())) + } + got, set, err := v.LookupWithError() + require.Equal(t, tt.want, got) + require.Equal(t, tt.set, set) + if tt.wantErr { + require.ErrorContains(t, err, v.Name()) + } else { + require.NoError(t, err) + } + require.Equal(t, tt.want, v.Get()) + got, valid := v.Lookup() + require.Equal(t, tt.want, got) + require.Equal(t, tt.set && !tt.wantErr, valid) + }) + } +} + func TestRegisterIntVar(t *testing.T) { allVars = make(map[string]Var) @@ -264,6 +342,13 @@ func TestExportMarkdown(t *testing.T) { RegisterBoolVar("TEST_MD_BOOL", true, "A bool variable.", ComponentController) md := ExportMarkdown("all") + if !strings.HasSuffix(md, "\n") || strings.HasSuffix(md, "\n\n") { + t.Error("markdown should end with exactly one newline") + } + t.Setenv("TEST_MD_VAR", "secret-from-current-environment") + if got := ExportMarkdown("all"); got != md { + t.Error("documentation must contain registered defaults, not live environment values") + } if !strings.Contains(md, "TEST_MD_VAR") { t.Error("markdown should contain TEST_MD_VAR") } @@ -304,6 +389,88 @@ func TestExportJSON(t *testing.T) { } } +func TestRegisterMultipleComponents(t *testing.T) { + tests := []struct { + name string + register func(...Component) + }{ + {"string", func(c ...Component) { RegisterStringVar("SHARED", "value", "shared setting", c...) }}, + {"bool", func(c ...Component) { RegisterBoolVar("SHARED", true, "shared setting", c...) }}, + {"int", func(c ...Component) { RegisterIntVar("SHARED", 42, "shared setting", c...) }}, + {"duration", func(c ...Component) { RegisterDurationVar("SHARED", time.Second, "shared setting", c...) }}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + allVars = make(map[string]Var) + components := []Component{ComponentController, ComponentCLI, ComponentController} + tt.register(components...) + want := []Component{ComponentCLI, ComponentController} + v, ok := VarByName("SHARED") + require.True(t, ok) + require.Equal(t, want, v.Components) + + // Neither caller-owned nor returned slices can change registered metadata. + components[0] = ComponentTesting + v.Components[0] = ComponentTesting + VarDescriptions()[0].Components[0] = ComponentTesting + v, ok = VarByName("SHARED") + require.True(t, ok) + require.Equal(t, want, v.Components) + require.Len(t, VarDescriptions(), 1) + require.Panics(t, func() { tt.register() }) + require.Panics(t, func() { tt.register(ComponentController, "") }) + }) + } +} + +func TestExportMultipleComponents(t *testing.T) { + type exportedVar struct { + Name string `json:"name"` + Components []Component `json:"components"` + } + allVars = make(map[string]Var) + RegisterStringVar("SHARED", "value", "shared setting", ComponentController, ComponentCLI, ComponentController) + RegisterBoolVar("CLI_ONLY", true, "CLI setting", ComponentCLI) + register(Var{Name: "HIDDEN", Components: []Component{ComponentCLI, ComponentController}, Hidden: true}) + wantComponents := []Component{ComponentCLI, ComponentController} + + tests := []struct { + component string + wantNames []string + sharedRows int + }{ + {"", []string{"CLI_ONLY", "SHARED"}, 2}, + {"all", []string{"CLI_ONLY", "SHARED"}, 2}, + {"controller", []string{"SHARED"}, 1}, + {"cli", []string{"CLI_ONLY", "SHARED"}, 1}, + {"agent-runtime", []string{}, 0}, + {"unknown", []string{}, 0}, + } + for _, tt := range tests { + t.Run(tt.component, func(t *testing.T) { + var exported []exportedVar + require.NoError(t, json.Unmarshal([]byte(ExportJSON(tt.component)), &exported)) + names := make([]string, 0, len(exported)) + for _, v := range exported { + names = append(names, v.Name) + if v.Name == "SHARED" { + require.Equal(t, wantComponents, v.Components) + } + } + require.Equal(t, tt.wantNames, names) + + md := ExportMarkdown(tt.component) + require.Equal(t, tt.sharedRows, strings.Count(md, "| `SHARED` |")) + require.NotContains(t, md, "HIDDEN") + if tt.component == "controller" { + require.Contains(t, md, "## controller\n") + require.NotContains(t, md, "## cli\n") + require.NotContains(t, md, "CLI_ONLY") + } + }) + } +} + func TestHiddenVarsExcluded(t *testing.T) { allVars = make(map[string]Var) @@ -313,7 +480,7 @@ func TestHiddenVarsExcluded(t *testing.T) { register(Var{ Name: "HIDDEN_VAR", Description: "hidden", - Component: ComponentController, + Components: []Component{ComponentController}, Hidden: true, }) diff --git a/go/core/test/e2e/README.md b/go/core/test/e2e/README.md index b95fc37fa2..b628c25136 100644 --- a/go/core/test/e2e/README.md +++ b/go/core/test/e2e/README.md @@ -135,7 +135,7 @@ KAGENT_E2E_API_URL=http://:8083 make -C go e2e `TestSessionInteraction` starts the deterministic mock LLM on the test host and translates its listener to the host address reachable from the cluster (`172.17.0.1` on Linux and `host.docker.internal` on macOS). Set -`KAGENT_LOCAL_HOST` when the cluster uses a different host address. +`KAGENT_E2E_LOCAL_HOST` when the cluster uses a different host address. `TestMCPInteraction` starts `mockmcp` on the same reachable host, registers it as a `RemoteMCPServer`, and verifies an actual `tools/call` request. diff --git a/go/core/test/e2e/cli_catalog_lifecycle_test.go b/go/core/test/e2e/cli_catalog_lifecycle_test.go index 52c30d4c76..18566ed467 100644 --- a/go/core/test/e2e/cli_catalog_lifecycle_test.go +++ b/go/core/test/e2e/cli_catalog_lifecycle_test.go @@ -11,6 +11,7 @@ import ( "github.com/google/uuid" apiv1alpha1 "github.com/kagent-dev/kagent/go/api/gen/kagent/api/v1alpha1" + kagentenv "github.com/kagent-dev/kagent/go/core/pkg/env" "google.golang.org/protobuf/encoding/protojson" "k8s.io/client-go/tools/clientcmd" ) @@ -161,7 +162,7 @@ func TestE2ECLISessionDiscoveryAndInvoke(t *testing.T) { func runKagentCLI(t *testing.T, ctx context.Context, binary string, args ...string) string { t.Helper() command := exec.CommandContext(ctx, binary, args...) - kubeconfig := os.Getenv(clientcmd.RecommendedConfigPathEnvVar) + kubeconfig := kagentenv.Kubeconfig.Get() if kubeconfig == "" { kubeconfig = clientcmd.RecommendedHomeFile } @@ -180,7 +181,7 @@ func runKagentCLI(t *testing.T, ctx context.Context, binary string, args ...stri func kagentCLI(t *testing.T) string { t.Helper() - binary := os.Getenv("KAGENT_E2E_CLI") + binary := kagentenv.E2ECLI.Get() if binary == "" { t.Fatal("KAGENT_E2E_CLI is not set; run E2E tests through `make -C go e2e`") } diff --git a/go/core/test/e2e/config_test.go b/go/core/test/e2e/config_test.go new file mode 100644 index 0000000000..74583d9d34 --- /dev/null +++ b/go/core/test/e2e/config_test.go @@ -0,0 +1,32 @@ +package e2e_test + +import ( + "testing" + + kagentenv "github.com/kagent-dev/kagent/go/core/pkg/env" + "github.com/stretchr/testify/require" +) + +func TestInteractionTargetConfiguration(t *testing.T) { + for _, tc := range []struct { + name string + e2eURL string + apiURL string + want string + }{ + {name: "explicit E2E URL", e2eURL: "http://e2e.example:8083", want: "e2e.example:8083"}, + {name: "E2E URL takes precedence", e2eURL: "http://e2e.example:8083", apiURL: "http://runtime.example:8084", want: "e2e.example:8083"}, + {name: "empty E2E URL falls back", apiURL: "http://runtime.example:8084", want: "runtime.example:8084"}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Setenv(kagentenv.E2EAPIURL.Name(), tc.e2eURL) + t.Setenv(kagentenv.KagentAPIURL.Name(), tc.apiURL) + require.Equal(t, tc.want, interactionTarget(t)) + }) + } +} + +func TestReachableServerURLConfiguredHost(t *testing.T) { + t.Setenv(kagentenv.KagentLocalHost.Name(), "mock.example") + require.Equal(t, "http://mock.example:8090/v1", reachableServerURL(t, "http://127.0.0.1:8090/", "/v1")) +} diff --git a/go/core/test/e2e/interaction_test.go b/go/core/test/e2e/interaction_test.go index 8380ed3bfa..507c63901d 100644 --- a/go/core/test/e2e/interaction_test.go +++ b/go/core/test/e2e/interaction_test.go @@ -13,7 +13,6 @@ import ( "net/http/httptest" "net/http/httputil" "net/url" - "os" goruntime "runtime" "strconv" "strings" @@ -30,6 +29,7 @@ import ( kagenta2a "github.com/kagent-dev/kagent/go/api/a2a" apiv1alpha1 "github.com/kagent-dev/kagent/go/api/gen/kagent/api/v1alpha1" "github.com/kagent-dev/kagent/go/api/v1alpha3" + kagentenv "github.com/kagent-dev/kagent/go/core/pkg/env" "github.com/kagent-dev/mockllm" "github.com/kagent-dev/mockmcp" "google.golang.org/genproto/googleapis/rpc/errdetails" @@ -628,9 +628,9 @@ type sharedInteractionFixture struct { func interactionTarget(t *testing.T) string { t.Helper() - rawURL := os.Getenv("KAGENT_E2E_API_URL") + rawURL := kagentenv.E2EAPIURL.Get() if rawURL == "" { - rawURL = os.Getenv("KAGENT_API_URL") + rawURL = kagentenv.KagentAPIURL.Get() } if rawURL == "" { t.Skip("KAGENT_E2E_API_URL is not set") @@ -942,7 +942,7 @@ func reachableServerURL(t *testing.T, baseURL, path string) string { if err != nil { t.Fatalf("parse mock LLM address: %v", err) } - host := os.Getenv("KAGENT_LOCAL_HOST") + host := kagentenv.KagentLocalHost.Get() if host == "" { switch goruntime.GOOS { case "darwin": @@ -950,7 +950,7 @@ func reachableServerURL(t *testing.T, baseURL, path string) string { case "linux": host = "172.17.0.1" default: - t.Fatalf("KAGENT_LOCAL_HOST is required on %s", goruntime.GOOS) + t.Fatalf("KAGENT_E2E_LOCAL_HOST is required on %s", goruntime.GOOS) } } if net.ParseIP(host) != nil { diff --git a/go/core/test/e2e/main_test.go b/go/core/test/e2e/main_test.go index dc04c02ea9..83b57d19ea 100644 --- a/go/core/test/e2e/main_test.go +++ b/go/core/test/e2e/main_test.go @@ -7,6 +7,7 @@ import ( "os" "testing" + kagentenv "github.com/kagent-dev/kagent/go/core/pkg/env" "github.com/kagent-dev/kagent/go/pkg/logging" ctrl "sigs.k8s.io/controller-runtime" ) @@ -15,7 +16,7 @@ var suiteTraceReceiver *otlpTraceReceiver func TestMain(m *testing.M) { ctrl.SetLogger(logging.AsLogr(slog.Default())) - address := os.Getenv("KAGENT_E2E_OTLP_LISTEN_ADDRESS") + address := kagentenv.E2EOTLPListenAddress.Get() if address == "" { os.Exit(m.Run()) } diff --git a/go/core/test/e2e/manifests/lifecycle.yaml.tmpl b/go/core/test/e2e/manifests/lifecycle.yaml.tmpl index 94c95d5453..20780682cf 100644 --- a/go/core/test/e2e/manifests/lifecycle.yaml.tmpl +++ b/go/core/test/e2e/manifests/lifecycle.yaml.tmpl @@ -55,7 +55,7 @@ spec: image: ${KAGENT_E2E_RUNTIME_IMAGE} command: ["/app"] env: - - name: PORT + - name: KAGENT_PORT value: "80" substrate: workerPoolRef: diff --git a/go/core/test/e2e/sandbox_agent_test.go b/go/core/test/e2e/sandbox_agent_test.go index e0441236eb..914e038c87 100644 --- a/go/core/test/e2e/sandbox_agent_test.go +++ b/go/core/test/e2e/sandbox_agent_test.go @@ -3,7 +3,6 @@ package e2e_test import ( "context" "encoding/json" - "os" "testing" "time" @@ -12,6 +11,7 @@ import ( "github.com/google/uuid" apiv1alpha1 "github.com/kagent-dev/kagent/go/api/gen/kagent/api/v1alpha1" "github.com/kagent-dev/kagent/go/api/v1alpha3" + kagentenv "github.com/kagent-dev/kagent/go/core/pkg/env" "github.com/kagent-dev/mockllm" "github.com/stretchr/testify/require" "google.golang.org/grpc" @@ -23,7 +23,7 @@ import ( func TestSandboxAgentMCP(t *testing.T) { target := interactionTarget(t) - image := os.Getenv("KAGENT_E2E_RUNTIME_IMAGE") + image := kagentenv.E2ERuntimeImage.Get() require.NotEmpty(t, image, "KAGENT_E2E_RUNTIME_IMAGE must be set to a digest-pinned Go ADK image") f := newSandboxFixture(t) kube := interactionKubeClient(t) diff --git a/go/core/test/e2e/sandbox_test.go b/go/core/test/e2e/sandbox_test.go index 9f1009b2b9..a6c00fcb6c 100644 --- a/go/core/test/e2e/sandbox_test.go +++ b/go/core/test/e2e/sandbox_test.go @@ -6,7 +6,6 @@ import ( "encoding/base64" "encoding/json" "io" - "os" "testing" "time" @@ -16,6 +15,7 @@ import ( sandboxapi "github.com/kagent-dev/kagent/go/api/sandbox" "github.com/kagent-dev/kagent/go/api/v1alpha3" "github.com/kagent-dev/kagent/go/core/internal/substrate" + kagentenv "github.com/kagent-dev/kagent/go/core/pkg/env" "github.com/stretchr/testify/require" "google.golang.org/grpc" "google.golang.org/grpc/codes" @@ -44,15 +44,15 @@ type sandboxFixture struct { func newSandboxFixture(t *testing.T) *sandboxFixture { t.Helper() target := interactionTarget(t) - image := os.Getenv("KAGENT_E2E_RUNTIME_IMAGE") + image := kagentenv.E2ERuntimeImage.Get() require.NotEmpty(t, image, "KAGENT_E2E_RUNTIME_IMAGE must be set to a digest-pinned Go ADK image") - namespace := os.Getenv("KAGENT_E2E_SANDBOX_NAMESPACE") + namespace := kagentenv.E2ESandboxNamespace.Get() if namespace == "" { - namespace = "kagent" + namespace = kagentenv.E2ESandboxNamespace.DefaultValue() } - pool := os.Getenv("KAGENT_E2E_SANDBOX_WORKER_POOL") + pool := kagentenv.E2ESandboxWorkerPool.Get() if pool == "" { - pool = "kagent-default" + pool = kagentenv.E2ESandboxWorkerPool.DefaultValue() } conn, err := grpc.NewClient(target, grpc.WithTransportCredentials(insecure.NewCredentials())) require.NoError(t, err) diff --git a/go/core/test/e2e/tracing_test.go b/go/core/test/e2e/tracing_test.go index 8d301fc24b..38747b3622 100644 --- a/go/core/test/e2e/tracing_test.go +++ b/go/core/test/e2e/tracing_test.go @@ -8,7 +8,6 @@ import ( "fmt" "io" "net" - "os" "strings" "sync" "testing" @@ -19,6 +18,7 @@ import ( "github.com/agent-substrate/substrate/pkg/proto/ateapipb" "github.com/kagent-dev/kagent/go/api/v1alpha3" "github.com/kagent-dev/kagent/go/core/internal/substrate" + kagentenv "github.com/kagent-dev/kagent/go/core/pkg/env" "github.com/kagent-dev/kagent/go/pkg/telemetry" "github.com/kagent-dev/kagent/go/pkg/tracing" "github.com/stretchr/testify/require" @@ -72,7 +72,7 @@ func startOTLPTraceReceiver(t *testing.T) *otlpTraceReceiver { if suiteTraceReceiver != nil { return suiteTraceReceiver } - address := os.Getenv("KAGENT_E2E_OTLP_LISTEN_ADDRESS") + address := kagentenv.E2EOTLPListenAddress.Get() if address == "" { address = ":14317" } @@ -392,7 +392,7 @@ func requireTracingHarnesses(t *testing.T) { if err := kube.Get(t.Context(), ctrlclient.ObjectKey{Namespace: "kagent", Name: name}, &harness); apierrors.IsNotFound(err) { // A job dedicated to tracing must fail rather than silently skip both // cases when its fixtures are missing. - if strings.EqualFold(strings.TrimSpace(os.Getenv("KAGENT_E2E_REQUIRE_TRACING")), "true") { + if strings.EqualFold(strings.TrimSpace(kagentenv.E2ERequireTracing.Get()), "true") { t.Fatalf("tracing Harness %s is not installed", name) } t.Skip("dedicated tracing Harnesses are not installed") diff --git a/go/core/test/upgrade/config_test.go b/go/core/test/upgrade/config_test.go new file mode 100644 index 0000000000..ecac62b6ac --- /dev/null +++ b/go/core/test/upgrade/config_test.go @@ -0,0 +1,56 @@ +package upgrade + +import ( + "os" + "path/filepath" + "testing" + + kagentenv "github.com/kagent-dev/kagent/go/core/pkg/env" + "github.com/stretchr/testify/require" +) + +func TestLoadUpgradeEnv(t *testing.T) { + repoRoot := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(repoRoot, "Makefile"), nil, 0o600)) + + for _, tc := range []struct { + name string + values map[string]string + want upgradeEnv + }{ + { + name: "empty settings use defaults", + want: upgradeEnv{dockerRegistry: "localhost:5001", kindClusterName: "kagent", namespace: "kagent", kubeContext: "kind-kagent", openAIAPIKey: "fake"}, + }, + { + name: "context follows cluster name", + values: map[string]string{kagentenv.E2EKindClusterName.Name(): "custom"}, + want: upgradeEnv{dockerRegistry: "localhost:5001", kindClusterName: "custom", namespace: "kagent", kubeContext: "kind-custom", openAIAPIKey: "fake"}, + }, + { + name: "explicit settings override defaults", + values: map[string]string{ + kagentenv.E2EDockerRegistry.Name(): "registry.example", + kagentenv.E2EKindClusterName.Name(): "custom", + kagentenv.E2ENamespace.Name(): "agents", + kagentenv.E2EKubeContext.Name(): "explicit-context", + kagentenv.OpenAIAPIKey.Name(): "test-key", + }, + want: upgradeEnv{dockerRegistry: "registry.example", kindClusterName: "custom", namespace: "agents", kubeContext: "explicit-context", openAIAPIKey: "test-key"}, + }, + } { + t.Run(tc.name, func(t *testing.T) { + t.Setenv(kagentenv.E2ERepoRoot.Name(), repoRoot) + t.Setenv(kagentenv.E2EUpgradeFromVersion.Name(), "1.0.0") + t.Setenv(kagentenv.E2EVersion.Name(), "1.1.0") + for _, variable := range []kagentenv.StringVar{kagentenv.E2EDockerRegistry, kagentenv.E2EKindClusterName, kagentenv.E2ENamespace, kagentenv.E2EKubeContext, kagentenv.OpenAIAPIKey} { + t.Setenv(variable.Name(), tc.values[variable.Name()]) + } + want := tc.want + want.repoRoot = repoRoot + want.upgradeFromVersion = "1.0.0" + want.version = "1.1.0" + require.Equal(t, want, loadUpgradeEnv(t)) + }) + } +} diff --git a/go/core/test/upgrade/invoke_e2e_test.go b/go/core/test/upgrade/invoke_e2e_test.go index ec5e89563b..f4ab7f25ad 100644 --- a/go/core/test/upgrade/invoke_e2e_test.go +++ b/go/core/test/upgrade/invoke_e2e_test.go @@ -10,6 +10,7 @@ import ( "testing" "time" + kagentenv "github.com/kagent-dev/kagent/go/core/pkg/env" "github.com/stretchr/testify/require" ) @@ -44,7 +45,7 @@ func checkoutPreviousRelease(t *testing.T, env upgradeEnv) string { // matching-version tree (repo root for HEAD, the worktree for the prior // release). It port-forwards the controller for KAGENT_API_URL — re-established per // state, so it survives the controller being reinstalled between states — and -// relies on KAGENT_LOCAL_HOST (kind gateway IP, set by the make target) for the +// relies on KAGENT_E2E_LOCAL_HOST (kind gateway IP, set by the make target) for the // agent→host mock-LLM callback. label identifies the state in messages. func runInvokeE2E(t *testing.T, env upgradeEnv, treeGoDir, label string) { t.Helper() @@ -83,8 +84,8 @@ func upgradeE2EEnv(t *testing.T, env upgradeEnv, port int) []string { func requireInvokeEnvironment(t *testing.T, treeGoDir, label string) { t.Helper() - if os.Getenv("KAGENT_LOCAL_HOST") == "" { - t.Skipf("[%s] KAGENT_LOCAL_HOST is not set; run via make run-upgrade-tests", label) + if kagentenv.KagentLocalHost.Get() == "" { + t.Skipf("[%s] KAGENT_E2E_LOCAL_HOST is not set; run via make run-upgrade-tests", label) } if _, err := os.Stat(filepath.Join(treeGoDir, "go.mod")); err != nil { t.Fatalf("[%s] test tree %q is not usable: %v", label, treeGoDir, err) diff --git a/go/core/test/upgrade/rolling_upgrade_test.go b/go/core/test/upgrade/rolling_upgrade_test.go index a437ce0d44..4754a9149e 100644 --- a/go/core/test/upgrade/rolling_upgrade_test.go +++ b/go/core/test/upgrade/rolling_upgrade_test.go @@ -3,17 +3,17 @@ package upgrade import ( "context" "fmt" - "os" "strings" "testing" "time" + kagentenv "github.com/kagent-dev/kagent/go/core/pkg/env" "github.com/stretchr/testify/require" ) func TestRollingUpgradeCompatibility(t *testing.T) { - if os.Getenv("RUN_ROLLING_UPGRADE_TESTS") != "true" { - t.Skip("set RUN_ROLLING_UPGRADE_TESTS=true to run rolling upgrade tests") + if kagentenv.E2ERunRollingUpgradeTests.Get() != "true" { + t.Skip("set KAGENT_E2E_RUN_ROLLING_UPGRADE_TESTS=true to run rolling upgrade tests") } env := loadUpgradeEnv(t) diff --git a/go/core/test/upgrade/upgrade_test.go b/go/core/test/upgrade/upgrade_test.go index 34efa1644c..d65a781db0 100644 --- a/go/core/test/upgrade/upgrade_test.go +++ b/go/core/test/upgrade/upgrade_test.go @@ -14,6 +14,7 @@ import ( "testing" "time" + kagentenv "github.com/kagent-dev/kagent/go/core/pkg/env" migrations "github.com/kagent-dev/kagent/go/core/pkg/migrations" "github.com/stretchr/testify/require" ) @@ -45,8 +46,8 @@ type postgresMigrationState struct { } func TestUpgrade(t *testing.T) { - if os.Getenv("RUN_UPGRADE_TESTS") != "true" { - t.Skip("set RUN_UPGRADE_TESTS=true to run upgrade tests") + if kagentenv.E2ERunUpgradeTests.Get() != "true" { + t.Skip("set KAGENT_E2E_RUN_UPGRADE_TESTS=true to run upgrade tests") } env := loadUpgradeEnv(t) @@ -310,45 +311,45 @@ func installPreviousReleaseCommand(ctx context.Context, env upgradeEnv) *exec.Cm func loadUpgradeEnv(t *testing.T) upgradeEnv { t.Helper() - // Resolve the repo root. Prefer an explicit REPO_ROOT (set by the make + // Resolve the repo root. Prefer an explicit KAGENT_E2E_REPO_ROOT (set by the make // targets), otherwise ask git: this is location-independent, so moving this // file cannot silently point make at the wrong root, and git is already a hard // dependency of this flow (the make targets derive versions from git tags). - repoRoot := os.Getenv("REPO_ROOT") + repoRoot := kagentenv.E2ERepoRoot.Get() if repoRoot == "" { out, err := exec.CommandContext(t.Context(), "git", "rev-parse", "--show-toplevel").Output() - require.NoError(t, err, "resolve repo root via `git rev-parse --show-toplevel`; set REPO_ROOT to override") + require.NoError(t, err, "resolve repo root via `git rev-parse --show-toplevel`; set KAGENT_E2E_REPO_ROOT to override") repoRoot = strings.TrimSpace(string(out)) } // Fail clearly here rather than letting `make -C ...` fail with a // confusing "no rule to make target" if the root resolved wrong. _, err := os.Stat(filepath.Join(repoRoot, "Makefile")) - require.NoError(t, err, "resolved repo root %q has no Makefile; set REPO_ROOT", repoRoot) + require.NoError(t, err, "resolved repo root %q has no Makefile; set KAGENT_E2E_REPO_ROOT", repoRoot) - clusterName := envOrDefault("KIND_CLUSTER_NAME", "kagent") + clusterName := envOrDefault(kagentenv.E2EKindClusterName, kagentenv.E2EKindClusterName.DefaultValue()) return upgradeEnv{ repoRoot: repoRoot, - upgradeFromVersion: requireEnv(t, "UPGRADE_FROM_VERSION"), - version: requireEnv(t, "VERSION"), - dockerRegistry: envOrDefault("DOCKER_REGISTRY", "localhost:5001"), + upgradeFromVersion: requireEnv(t, kagentenv.E2EUpgradeFromVersion), + version: requireEnv(t, kagentenv.E2EVersion), + dockerRegistry: envOrDefault(kagentenv.E2EDockerRegistry, kagentenv.E2EDockerRegistry.DefaultValue()), kindClusterName: clusterName, - namespace: envOrDefault("NAMESPACE", "kagent"), - kubeContext: envOrDefault("KUBE_CONTEXT", "kind-"+clusterName), - openAIAPIKey: envOrDefault("OPENAI_API_KEY", "fake"), + namespace: envOrDefault(kagentenv.E2ENamespace, kagentenv.E2ENamespace.DefaultValue()), + kubeContext: envOrDefault(kagentenv.E2EKubeContext, "kind-"+clusterName), + openAIAPIKey: envOrDefault(kagentenv.OpenAIAPIKey, "fake"), } } -func requireEnv(t *testing.T, key string) string { +func requireEnv(t *testing.T, variable kagentenv.StringVar) string { t.Helper() - val := os.Getenv(key) - require.NotEmpty(t, val, "%s must be set", key) + val := variable.Get() + require.NotEmpty(t, val, "%s must be set", variable.Name()) return val } -func envOrDefault(key, fallback string) string { - if val := os.Getenv(key); val != "" { +func envOrDefault(variable kagentenv.StringVar, fallback string) string { + if val := variable.Get(); val != "" { return val } return fallback diff --git a/go/harness/claude/cmd/main.go b/go/harness/claude/cmd/main.go index 8e45566875..21db0c8dbf 100644 --- a/go/harness/claude/cmd/main.go +++ b/go/harness/claude/cmd/main.go @@ -34,7 +34,7 @@ func main() { os.Exit(1) } ctx := logging.IntoContext(context.Background(), logger) - if err := run(ctx, *check, os.Getenv, os.Environ()); err != nil { + if err := run(ctx, *check, os.Getenv, os.Environ()); err != nil { //nolint:forbidigo // Reads only controller-generated config and agent-card payloads. logger.ErrorContext(ctx, "claude harness stopped", "error", err) os.Exit(1) } diff --git a/go/harness/codex/cmd/main.go b/go/harness/codex/cmd/main.go index d041ffb380..0a2a7d6109 100644 --- a/go/harness/codex/cmd/main.go +++ b/go/harness/codex/cmd/main.go @@ -34,7 +34,7 @@ func main() { os.Exit(1) } ctx := logging.IntoContext(context.Background(), logger) - if err := run(ctx, *check, os.Getenv, os.Environ()); err != nil { + if err := run(ctx, *check, os.Getenv, os.Environ()); err != nil { //nolint:forbidigo // Reads only controller-generated config and agent-card payloads. logger.ErrorContext(ctx, "codex harness stopped", "error", err) os.Exit(1) } diff --git a/go/pkg/logging/logging.go b/go/pkg/logging/logging.go index 9ad74f3999..519c4d5815 100644 --- a/go/pkg/logging/logging.go +++ b/go/pkg/logging/logging.go @@ -5,9 +5,9 @@ import ( "context" "io" "log/slog" - "os" "github.com/go-logr/logr" + "github.com/kagent-dev/kagent/go/core/pkg/env" "go.opentelemetry.io/otel/trace" ) @@ -43,9 +43,9 @@ func (h traceHandler) WithGroup(name string) slog.Handler { return traceHandler{h.Handler.WithGroup(name)} } -// NewFromEnv returns a JSON logger using LOG_LEVEL, defaulting to info. +// NewFromEnv returns a JSON logger using KAGENT_LOG_LEVEL, defaulting to info. func NewFromEnv(w io.Writer) (*slog.Logger, error) { - return New(w, cmp.Or(os.Getenv("LOG_LEVEL"), "info")) + return New(w, cmp.Or(env.LogLevel.Get(), env.LogLevel.DefaultValue())) } // IntoContext stores logger in the logr context slot used by controller-runtime. diff --git a/go/pkg/logging/logging_test.go b/go/pkg/logging/logging_test.go index 58160625d1..d8206381ff 100644 --- a/go/pkg/logging/logging_test.go +++ b/go/pkg/logging/logging_test.go @@ -7,6 +7,7 @@ import ( "log/slog" "testing" + "github.com/kagent-dev/kagent/go/core/pkg/env" "go.opentelemetry.io/otel/trace" ) @@ -28,6 +29,37 @@ func TestNew(t *testing.T) { } } +func TestNewFromEnv(t *testing.T) { + for _, tt := range []struct { + name string + level string + want slog.Level + wantErr bool + }{ + {name: "empty defaults to info", want: slog.LevelInfo}, + {name: "debug", level: "debug", want: slog.LevelDebug}, + {name: "case insensitive", level: "WARN", want: slog.LevelWarn}, + {name: "invalid", level: "verbose", wantErr: true}, + } { + t.Run(tt.name, func(t *testing.T) { + t.Setenv(env.LogLevel.Name(), tt.level) + logger, err := NewFromEnv(&bytes.Buffer{}) + if tt.wantErr { + if err == nil { + t.Fatal("expected invalid log level to fail") + } + return + } + if err != nil { + t.Fatal(err) + } + if !logger.Enabled(t.Context(), tt.want) || logger.Enabled(t.Context(), tt.want-1) { + t.Fatalf("expected minimum log level %s", tt.want) + } + }) + } +} + func TestContextBridge(t *testing.T) { var output bytes.Buffer logger := slog.New(slog.NewJSONHandler(&output, nil)).With("component", "test") diff --git a/go/pkg/telemetry/telemetry.go b/go/pkg/telemetry/telemetry.go index adbdac74ab..5bff26f0eb 100644 --- a/go/pkg/telemetry/telemetry.go +++ b/go/pkg/telemetry/telemetry.go @@ -12,6 +12,7 @@ import ( "sync/atomic" "time" + "github.com/kagent-dev/kagent/go/core/pkg/env" "go.opentelemetry.io/contrib/exporters/autoexport" "go.opentelemetry.io/contrib/propagators/autoprop" "go.opentelemetry.io/otel" @@ -28,15 +29,12 @@ import ( // FlushTimeout bounds one ForceFlush. const FlushTimeout = 3 * time.Second -// Default is an SDK setting kagent applies when the environment leaves it unset. -type Default struct{ Name, Value string } - // Defaults keep baggage from leaking to tools and model providers, compress // exports, and give every runtime the same histogram buckets. -var Defaults = []Default{ - {Name: "OTEL_PROPAGATORS", Value: "tracecontext"}, - {Name: "OTEL_EXPORTER_OTLP_COMPRESSION", Value: "gzip"}, - {Name: "OTEL_EXPORTER_OTLP_METRICS_DEFAULT_HISTOGRAM_AGGREGATION", Value: "base2_exponential_bucket_histogram"}, +var Defaults = []env.StringVar{ + env.OtelPropagators, + env.OtelExporterOTLPCompression, + env.OtelExporterOTLPMetricsDefaultHistogramAggregation, } // WithDefaults adds each default that a child process environment leaves unset. @@ -44,10 +42,10 @@ func WithDefaults(environment []string) []string { result := slices.Clone(environment) for _, value := range Defaults { if !slices.ContainsFunc(environment, func(variable string) bool { - current, found := strings.CutPrefix(variable, value.Name+"=") + current, found := strings.CutPrefix(variable, value.Name()+"=") return found && current != "" }) { - result = append(result, value.Name+"="+value.Value) + result = append(result, value.Name()+"="+value.DefaultValue()) } } return result @@ -77,13 +75,13 @@ func Init(ctx context.Context, opts Options) (*Providers, error) { slog.Default().ErrorContext(context.Background(), "opentelemetry", "error", err) })) for _, value := range Defaults { - if os.Getenv(value.Name) == "" { - _ = os.Setenv(value.Name, value.Value) + if current, set := value.Lookup(); !set || current == "" { + _ = os.Setenv(value.Name(), value.DefaultValue()) } } otel.SetTextMapPropagator(autoprop.NewTextMapPropagator()) providers := &Providers{} - if strings.EqualFold(strings.TrimSpace(os.Getenv("OTEL_SDK_DISABLED")), "true") { + if strings.EqualFold(strings.TrimSpace(env.OtelSDKDisabled.Get()), "true") { return providers, nil } var errs []error diff --git a/go/pkg/telemetry/telemetry_test.go b/go/pkg/telemetry/telemetry_test.go index 5151cbe5e1..bd0ba654ba 100644 --- a/go/pkg/telemetry/telemetry_test.go +++ b/go/pkg/telemetry/telemetry_test.go @@ -138,7 +138,7 @@ func TestInitAppliesDefaultsWithoutBaggage(t *testing.T) { restoreGlobals(t) setExporters(t, "none", "none", "none") for _, value := range Defaults { - t.Setenv(value.Name, "") + t.Setenv(value.Name(), "") } t.Setenv("OTEL_EXPORTER_OTLP_COMPRESSION", "none") if _, err := Init(t.Context(), Options{}); err != nil { diff --git a/go/pkg/tracing/runtime.go b/go/pkg/tracing/runtime.go index c9e13e2cf1..b3b68062d2 100644 --- a/go/pkg/tracing/runtime.go +++ b/go/pkg/tracing/runtime.go @@ -130,14 +130,8 @@ func (r Runtime) NativeHarness() bool { return r == RuntimeClaude || r == RuntimeCodex } -// CaptureContentEnvironmentVariable is the OpenTelemetry GenAI instrumentation -// switch for recording prompts and responses. The controller renders it into -// every runtime from one setting, so the runtimes that read it directly and -// the ones that carry the decision in their compiled configuration agree. -const CaptureContentEnvironmentVariable = "OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT" - -// Values the controller renders for CaptureContentEnvironmentVariable. The ADK -// runtimes read the variable as a mode and treat a plain true as log records +// Values the controller renders for OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT. +// The ADK runtimes read the variable as a mode and treat a plain true as log records // only, so the span form is spelled out; false is what every runtime, // including kagent's own ADK payload capture, reads as off. const ( diff --git a/go/sandbox/guest/Dockerfile b/go/sandbox/guest/Dockerfile index cbf30e9dd7..7f32e0544a 100644 --- a/go/sandbox/guest/Dockerfile +++ b/go/sandbox/guest/Dockerfile @@ -5,6 +5,7 @@ ARG TARGETARCH WORKDIR /workspace COPY go.mod go.sum ./ +COPY core/pkg/env/ core/pkg/env/ COPY pkg/logging/ pkg/logging/ COPY sandbox/guest/cmd/ sandbox/guest/cmd/ diff --git a/go/sandbox/guest/README.md b/go/sandbox/guest/README.md index e49d3fb95c..1fa37d07e4 100644 --- a/go/sandbox/guest/README.md +++ b/go/sandbox/guest/README.md @@ -19,7 +19,7 @@ Sessions do not run this guest. - Process output is spooled under `/data/guest-logs`. - Both directories must be writable by the runtime user when mounting `/data`. - Flags `--listen`, `--workspace`, and `--log-dir` override these defaults. -- JSON logs go to stderr; `LOG_LEVEL` selects debug, info (default), warn, or error. +- JSON logs go to stderr; `KAGENT_LOG_LEVEL` selects debug, info (default), warn, or error. - SIGINT/SIGTERM stop serving and disconnect active RPCs, including output observers, before cleaning up the guest services and exiting. diff --git a/helm/kagent/templates/controller-configmap.yaml b/helm/kagent/templates/controller-configmap.yaml index 6c43d5237c..ba0551e02e 100644 --- a/helm/kagent/templates/controller-configmap.yaml +++ b/helm/kagent/templates/controller-configmap.yaml @@ -16,11 +16,11 @@ data: IMAGE_REPOSITORY: {{ .Values.controller.agentImage.repository | quote }} IMAGE_TAG: {{ coalesce .Values.controller.agentImage.tag .Values.tag .Chart.Version | quote }} {{- with .Values.controller.sandbox }} - SANDBOX_GUEST_IMAGE: {{ include "kagent.sandboxGuestImage" $ | quote }} - SANDBOX_CPU: {{ .cpu | quote }} - SANDBOX_MEMORY: {{ .memory | quote }} - SANDBOX_DEFAULT_TTL: {{ .defaultTTL | quote }} - SANDBOX_MAX_TTL: {{ .maxTTL | quote }} + KAGENT_SANDBOX_GUEST_IMAGE: {{ include "kagent.sandboxGuestImage" $ | quote }} + KAGENT_SANDBOX_CPU: {{ .cpu | quote }} + KAGENT_SANDBOX_MEMORY: {{ .memory | quote }} + KAGENT_SANDBOX_DEFAULT_TTL: {{ .defaultTTL | quote }} + KAGENT_SANDBOX_MAX_TTL: {{ .maxTTL | quote }} {{- end }} {{- with .Values.otel }} {{- if not (or .traces.enabled .metrics.enabled .logs.enabled $.Values.controller.metrics.enabled) }} @@ -57,8 +57,11 @@ data: {{- if .Values.proxy.url }} PROXY_URL: {{ .Values.proxy.url | quote }} {{- end }} - DATABASE_VECTOR_ENABLED: {{ .Values.database.postgres.vectorEnabled | quote }} - SKIP_MIGRATIONS: {{ .Values.database.postgres.skipMigrations | default false | quote }} + KAGENT_DATABASE_VECTOR_ENABLED: {{ .Values.database.postgres.vectorEnabled | quote }} + KAGENT_SKIP_MIGRATIONS: {{ .Values.database.postgres.skipMigrations | default false | quote }} + {{- with .Values.database.postgres.urlFile }} + KAGENT_POSTGRES_DATABASE_URL_FILE: {{ . | quote }} + {{- end }} {{- with .Values.database.postgres.pool }} {{- if and (hasKey . "maxConns") (ne .maxConns nil) }} DB_MAX_CONNS: {{ .maxConns | quote }} @@ -73,9 +76,22 @@ data: DB_MAX_CONN_LIFETIME: {{ .maxConnLifetime | quote }} {{- end }} {{- end }} - WATCH_NAMESPACES: {{ include "kagent.watchNamespaces" . | quote }} + KAGENT_WATCH_NAMESPACES: {{ include "kagent.watchNamespaces" . | quote }} MCP_EGRESS_PLAINTEXT: {{ .Values.controller.mcpEgressPlaintext | default false | quote }} {{- if .Values.controller.a2aClientTimeout }} KAGENT_A2A_CLIENT_TIMEOUT: {{ .Values.controller.a2aClientTimeout | quote }} {{- end }} - LOG_LEVEL: {{ .Values.controller.loglevel | quote }} + KAGENT_LOG_LEVEL: {{ .Values.controller.loglevel | quote }} + {{- if include "kagent.controller.metricsEnabled" . }} + KAGENT_METRICS_BIND_ADDRESS: {{ .Values.controller.metrics.bindAddress | quote }} + KAGENT_METRICS_SECURE: {{ .Values.controller.metrics.secureServing | quote }} + {{- end }} + KAGENT_GRPC_REFLECTION: {{ .Values.controller.grpc.reflection | quote }} + {{- if and .Values.controller.substrate .Values.controller.substrate.enabled }} + KAGENT_SUBSTRATE_ATE_API_ENDPOINT: {{ include "kagent.substrate.ateApiEndpoint" . | quote }} + KAGENT_SUBSTRATE_ATE_API_CA_FILE: /run/substrate-servicedns/trust-bundle.pem + KAGENT_SUBSTRATE_ATE_API_CLIENT_CERT_FILE: /run/substrate-podidentity/credential-bundle.pem + {{- with include "kagent.substrate.atenetRouterURL" . }} + KAGENT_SUBSTRATE_ATENET_ROUTER_URL: {{ . | quote }} + {{- end }} + {{- end }} diff --git a/helm/kagent/templates/controller-deployment.yaml b/helm/kagent/templates/controller-deployment.yaml index e5044d77d6..13631ebf4e 100644 --- a/helm/kagent/templates/controller-deployment.yaml +++ b/helm/kagent/templates/controller-deployment.yaml @@ -96,19 +96,13 @@ spec: valueFrom: fieldRef: fieldPath: spec.nodeName + {{- /* Kubelet expands pod identity here; envFrom values are not expanded. */}} - name: OTEL_RESOURCE_ATTRIBUTES value: {{ list (include "kagent.otel.resourceAttributes" .) "service.instance.id=$(K8S_POD_UID),k8s.pod.uid=$(K8S_POD_UID),k8s.pod.name=$(K8S_POD_NAME),k8s.namespace.name=$(KAGENT_NAMESPACE),k8s.node.name=$(K8S_NODE_NAME),k8s.container.name=controller" | compact | join "," | quote }} - - name: AUTH_MODE - value: {{ .Values.controller.auth.mode | default "unsecure" | quote }} - {{- if .Values.controller.auth.userIdClaim }} - - name: AUTH_USER_ID_CLAIM - value: {{ .Values.controller.auth.userIdClaim | quote }} - {{- end }} - {{- if .Values.database.postgres.urlFile }} - - name: POSTGRES_DATABASE_URL_FILE - value: {{ .Values.database.postgres.urlFile | quote }} - {{- else if .Values.database.postgres.url }} - - name: POSTGRES_DATABASE_URL + {{- /* Keep credentials and secret-dependent expansion out of the ConfigMap. */}} + {{- if not .Values.database.postgres.urlFile }} + {{- if .Values.database.postgres.url }} + - name: KAGENT_POSTGRES_DATABASE_URL value: {{ .Values.database.postgres.url | quote }} {{- else if .Values.database.postgres.bundled.enabled }} - name: POSTGRES_PASSWORD @@ -116,48 +110,15 @@ spec: secretKeyRef: name: {{ include "kagent.passwordSecretName" . }} key: POSTGRES_PASSWORD - - name: POSTGRES_DATABASE_URL + - name: KAGENT_POSTGRES_DATABASE_URL value: {{ printf "postgres://kagent:$(POSTGRES_PASSWORD)@%s.%s.svc:5432/kagent?sslmode=disable" (include "kagent.postgresqlServiceName" .) (include "kagent.namespace" .) | quote }} {{- else }} {{ fail "No database connection configured. Set database.postgres.url, database.postgres.urlFile, or enable database.postgres.bundled." }} {{- end }} - {{- if include "kagent.controller.metricsEnabled" . }} - - name: METRICS_BIND_ADDRESS - value: {{ .Values.controller.metrics.bindAddress | quote }} - - name: METRICS_SECURE - value: {{ .Values.controller.metrics.secureServing | quote }} - {{- end }} - - name: GRPC_MAX_MESSAGE_BYTES - value: {{ printf "%.0f" (.Values.controller.grpc.maxMessageBytes | float64) | quote }} - - name: GRPC_REFLECTION - value: {{ .Values.controller.grpc.reflection | quote }} - {{- with .Values.controller.grpc.tlsCertFile }} - - name: GRPC_TLS_CERT_FILE - value: {{ . | quote }} - {{- end }} - {{- with .Values.controller.grpc.tlsKeyFile }} - - name: GRPC_TLS_KEY_FILE - value: {{ . | quote }} {{- end }} {{- with .Values.controller.env }} {{- toYaml . | nindent 12 }} {{- end }} - {{- if and .Values.controller.substrate .Values.controller.substrate.enabled }} - - name: SUBSTRATE_ATE_API_ENDPOINT - value: {{ include "kagent.substrate.ateApiEndpoint" . | quote }} - - name: SUBSTRATE_ATE_API_CA_FILE - value: /run/substrate-servicedns/trust-bundle.pem - - name: SUBSTRATE_ATE_API_CLIENT_CERT_FILE - value: /run/substrate-podidentity/credential-bundle.pem - {{- with include "kagent.substrate.atenetRouterURL" . }} - - name: SUBSTRATE_ATENET_ROUTER_URL - value: {{ . | quote }} - {{- end }} - - name: SUBSTRATE_DEFAULT_WORKERPOOL_NAMESPACE - value: {{ .Values.controller.substrate.defaultWorkerPool.namespace | default (include "kagent.namespace" .) | quote }} - - name: SUBSTRATE_DEFAULT_WORKERPOOL_NAME - value: {{ .Values.controller.substrate.defaultWorkerPool.name | default (ternary .Values.substrateWorkerPool.name "" .Values.substrateWorkerPool.create) | quote }} - {{- end }} envFrom: - configMapRef: name: {{ include "kagent.fullname" . }}-controller diff --git a/helm/kagent/templates/postgresql-configmap.yaml b/helm/kagent/templates/postgresql-configmap.yaml new file mode 100644 index 0000000000..b1b144b7b8 --- /dev/null +++ b/helm/kagent/templates/postgresql-configmap.yaml @@ -0,0 +1,14 @@ +{{- if .Values.database.postgres.bundled.enabled }} +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "kagent.postgresqlServiceName" . }} + namespace: {{ include "kagent.namespace" . }} + labels: + {{- include "kagent.labels" . | nindent 4 }} + app.kubernetes.io/component: database +data: + POSTGRES_DB: "kagent" + POSTGRES_USER: "kagent" + PGDATA: /var/lib/postgresql/data/pgdata +{{- end }} diff --git a/helm/kagent/templates/postgresql.yaml b/helm/kagent/templates/postgresql.yaml index 0f735c1aa2..a49c9cfb7f 100644 --- a/helm/kagent/templates/postgresql.yaml +++ b/helm/kagent/templates/postgresql.yaml @@ -47,6 +47,8 @@ spec: app.kubernetes.io/component: database template: metadata: + annotations: + checksum/configmap: {{ include (print $.Template.BasePath "/postgresql-configmap.yaml") . | sha256sum }} labels: {{- /* extra pod labels: component merged over global, selector labels applied last so they can never be overridden */}} @@ -84,17 +86,14 @@ spec: containerPort: 5432 protocol: TCP env: - - name: POSTGRES_DB - value: "kagent" - - name: POSTGRES_USER - value: "kagent" - name: POSTGRES_PASSWORD valueFrom: secretKeyRef: name: {{ include "kagent.passwordSecretName" . }} key: POSTGRES_PASSWORD - - name: PGDATA - value: /var/lib/postgresql/data/pgdata + envFrom: + - configMapRef: + name: {{ $fullname }} livenessProbe: exec: command: diff --git a/helm/kagent/templates/toolserver-api.yaml b/helm/kagent/templates/toolserver-api.yaml index bdac820fa6..6634235060 100644 --- a/helm/kagent/templates/toolserver-api.yaml +++ b/helm/kagent/templates/toolserver-api.yaml @@ -8,6 +8,6 @@ metadata: spec: description: "Kagent API tools for Sessions, checkpoints, and sandboxes" protocol: STREAMABLE_HTTP - url: "{{ if .Values.controller.grpc.tlsCertFile }}https{{ else }}http{{ end }}://{{ include "kagent.controllerServiceAuthority" . }}/mcp" + url: "http://{{ include "kagent.controllerServiceAuthority" . }}/mcp" timeout: 30s sseReadTimeout: 5m0s diff --git a/helm/kagent/templates/ui-configmap.yaml b/helm/kagent/templates/ui-configmap.yaml new file mode 100644 index 0000000000..8613a25071 --- /dev/null +++ b/helm/kagent/templates/ui-configmap.yaml @@ -0,0 +1,21 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "kagent.fullname" . }}-ui + namespace: {{ include "kagent.namespace" . }} + labels: + {{- include "kagent.labels" . | nindent 4 }} +data: + KAGENT_UI_API_BASE_URL: {{ .Values.ui.publicBackendUrl | default "/api" | quote }} + {{- with .Values.ui.basePath }} + {{- /* Keep in sync with ui/scripts/init.sh. nginx strips the prefix, so it must not start with a path nginx serves. */}} + {{- if or (not (regexMatch "^(/[A-Za-z0-9._~-]+)*/?$" .)) (regexMatch "(^|/)\\.\\.?(/|$)" .) }}{{ fail "ui.basePath must be a path like /ui" }}{{ end }} + {{- if regexMatch "^/(api|a2a|assets|health|env-config\\.js|index\\.html|mockServiceWorker\\.js)(/|$)" . }}{{ fail "ui.basePath cannot start with a path nginx serves, such as /api or /assets" }}{{ end }} + KAGENT_UI_BASE_PATH: {{ . | quote }} + {{- end }} + {{- if .Values.ui.auth }} + KAGENT_UI_SSO_REDIRECT_PATH: {{ .Values.ui.auth.ssoRedirectPath | default "/oauth2/start" | quote }} + {{- end }} + {{- if .Values.ui.streamTimeoutSeconds }} + KAGENT_UI_STREAM_TIMEOUT_MS: {{ mul (int .Values.ui.streamTimeoutSeconds) 1000 | quote }} + {{- end }} diff --git a/helm/kagent/templates/ui-deployment.yaml b/helm/kagent/templates/ui-deployment.yaml index 288adc7bde..200dfc0831 100644 --- a/helm/kagent/templates/ui-deployment.yaml +++ b/helm/kagent/templates/ui-deployment.yaml @@ -18,6 +18,7 @@ spec: template: metadata: annotations: + checksum/configmap: {{ include (print $.Template.BasePath "/ui-configmap.yaml") . | sha256sum }} checksum/ui-config: {{ include (print $.Template.BasePath "/ui-nginx-configmap.yaml") . | sha256sum }} {{- with .Values.ui.podAnnotations | default .Values.podAnnotations }} {{- toYaml . | nindent 8 }} @@ -65,30 +66,13 @@ spec: {{- end }} image: {{ include "kagent.uiImage" . | quote }} imagePullPolicy: {{ include "kagent.imagePullPolicy" (dict "root" . "local" .Values.ui.image.pullPolicy) }} - {{- /* The UI is a static bundle; these are read by init.sh, which - renders them into the config.json the browser fetches at - startup. Nothing here is consumed by a server process. */}} + {{- with .Values.ui.env }} env: - - name: KAGENT_API_BASE_URL - value: {{ .Values.ui.publicBackendUrl | default "/api" | quote }} - {{- with .Values.ui.basePath }} - {{- /* Keep in sync with ui/scripts/init.sh. nginx strips the prefix, so it must not start with a path nginx serves. */}} - {{- if or (not (regexMatch "^(/[A-Za-z0-9._~-]+)*/?$" .)) (regexMatch "(^|/)\\.\\.?(/|$)" .) }}{{ fail "ui.basePath must be a path like /ui" }}{{ end }} - {{- if regexMatch "^/(api|a2a|assets|health|env-config\\.js|index\\.html|mockServiceWorker\\.js)(/|$)" . }}{{ fail "ui.basePath cannot start with a path nginx serves, such as /api or /assets" }}{{ end }} - - name: KAGENT_UI_BASE_PATH - value: {{ . | quote }} - {{- end }} - {{- if .Values.ui.auth }} - - name: SSO_REDIRECT_PATH - value: {{ .Values.ui.auth.ssoRedirectPath | default "/oauth2/start" | quote }} - {{- end }} - {{- if .Values.ui.streamTimeoutSeconds }} - - name: KAGENT_STREAM_TIMEOUT_MS - value: {{ mul (int .Values.ui.streamTimeoutSeconds) 1000 | quote }} - {{- end }} - {{- with .Values.ui.env }} - {{- toYaml . | nindent 12 }} - {{- end }} + {{- toYaml . | nindent 12 }} + {{- end }} + envFrom: + - configMapRef: + name: {{ include "kagent.fullname" . }}-ui ports: - name: http containerPort: {{ .Values.ui.service.ports.targetPort }} diff --git a/helm/kagent/tests/controller-configmap_test.yaml b/helm/kagent/tests/controller-configmap_test.yaml index f1f05967d5..b8507aca8f 100644 --- a/helm/kagent/tests/controller-configmap_test.yaml +++ b/helm/kagent/tests/controller-configmap_test.yaml @@ -7,11 +7,11 @@ tests: - isSubset: path: data content: - SANDBOX_GUEST_IMAGE: "" - SANDBOX_CPU: "1" - SANDBOX_MEMORY: 1Gi - SANDBOX_DEFAULT_TTL: 1h - SANDBOX_MAX_TTL: 24h + KAGENT_SANDBOX_GUEST_IMAGE: "" + KAGENT_SANDBOX_CPU: "1" + KAGENT_SANDBOX_MEMORY: 1Gi + KAGENT_SANDBOX_DEFAULT_TTL: 1h + KAGENT_SANDBOX_MAX_TTL: 24h - it: applies sandbox policy overrides through the ConfigMap set: @@ -23,10 +23,10 @@ tests: - isSubset: path: data content: - SANDBOX_CPU: "2" - SANDBOX_MEMORY: 2Gi - SANDBOX_DEFAULT_TTL: 30m - SANDBOX_MAX_TTL: 2h + KAGENT_SANDBOX_CPU: "2" + KAGENT_SANDBOX_MEMORY: 2Gi + KAGENT_SANDBOX_DEFAULT_TTL: 30m + KAGENT_SANDBOX_MAX_TTL: 2h - it: uses the guest image registry repository and digest set: @@ -35,7 +35,7 @@ tests: controller.sandbox.guestImage.digest: sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa asserts: - equal: - path: data.SANDBOX_GUEST_IMAGE + path: data.KAGENT_SANDBOX_GUEST_IMAGE value: registry.example.com/team/guest@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa - it: uses the top-level registry when the guest registry is unset @@ -45,7 +45,7 @@ tests: controller.sandbox.guestImage.digest: sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa asserts: - equal: - path: data.SANDBOX_GUEST_IMAGE + path: data.KAGENT_SANDBOX_GUEST_IMAGE value: registry.example.com/kagent-dev/kagent/sandbox-guest@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa - it: respects the global mirror without applying the global tag to the guest @@ -56,7 +56,7 @@ tests: global.imageRegistry: mirror.example.com/ asserts: - equal: - path: data.SANDBOX_GUEST_IMAGE + path: data.KAGENT_SANDBOX_GUEST_IMAGE value: mirror.example.com/kagent-dev/kagent/sandbox-guest@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa - it: preserves a custom guest repository under the global mirror @@ -68,5 +68,5 @@ tests: global.imageRegistry: mirror.example.com asserts: - equal: - path: data.SANDBOX_GUEST_IMAGE + path: data.KAGENT_SANDBOX_GUEST_IMAGE value: mirror.example.com/team/guest@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa diff --git a/helm/kagent/tests/controller-deployment_test.yaml b/helm/kagent/tests/controller-deployment_test.yaml index f23da10f0d..9f4d6a2016 100644 --- a/helm/kagent/tests/controller-deployment_test.yaml +++ b/helm/kagent/tests/controller-deployment_test.yaml @@ -4,6 +4,30 @@ templates: - controller-configmap.yaml - postgresql-secret.yaml tests: + - it: keeps chart defaults in the ConfigMap and pod identity and credentials in env + template: controller-deployment.yaml + set: + controller.substrate.enabled: true + controller.metrics.enabled: true + asserts: + - contains: + path: spec.template.spec.containers[0].envFrom + content: + configMapRef: + name: RELEASE-NAME-controller + - matchRegex: + path: spec.template.spec.containers[0].env[*].name + pattern: "^(KAGENT_NAMESPACE|K8S_POD_NAME|K8S_POD_UID|K8S_NODE_NAME|OTEL_RESOURCE_ATTRIBUTES|POSTGRES_PASSWORD|KAGENT_POSTGRES_DATABASE_URL)$" + - notExists: + path: data.KAGENT_POSTGRES_DATABASE_URL + template: controller-configmap.yaml + - notExists: + path: data.POSTGRES_PASSWORD + template: controller-configmap.yaml + - notExists: + path: data.OTEL_RESOURCE_ATTRIBUTES + template: controller-configmap.yaml + - it: should render controller deployment with default values template: controller-deployment.yaml asserts: @@ -77,19 +101,12 @@ tests: path: spec.template.spec.containers[0].ports[0].containerPort value: 8083 - - it: should configure the gRPC server - template: controller-deployment.yaml + - it: should disable gRPC reflection by default + template: controller-configmap.yaml asserts: - - contains: - path: spec.template.spec.containers[0].env - content: - name: GRPC_MAX_MESSAGE_BYTES - value: "16777216" - - contains: - path: spec.template.spec.containers[0].env - content: - name: GRPC_REFLECTION - value: "false" + - equal: + path: data.KAGENT_GRPC_REFLECTION + value: "false" - it: should set global annotations on deployment template: controller-deployment.yaml @@ -128,21 +145,18 @@ tests: enabled: true ateApiEndpoint: "dns:///api.ate-system.svc:443" asserts: - - contains: - path: spec.template.spec.containers[0].env - content: - name: SUBSTRATE_ATE_API_ENDPOINT - value: "dns:///api.ate-system.svc:443" - - contains: - path: spec.template.spec.containers[0].env - content: - name: SUBSTRATE_ATE_API_CA_FILE - value: /run/substrate-servicedns/trust-bundle.pem - - contains: - path: spec.template.spec.containers[0].env - content: - name: SUBSTRATE_ATE_API_CLIENT_CERT_FILE - value: /run/substrate-podidentity/credential-bundle.pem + - equal: + path: data.KAGENT_SUBSTRATE_ATE_API_ENDPOINT + value: "dns:///api.ate-system.svc:443" + template: controller-configmap.yaml + - equal: + path: data.KAGENT_SUBSTRATE_ATE_API_CA_FILE + value: /run/substrate-servicedns/trust-bundle.pem + template: controller-configmap.yaml + - equal: + path: data.KAGENT_SUBSTRATE_ATE_API_CLIENT_CERT_FILE + value: /run/substrate-podidentity/credential-bundle.pem + template: controller-configmap.yaml - equal: path: spec.template.spec.volumes[0].name value: substrate-servicedns @@ -360,7 +374,7 @@ tests: loglevel: "debug" asserts: - equal: - path: data.LOG_LEVEL + path: data.KAGENT_LOG_LEVEL value: "debug" - it: should configure watch namespaces template: controller-configmap.yaml @@ -371,7 +385,7 @@ tests: - namespace-2 asserts: - equal: - path: data.WATCH_NAMESPACES + path: data.KAGENT_WATCH_NAMESPACES value: "namespace-1,namespace-2" - it: should derive watch namespaces from rbac.namespaces when controller.watchNamespaces is unset @@ -383,10 +397,10 @@ tests: - ns2 asserts: - equal: - path: data.WATCH_NAMESPACES + path: data.KAGENT_WATCH_NAMESPACES value: "NAMESPACE,ns2" - - it: controller.watchNamespaces should override rbac.namespaces for WATCH_NAMESPACES + - it: controller.watchNamespaces should override rbac.namespaces for KAGENT_WATCH_NAMESPACES template: controller-configmap.yaml set: controller: @@ -397,7 +411,7 @@ tests: - NAMESPACE asserts: - equal: - path: data.WATCH_NAMESPACES + path: data.KAGENT_WATCH_NAMESPACES value: "explicit-ns" - it: should derive watch namespaces from global.watchNamespaces, install namespace included @@ -408,7 +422,7 @@ tests: - team-a asserts: - equal: - path: data.WATCH_NAMESPACES + path: data.KAGENT_WATCH_NAMESPACES value: "NAMESPACE,team-a" - it: should watch all namespaces when an explicit empty rbac.namespaces overrides the global @@ -421,7 +435,7 @@ tests: - team-a asserts: - equal: - path: data.WATCH_NAMESPACES + path: data.KAGENT_WATCH_NAMESPACES value: "" - it: should set podAnnotations @@ -569,7 +583,7 @@ tests: # Database Configuration Tests # ============================================================================= - - it: should set POSTGRES_PASSWORD from secret and POSTGRES_DATABASE_URL with bundled connection string by default + - it: should set POSTGRES_PASSWORD from secret and KAGENT_POSTGRES_DATABASE_URL with bundled connection string by default template: controller-deployment.yaml asserts: - contains: @@ -583,10 +597,10 @@ tests: - contains: path: spec.template.spec.containers[0].env content: - name: POSTGRES_DATABASE_URL + name: KAGENT_POSTGRES_DATABASE_URL value: "postgres://kagent:$(POSTGRES_PASSWORD)@RELEASE-NAME-postgresql.NAMESPACE.svc:5432/kagent?sslmode=disable" - - it: should set POSTGRES_DATABASE_URL with external url when url is set + - it: should set KAGENT_POSTGRES_DATABASE_URL with external url when url is set template: controller-deployment.yaml set: database: @@ -596,34 +610,33 @@ tests: - contains: path: spec.template.spec.containers[0].env content: - name: POSTGRES_DATABASE_URL + name: KAGENT_POSTGRES_DATABASE_URL value: "postgres://user:pass@external-host:5432/db" - - it: should set POSTGRES_DATABASE_URL_FILE and omit POSTGRES_PASSWORD when urlFile is set + - it: should set KAGENT_POSTGRES_DATABASE_URL_FILE and omit POSTGRES_PASSWORD when urlFile is set template: controller-deployment.yaml set: database: postgres: urlFile: "/var/secrets/db-url" asserts: - - contains: - path: spec.template.spec.containers[0].env - content: - name: POSTGRES_DATABASE_URL_FILE - value: "/var/secrets/db-url" + - equal: + path: data.KAGENT_POSTGRES_DATABASE_URL_FILE + value: "/var/secrets/db-url" + template: controller-configmap.yaml - notContains: path: spec.template.spec.containers[0].env content: name: POSTGRES_PASSWORD - - it: should set DATABASE_VECTOR_ENABLED to false by default + - it: should set KAGENT_DATABASE_VECTOR_ENABLED to false by default template: controller-configmap.yaml asserts: - equal: - path: data.DATABASE_VECTOR_ENABLED + path: data.KAGENT_DATABASE_VECTOR_ENABLED value: "false" - - it: should set DATABASE_VECTOR_ENABLED to true when enabled + - it: should set KAGENT_DATABASE_VECTOR_ENABLED to true when enabled template: controller-configmap.yaml set: database: @@ -631,7 +644,7 @@ tests: vectorEnabled: true asserts: - equal: - path: data.DATABASE_VECTOR_ENABLED + path: data.KAGENT_DATABASE_VECTOR_ENABLED value: "true" - it: should not set DB pool env vars by default @@ -670,11 +683,11 @@ tests: path: data.DB_MAX_CONN_LIFETIME value: "10m" - - it: should not set POSTGRES_DATABASE_URL in configmap + - it: should not set KAGENT_POSTGRES_DATABASE_URL in configmap template: controller-configmap.yaml asserts: - notExists: - path: data.POSTGRES_DATABASE_URL + path: data.KAGENT_POSTGRES_DATABASE_URL - it: should not set POSTGRES_PASSWORD when url is set and bundled is enabled template: controller-deployment.yaml @@ -704,7 +717,7 @@ tests: content: name: POSTGRES_PASSWORD - - it: should set POSTGRES_DATABASE_URL_FILE and omit POSTGRES_PASSWORD when urlFile and bundled are both enabled + - it: should set KAGENT_POSTGRES_DATABASE_URL_FILE and omit POSTGRES_PASSWORD when urlFile and bundled are both enabled template: controller-deployment.yaml set: database: @@ -713,11 +726,10 @@ tests: bundled: enabled: true asserts: - - contains: - path: spec.template.spec.containers[0].env - content: - name: POSTGRES_DATABASE_URL_FILE - value: "/var/secrets/db-url" + - equal: + path: data.KAGENT_POSTGRES_DATABASE_URL_FILE + value: "/var/secrets/db-url" + template: controller-configmap.yaml - notContains: path: spec.template.spec.containers[0].env content: @@ -725,9 +737,9 @@ tests: - notContains: path: spec.template.spec.containers[0].env content: - name: POSTGRES_DATABASE_URL + name: KAGENT_POSTGRES_DATABASE_URL - - it: should set external POSTGRES_DATABASE_URL and omit POSTGRES_PASSWORD when url and bundled are both enabled + - it: should set external KAGENT_POSTGRES_DATABASE_URL and omit POSTGRES_PASSWORD when url and bundled are both enabled template: controller-deployment.yaml set: database: @@ -739,7 +751,7 @@ tests: - contains: path: spec.template.spec.containers[0].env content: - name: POSTGRES_DATABASE_URL + name: KAGENT_POSTGRES_DATABASE_URL value: "postgres://user:pass@external-host:5432/db" - notContains: path: spec.template.spec.containers[0].env @@ -830,18 +842,12 @@ tests: any: true - it: should not set metrics env vars by default - template: controller-deployment.yaml + template: controller-configmap.yaml asserts: - - notContains: - path: spec.template.spec.containers[0].env - content: - name: METRICS_BIND_ADDRESS - any: true - - notContains: - path: spec.template.spec.containers[0].env - content: - name: METRICS_SECURE - any: true + - notExists: + path: data.KAGENT_METRICS_BIND_ADDRESS + - notExists: + path: data.KAGENT_METRICS_SECURE - it: should expose metrics container port when enabled template: controller-deployment.yaml @@ -856,32 +862,26 @@ tests: protocol: TCP - it: should set metrics env vars when enabled - template: controller-deployment.yaml + template: controller-configmap.yaml set: controller.metrics.enabled: true asserts: - - contains: - path: spec.template.spec.containers[0].env - content: - name: METRICS_BIND_ADDRESS - value: ":8443" - - contains: - path: spec.template.spec.containers[0].env - content: - name: METRICS_SECURE - value: "true" + - equal: + path: data.KAGENT_METRICS_BIND_ADDRESS + value: ":8443" + - equal: + path: data.KAGENT_METRICS_SECURE + value: "true" - it: should reflect insecure serving in env - template: controller-deployment.yaml + template: controller-configmap.yaml set: controller.metrics.enabled: true controller.metrics.secureServing: false asserts: - - contains: - path: spec.template.spec.containers[0].env - content: - name: METRICS_SECURE - value: "false" + - equal: + path: data.KAGENT_METRICS_SECURE + value: "false" - it: should derive metrics container port from bindAddress template: controller-deployment.yaml @@ -889,11 +889,10 @@ tests: controller.metrics.enabled: true controller.metrics.bindAddress: ":9443" asserts: - - contains: - path: spec.template.spec.containers[0].env - content: - name: METRICS_BIND_ADDRESS - value: ":9443" + - equal: + path: data.KAGENT_METRICS_BIND_ADDRESS + value: ":9443" + template: controller-configmap.yaml - contains: path: spec.template.spec.containers[0].ports content: @@ -933,16 +932,12 @@ tests: controller.metrics.enabled: true controller.metrics.bindAddress: "0" asserts: - - notContains: - path: spec.template.spec.containers[0].env - content: - name: METRICS_BIND_ADDRESS - any: true - - notContains: - path: spec.template.spec.containers[0].env - content: - name: METRICS_SECURE - any: true + - notExists: + path: data.KAGENT_METRICS_BIND_ADDRESS + template: controller-configmap.yaml + - notExists: + path: data.KAGENT_METRICS_SECURE + template: controller-configmap.yaml - notContains: path: spec.template.spec.containers[0].ports content: @@ -955,16 +950,12 @@ tests: controller.metrics.enabled: true controller.metrics.bindAddress: "" asserts: - - notContains: - path: spec.template.spec.containers[0].env - content: - name: METRICS_BIND_ADDRESS - any: true - - notContains: - path: spec.template.spec.containers[0].env - content: - name: METRICS_SECURE - any: true + - notExists: + path: data.KAGENT_METRICS_BIND_ADDRESS + template: controller-configmap.yaml + - notExists: + path: data.KAGENT_METRICS_SECURE + template: controller-configmap.yaml - notContains: path: spec.template.spec.containers[0].ports content: @@ -976,16 +967,15 @@ tests: set: controller.metrics.enabled: true controller.env: - - name: METRICS_BIND_ADDRESS + - name: KAGENT_METRICS_BIND_ADDRESS value: "0" asserts: + - equal: + path: data.KAGENT_METRICS_BIND_ADDRESS + value: ":8443" + template: controller-configmap.yaml - contains: path: spec.template.spec.containers[0].env content: - name: METRICS_BIND_ADDRESS - value: ":8443" - - contains: - path: spec.template.spec.containers[0].env - content: - name: METRICS_BIND_ADDRESS + name: KAGENT_METRICS_BIND_ADDRESS value: "0" diff --git a/helm/kagent/tests/global-values_test.yaml b/helm/kagent/tests/global-values_test.yaml index da360e223d..8552615b1a 100644 --- a/helm/kagent/tests/global-values_test.yaml +++ b/helm/kagent/tests/global-values_test.yaml @@ -5,9 +5,10 @@ templates: - controller-configmap.yaml - rbac/getter-role.yaml - substrate-workerpool.yaml - # ui-deployment.yaml includes ui-nginx-configmap.yaml, and helm-unittest can - # only resolve cross-template includes when both are listed here. + # Helm-unittest requires the UI's ConfigMaps to be listed here so it can + # resolve the Deployment's checksum includes. - ui-deployment.yaml + - ui-configmap.yaml - ui-nginx-configmap.yaml tests: - it: should override every image registry from global.imageRegistry diff --git a/helm/kagent/tests/pod-labels_test.yaml b/helm/kagent/tests/pod-labels_test.yaml index edc5c03b6e..cd26707e82 100644 --- a/helm/kagent/tests/pod-labels_test.yaml +++ b/helm/kagent/tests/pod-labels_test.yaml @@ -3,8 +3,10 @@ templates: - controller-deployment.yaml - controller-configmap.yaml - postgresql.yaml + - postgresql-configmap.yaml - postgresql-secret.yaml - ui-deployment.yaml + - ui-configmap.yaml - ui-nginx-configmap.yaml tests: - it: should not add extra pod labels by default (controller) diff --git a/helm/kagent/tests/postgresql_test.yaml b/helm/kagent/tests/postgresql_test.yaml index 5d6d40a018..4d4c088d36 100644 --- a/helm/kagent/tests/postgresql_test.yaml +++ b/helm/kagent/tests/postgresql_test.yaml @@ -1,8 +1,39 @@ suite: test postgresql templates: + - postgresql-configmap.yaml - postgresql.yaml - postgresql-secret.yaml tests: + - it: loads non-secret database settings from the ConfigMap + template: postgresql.yaml + asserts: + - contains: + path: spec.template.spec.containers[0].envFrom + content: + configMapRef: + name: RELEASE-NAME-postgresql + documentIndex: 2 + - lengthEqual: + path: spec.template.spec.containers[0].env + count: 1 + documentIndex: 2 + - matchRegex: + path: spec.template.metadata.annotations["checksum/configmap"] + pattern: "^[a-f0-9]{64}$" + documentIndex: 2 + - notExists: + path: data.POSTGRES_PASSWORD + template: postgresql-configmap.yaml + + - it: omits the database ConfigMap when bundled postgres is disabled + template: postgresql-configmap.yaml + set: + database.postgres.bundled.enabled: false + database.postgres.url: postgres://user:pass@external-host:5432/db + asserts: + - hasDocuments: + count: 0 + # ============================================================================= # bundled mode (default — url and urlFile both empty, bundled.enabled true) # ============================================================================= @@ -153,29 +184,21 @@ tests: key: POSTGRES_PASSWORD - it: should set POSTGRES_DB and POSTGRES_USER to hardcoded values - template: postgresql.yaml - documentIndex: 2 + template: postgresql-configmap.yaml asserts: - - contains: - path: spec.template.spec.containers[0].env - content: - name: POSTGRES_DB - value: "kagent" - - contains: - path: spec.template.spec.containers[0].env - content: - name: POSTGRES_USER - value: "kagent" + - equal: + path: data.POSTGRES_DB + value: "kagent" + - equal: + path: data.POSTGRES_USER + value: "kagent" - it: should set PGDATA env var - template: postgresql.yaml - documentIndex: 2 + template: postgresql-configmap.yaml asserts: - - contains: - path: spec.template.spec.containers[0].env - content: - name: PGDATA - value: /var/lib/postgresql/data/pgdata + - equal: + path: data.PGDATA + value: /var/lib/postgresql/data/pgdata - it: should have liveness and readiness probes template: postgresql.yaml diff --git a/helm/kagent/tests/security-context_test.yaml b/helm/kagent/tests/security-context_test.yaml index fc654afdfc..b3583142a7 100644 --- a/helm/kagent/tests/security-context_test.yaml +++ b/helm/kagent/tests/security-context_test.yaml @@ -5,6 +5,7 @@ templates: - controller-deployment.yaml - postgresql-secret.yaml - ui-deployment.yaml + - ui-configmap.yaml - ui-nginx-configmap.yaml tests: # ============================================================================= diff --git a/helm/kagent/tests/toolserver-api_test.yaml b/helm/kagent/tests/toolserver-api_test.yaml index ce85e0d7fd..44901a649b 100644 --- a/helm/kagent/tests/toolserver-api_test.yaml +++ b/helm/kagent/tests/toolserver-api_test.yaml @@ -59,15 +59,3 @@ tests: - equal: path: spec.url value: http://staging-control-controller.agents.svc:8083/mcp - - - it: uses HTTPS when TLS is configured on the controller - release: - name: kagent - namespace: kagent - set: - controller.grpc.tlsCertFile: /certs/tls.crt - controller.grpc.tlsKeyFile: /certs/tls.key - asserts: - - equal: - path: spec.url - value: https://kagent-controller.kagent.svc:8083/mcp diff --git a/helm/kagent/tests/ui-deployment_test.yaml b/helm/kagent/tests/ui-deployment_test.yaml index ed3bc5f438..5240f707ca 100644 --- a/helm/kagent/tests/ui-deployment_test.yaml +++ b/helm/kagent/tests/ui-deployment_test.yaml @@ -1,8 +1,53 @@ suite: test ui deployment templates: + - ui-configmap.yaml - ui-deployment.yaml - ui-nginx-configmap.yaml tests: + - it: loads browser settings from the ConfigMap and rolls out on changes + template: ui-deployment.yaml + asserts: + - contains: + path: spec.template.spec.containers[0].envFrom + content: + configMapRef: + name: RELEASE-NAME-ui + - notExists: + path: spec.template.spec.containers[0].env + - matchRegex: + path: spec.template.metadata.annotations["checksum/configmap"] + pattern: "^[a-f0-9]{64}$" + + - it: preserves explicit env overrides and Secret references + template: ui-deployment.yaml + set: + ui.env: + - name: KAGENT_UI_API_BASE_URL + value: /custom-api + - name: CUSTOM_TOKEN + valueFrom: + secretKeyRef: + name: ui-token + key: token + asserts: + - equal: + path: spec.template.spec.containers[0].env + value: + - name: KAGENT_UI_API_BASE_URL + value: /custom-api + - name: CUSTOM_TOKEN + valueFrom: + secretKeyRef: + name: ui-token + key: token + - equal: + path: data.KAGENT_UI_API_BASE_URL + value: /api + template: ui-configmap.yaml + - notExists: + path: data.CUSTOM_TOKEN + template: ui-configmap.yaml + - it: should render ui deployment with default values template: ui-deployment.yaml asserts: @@ -303,68 +348,53 @@ tests: path: spec.template.spec.topologySpreadConstraints - it: should pass runtime browser config to init.sh as env - template: ui-deployment.yaml + template: ui-configmap.yaml asserts: - - contains: - path: spec.template.spec.containers[0].env - content: - name: KAGENT_API_BASE_URL - value: "/api" - - contains: - path: spec.template.spec.containers[0].env - content: - name: SSO_REDIRECT_PATH - value: "/oauth2/start" - - contains: - path: spec.template.spec.containers[0].env - content: - name: KAGENT_STREAM_TIMEOUT_MS - value: "1800000" + - equal: + path: data.KAGENT_UI_API_BASE_URL + value: "/api" + - equal: + path: data.KAGENT_UI_SSO_REDIRECT_PATH + value: "/oauth2/start" + - equal: + path: data.KAGENT_UI_STREAM_TIMEOUT_MS + value: "1800000" - it: should carry an overridden publicBackendUrl through to the browser config - template: ui-deployment.yaml + template: ui-configmap.yaml set: ui: publicBackendUrl: "https://api.example.com/api" asserts: - - contains: - path: spec.template.spec.containers[0].env - content: - name: KAGENT_API_BASE_URL - value: "https://api.example.com/api" + - equal: + path: data.KAGENT_UI_API_BASE_URL + value: "https://api.example.com/api" - it: should fall back to /api when publicBackendUrl is blanked - template: ui-deployment.yaml + template: ui-configmap.yaml set: ui: publicBackendUrl: "" asserts: - - contains: - path: spec.template.spec.containers[0].env - content: - name: KAGENT_API_BASE_URL - value: "/api" + - equal: + path: data.KAGENT_UI_API_BASE_URL + value: "/api" - it: should pass ui.basePath through to the browser config only when set - template: ui-deployment.yaml + template: ui-configmap.yaml set: ui: basePath: /ui asserts: - - contains: - path: spec.template.spec.containers[0].env - content: - name: KAGENT_UI_BASE_PATH - value: "/ui" + - equal: + path: data.KAGENT_UI_BASE_PATH + value: "/ui" - it: should omit KAGENT_UI_BASE_PATH by default - template: ui-deployment.yaml + template: ui-configmap.yaml asserts: - - notContains: - path: spec.template.spec.containers[0].env - content: - name: KAGENT_UI_BASE_PATH - any: true + - notExists: + path: data.KAGENT_UI_BASE_PATH - it: should reject a ui.basePath that is not a path template: ui-deployment.yaml @@ -394,60 +424,29 @@ tests: errorMessage: ui.basePath cannot start with a path nginx serves, such as /api or /assets - it: should accept a ui.basePath that only shares a prefix with a served path - template: ui-deployment.yaml + template: ui-configmap.yaml set: ui: basePath: /apis asserts: - - contains: - path: spec.template.spec.containers[0].env - content: - name: KAGENT_UI_BASE_PATH - value: "/apis" + - equal: + path: data.KAGENT_UI_BASE_PATH + value: "/apis" - it: should reflect overridden auth and stream settings - template: ui-deployment.yaml + template: ui-configmap.yaml set: ui: auth: ssoRedirectPath: /custom/start streamTimeoutSeconds: 90 asserts: - - contains: - path: spec.template.spec.containers[0].env - content: - name: SSO_REDIRECT_PATH - value: "/custom/start" - - contains: - path: spec.template.spec.containers[0].env - content: - name: KAGENT_STREAM_TIMEOUT_MS - value: "90000" - - - it: should not set env vars that only the removed node server consumed - template: ui-deployment.yaml - asserts: - - notContains: - path: spec.template.spec.containers[0].env - content: - name: NEXT_PUBLIC_BACKEND_URL - value: "/api" - - notContains: - path: spec.template.spec.containers[0].env - content: - name: NEXT_PUBLIC_SSO_REDIRECT_PATH - value: "/oauth2/start" - - notContains: - path: spec.template.spec.containers[0].env - content: - name: BACKEND_INTERNAL_URL - value: "http://RELEASE-NAME-controller.NAMESPACE.svc:8083/api" - # The browser reaches gRPC over the same origin, through nginx, so there is - # no separate gRPC URL for anything to be configured with. - - notContains: - path: spec.template.spec.containers[0].env - content: - name: BACKEND_GRPC_URL + - equal: + path: data.KAGENT_UI_SSO_REDIRECT_PATH + value: "/custom/start" + - equal: + path: data.KAGENT_UI_STREAM_TIMEOUT_MS + value: "90000" - it: should not mount a nextjs build cache template: ui-deployment.yaml diff --git a/helm/kagent/values.yaml b/helm/kagent/values.yaml index 92a2ec2ec4..d1bdb79cea 100644 --- a/helm/kagent/values.yaml +++ b/helm/kagent/values.yaml @@ -196,7 +196,7 @@ rbac: # If that is also unset, the chart creates cluster-scoped ClusterRole and # ClusterRoleBinding resources, and the controller watches all namespaces. # If set, the chart creates a Role and a RoleBinding per listed namespace. - # The controller's WATCH_NAMESPACES derives from the same list, unless + # The controller's KAGENT_WATCH_NAMESPACES derives from the same list, unless # controller.watchNamespaces overrides it. # An explicit empty list forces cluster-scoped RBAC, even under a parent that # sets global.watchNamespaces. This is why the key ships commented out: a @@ -218,14 +218,6 @@ controller: annotations: {} replicas: 1 loglevel: "info" - # Authentication mode: "unsecure" (default) or "trusted-proxy" - # - unsecure: uses X-User-Id header/query param or defaults to admin@kagent.dev - # - trusted-proxy: trusts JWT token from Authorization header (set by oauth2-proxy) - auth: - mode: unsecure - # JWT claim for user identity (default: "sub") - # Override only if your OIDC provider uses a different claim - userIdClaim: "" # -- Public gateway base URL advertised by Session Agent Cards for gRPC and HTTP/JSON-RPC. # @default -- `http://-controller..svc:` a2aGatewayUrl: "" @@ -333,7 +325,7 @@ controller: # `-metrics-reader` to your Prometheus ServiceAccount to grant # scrape access. Use `bindAddress` for any port change: the Service # `targetPort` and the pod `containerPort` are derived from it at - # template time, so overriding `METRICS_BIND_ADDRESS` via + # template time, so overriding `KAGENT_METRICS_BIND_ADDRESS` via # `controller.env` shifts only the runtime listener and leaves the # rendered Service pointing at the chart-time port. Setting # `bindAddress: "0"` (or empty) is treated as a disable signal — @@ -403,12 +395,10 @@ controller: # -- gRPC application API settings. Native gRPC, gRPC-Web, MCP, and health # share the controller service port. grpc: - maxMessageBytes: 16777216 reflection: false - tlsCertFile: "" - tlsKeyFile: "" - # Extra controller env (mapped to flags via SUBSTRATE_* env names). + # Extra controller environment variables, applied after ConfigMap defaults. + # Kept in the Deployment to support valueFrom and $(VAR_NAME) expansion. env: [] # Standalone workspaces, enabled by default. @@ -430,15 +420,10 @@ controller: enabled: false ateApiEndpoint: "" atenetRouterURL: "" - defaultWorkerPool: - namespace: "" - name: "" # Example when enabled: # enabled: true # ateApiEndpoint: "dns:///api.ate-system.svc:443" # atenetRouterURL: "http://atenet-router.ate-system.svc:80" - # defaultWorkerPool: - # name: "kagent-default" envFrom: [] # Additional volumes on the output Deployment definition. @@ -541,9 +526,10 @@ ui: proxyReadTimeout: 1800s # -- proxy_send_timeout: max time between two successive writes to the upstream. proxySendTimeout: 1800s - # -- Extra environment variables for the UI container: a list of `{name, value}` - # entries, spliced into its `env:` verbatim. An installed app extension's own - # settings go here, named `EXTENSION_*`; the container's startup script copies + # -- Extra environment variables for the UI container, applied after ConfigMap + # defaults. Entries stay in the Deployment to support valueFrom and $(VAR_NAME) + # expansion. An installed app extension's settings go here, named + # `KAGENT_UI_EXTENSION_*`; the container's startup script copies # those onto `window.environmentVariables` for the browser to read. env: [] # -- Identity headers the UI's nginx proxy will forward to the backend on @@ -875,7 +861,8 @@ grafana-mcp: # OAUTH2-PROXY CONFIGURATION (Optional) # ============================================================================== # Enable oauth2-proxy subchart for OIDC authentication -# Requires controller.auth.mode: trusted-proxy +# Controller authentication requires a library integration; see +# docs/architecture/oidc-proxy-authentication.md. oauth2-proxy: enabled: false diff --git a/python/README.md b/python/README.md index 110e184538..0516b3b8c8 100644 --- a/python/README.md +++ b/python/README.md @@ -46,5 +46,6 @@ spec: ``` In this example, the HTTP listener uses port `8080`. Substrate readiness uses a -separate listener on port `8081`. A2A gRPC uses the address configured by -`KAGENT_A2A_GRPC_ADDRESS`. +separate listener on port `8081`. A2A gRPC binds to `[::]` on `KAGENT_PORT`, +which the controller sets to `80`. The same variable selects the Go ADK's +shared HTTP/gRPC port. diff --git a/python/packages/kagent-adk/src/kagent/adk/_a2a.py b/python/packages/kagent-adk/src/kagent/adk/_a2a.py index e8ab210a10..8819a20872 100644 --- a/python/packages/kagent-adk/src/kagent/adk/_a2a.py +++ b/python/packages/kagent-adk/src/kagent/adk/_a2a.py @@ -84,7 +84,7 @@ def __init__( """ self.root_agent_factory = root_agent_factory self.kagent_api_url = kagent_api_url - self.a2a_grpc_address = a2a_grpc_address or os.getenv("KAGENT_A2A_GRPC_ADDRESS", "[::]:80") + self.a2a_grpc_address = a2a_grpc_address or f"[::]:{os.getenv('KAGENT_PORT') or '80'}" self.app_name = app_name self.agent_card = agent_card self._lifespan = lifespan diff --git a/python/packages/kagent-adk/src/kagent/adk/cli.py b/python/packages/kagent-adk/src/kagent/adk/cli.py index 0f2f2f3e37..980eb026bd 100644 --- a/python/packages/kagent-adk/src/kagent/adk/cli.py +++ b/python/packages/kagent-adk/src/kagent/adk/cli.py @@ -38,11 +38,11 @@ def _split_csv(value: Optional[str]) -> Optional[list[str]]: return entries or None -sts_well_known_uri = os.getenv("STS_WELL_KNOWN_URI") +sts_well_known_uri = os.getenv("KAGENT_STS_WELL_KNOWN_URI") propagate_token = os.getenv("KAGENT_PROPAGATE_TOKEN", "").lower() == "true" token_resource = _split_csv(os.getenv("KAGENT_STS_RESOURCE")) token_audience = _split_csv(os.getenv("KAGENT_STS_AUDIENCE")) -uvicorn_log_level = os.getenv("UVICORN_LOG_LEVEL", os.getenv("LOG_LEVEL", "info")).lower() +uvicorn_log_level = os.getenv("KAGENT_LOG_LEVEL", "info").lower() def create_sts_integration() -> Optional[ADKTokenPropagationPlugin]: diff --git a/python/packages/kagent-adk/tests/unittests/test_a2a_grpc.py b/python/packages/kagent-adk/tests/unittests/test_a2a_grpc.py index 811d1b8443..8ab0d1915a 100644 --- a/python/packages/kagent-adk/tests/unittests/test_a2a_grpc.py +++ b/python/packages/kagent-adk/tests/unittests/test_a2a_grpc.py @@ -9,7 +9,7 @@ from kagent.adk import KAgentApp -def make_app(address: str = "127.0.0.1:0"): +def make_app(address: str | None = "127.0.0.1:0"): card = ParseDict( { "name": "test-app", @@ -29,12 +29,25 @@ def make_app(address: str = "127.0.0.1:0"): kagent_api_url="http://unused", app_name="test-app", a2a_grpc_address=address, - ).build(local=True) + ) + + +@pytest.mark.parametrize("port,expected", [(None, "[::]:80"), ("", "[::]:80"), ("9090", "[::]:9090")]) +def test_grpc_address_from_shared_port(monkeypatch, port, expected): + monkeypatch.delenv("KAGENT_PORT", raising=False) + if port is not None: + monkeypatch.setenv("KAGENT_PORT", port) + assert make_app(address=None).a2a_grpc_address == expected @pytest.mark.asyncio -async def test_grpc_health(): - app = make_app() +@pytest.mark.parametrize("address", ["127.0.0.1:0", None]) +async def test_grpc_health(monkeypatch, address): + # An explicit address takes precedence; otherwise the shared port selects + # an ephemeral listener. The removed variable must have no effect. + monkeypatch.setenv("KAGENT_PORT", "invalid" if address else "0") + monkeypatch.setenv("KAGENT_A2A_GRPC_ADDRESS", "invalid") + app = make_app(address).build(local=True) async with app.router.lifespan_context(app): async with grpc.aio.insecure_channel(f"127.0.0.1:{app.state.a2a_grpc_port}") as channel: @@ -61,7 +74,7 @@ def capture_grpc_handler(handler, *args, **kwargs): monkeypatch.setattr(_a2a, "create_jsonrpc_routes", capture_jsonrpc_handler) monkeypatch.setattr(_a2a, "GrpcHandler", capture_grpc_handler) - app = make_app() + app = make_app().build(local=True) async with app.router.lifespan_context(app): async with grpc.aio.insecure_channel(f"127.0.0.1:{app.state.a2a_grpc_port}") as channel: diff --git a/python/packages/kagent-core/src/kagent/core/_logging.py b/python/packages/kagent-core/src/kagent/core/_logging.py index 870a9ab21f..ddab17cb7d 100644 --- a/python/packages/kagent-core/src/kagent/core/_logging.py +++ b/python/packages/kagent-core/src/kagent/core/_logging.py @@ -7,10 +7,10 @@ def configure_logging() -> None: - """Configure logging based on LOG_LEVEL environment variable.""" + """Configure logging based on KAGENT_LOG_LEVEL environment variable.""" global _logging_configured - log_level = os.getenv("LOG_LEVEL", "INFO").upper() + log_level = os.getenv("KAGENT_LOG_LEVEL", "INFO").upper() formatter = logging.Formatter(LOG_FORMAT) # Only configure if not already configured (avoid duplicate handlers) diff --git a/python/packages/kagent-core/src/kagent/core/a2a/_config.py b/python/packages/kagent-core/src/kagent/core/a2a/_config.py index b3831bfc02..0f736576c3 100644 --- a/python/packages/kagent-core/src/kagent/core/a2a/_config.py +++ b/python/packages/kagent-core/src/kagent/core/a2a/_config.py @@ -5,7 +5,7 @@ logger = logging.getLogger(__name__) -A2A_MAX_CONTENT_LENGTH_ENV_VAR = "A2A_MAX_CONTENT_LENGTH" +A2A_MAX_CONTENT_LENGTH_ENV_VAR = "KAGENT_A2A_MAX_CONTENT_LENGTH" DEFAULT_A2A_MAX_CONTENT_LENGTH = 10 * 1024 * 1024 # 10MB (preserves the pre-v1 SDK default) @@ -16,7 +16,7 @@ def get_a2a_max_content_length() -> int | None: middleware. Environment variable: - A2A_MAX_CONTENT_LENGTH: Maximum payload size in bytes. + KAGENT_A2A_MAX_CONTENT_LENGTH: Maximum payload size in bytes. Default: 10485760 (10MB, a2a-sdk default) Example: 52428800 (50MB) Set to "0" or "none" for unlimited. diff --git a/python/packages/kagent-core/tests/test_a2a_config.py b/python/packages/kagent-core/tests/test_a2a_config.py index 58e3355683..c5a5428697 100644 --- a/python/packages/kagent-core/tests/test_a2a_config.py +++ b/python/packages/kagent-core/tests/test_a2a_config.py @@ -10,8 +10,8 @@ def test_get_a2a_max_content_length_with_env_var(): - """Test that setting A2A_MAX_CONTENT_LENGTH env var returns the configured value.""" - with patch.dict(os.environ, {"A2A_MAX_CONTENT_LENGTH": "52428800"}): + """Test that setting KAGENT_A2A_MAX_CONTENT_LENGTH env var returns the configured value.""" + with patch.dict(os.environ, {"KAGENT_A2A_MAX_CONTENT_LENGTH": "52428800"}): result = get_a2a_max_content_length() assert result == 52428800 # 50MB @@ -20,47 +20,47 @@ def test_get_a2a_max_content_length_without_env_var(): """Test that without env var, the former a2a-sdk default is preserved.""" with patch.dict(os.environ, {}, clear=True): # Ensure env var is not set - os.environ.pop("A2A_MAX_CONTENT_LENGTH", None) + os.environ.pop("KAGENT_A2A_MAX_CONTENT_LENGTH", None) result = get_a2a_max_content_length() assert result == DEFAULT_A2A_MAX_CONTENT_LENGTH def test_get_a2a_max_content_length_with_zero(): """Test that setting env var to '0' returns None (unlimited).""" - with patch.dict(os.environ, {"A2A_MAX_CONTENT_LENGTH": "0"}): + with patch.dict(os.environ, {"KAGENT_A2A_MAX_CONTENT_LENGTH": "0"}): result = get_a2a_max_content_length() assert result is None def test_get_a2a_max_content_length_with_none_string(): """Test that setting env var to 'none' returns None (unlimited).""" - with patch.dict(os.environ, {"A2A_MAX_CONTENT_LENGTH": "none"}): + with patch.dict(os.environ, {"KAGENT_A2A_MAX_CONTENT_LENGTH": "none"}): result = get_a2a_max_content_length() assert result is None def test_get_a2a_max_content_length_with_unlimited_string(): """Test that setting env var to 'unlimited' returns None.""" - with patch.dict(os.environ, {"A2A_MAX_CONTENT_LENGTH": "unlimited"}): + with patch.dict(os.environ, {"KAGENT_A2A_MAX_CONTENT_LENGTH": "unlimited"}): result = get_a2a_max_content_length() assert result is None def test_get_a2a_max_content_length_with_invalid_value(caplog): """Test that invalid env var value logs a warning and returns default.""" - with patch.dict(os.environ, {"A2A_MAX_CONTENT_LENGTH": "not_a_number"}): + with patch.dict(os.environ, {"KAGENT_A2A_MAX_CONTENT_LENGTH": "not_a_number"}): result = get_a2a_max_content_length() assert result == DEFAULT_A2A_MAX_CONTENT_LENGTH - assert "Invalid A2A_MAX_CONTENT_LENGTH value" in caplog.text + assert "Invalid KAGENT_A2A_MAX_CONTENT_LENGTH value" in caplog.text assert "not_a_number" in caplog.text def test_get_a2a_max_content_length_with_negative_value(caplog): """Test that negative env var value logs a warning and returns default.""" - with patch.dict(os.environ, {"A2A_MAX_CONTENT_LENGTH": "-1"}): + with patch.dict(os.environ, {"KAGENT_A2A_MAX_CONTENT_LENGTH": "-1"}): result = get_a2a_max_content_length() assert result == DEFAULT_A2A_MAX_CONTENT_LENGTH - assert "Invalid A2A_MAX_CONTENT_LENGTH value" in caplog.text + assert "Invalid KAGENT_A2A_MAX_CONTENT_LENGTH value" in caplog.text assert "-1" in caplog.text assert "must be non-negative" in caplog.text diff --git a/python/packages/kagent-openai/README.md b/python/packages/kagent-openai/README.md index 43e9b942d0..778da72551 100644 --- a/python/packages/kagent-openai/README.md +++ b/python/packages/kagent-openai/README.md @@ -132,7 +132,7 @@ Set `KAGENT_API_URL` and `KAGENT_GATEWAY_URL` to connect to kagent. - `KAGENT_API_URL` - KAgent control-plane API URL - `KAGENT_GATEWAY_URL` - KAgent A2A and MCP gateway URL -- `LOG_LEVEL` - Logging level (default: INFO) +- `KAGENT_LOG_LEVEL` - Logging level (default: INFO) --- diff --git a/python/packages/kagent-openai/src/kagent/openai/_a2a.py b/python/packages/kagent-openai/src/kagent/openai/_a2a.py index 48c62b06a1..fa645806fa 100644 --- a/python/packages/kagent-openai/src/kagent/openai/_a2a.py +++ b/python/packages/kagent-openai/src/kagent/openai/_a2a.py @@ -51,7 +51,7 @@ def thread_dump(request: Request) -> PlainTextResponse: return PlainTextResponse(tmp.read()) -sts_well_known_uri = os.getenv("STS_WELL_KNOWN_URI") +sts_well_known_uri = os.getenv("KAGENT_STS_WELL_KNOWN_URI") def _configure_openai_client() -> None: diff --git a/python/packages/kagent-skills/src/kagent/skills/shell.py b/python/packages/kagent-skills/src/kagent/skills/shell.py index a6369798ca..8257bb6b95 100644 --- a/python/packages/kagent-skills/src/kagent/skills/shell.py +++ b/python/packages/kagent-skills/src/kagent/skills/shell.py @@ -416,7 +416,7 @@ async def execute_command( # If a separate venv for shell commands is specified, use its python and pip # Otherwise the system python/pip will be used for backward compatibility - bash_venv_path = os.environ.get("BASH_VENV_PATH") + bash_venv_path = os.environ.get("KAGENT_BASH_VENV_PATH") if bash_venv_path: bash_venv_bin = os.path.join(bash_venv_path, "bin") # Prepend bash venv to PATH so its python and pip are used diff --git a/scripts/setup-cluster/README.md b/scripts/setup-cluster/README.md index ab320edb98..62ff661fb3 100644 --- a/scripts/setup-cluster/README.md +++ b/scripts/setup-cluster/README.md @@ -37,7 +37,7 @@ yarn dev That is the whole reason this script holds two forwards open rather than one. It used to forward only the UI, and running the dev server then needed -`KAGENT_DEV_CONTROLLER_URL=http://127.0.0.1:8080` in `ui/.env` — a line whose absence +`KAGENT_UI_DEV_CONTROLLER_URL=http://127.0.0.1:8080` in `ui/.env` — a line whose absence looked like a broken backend rather than a missing forward, since the page loaded fine and every read failed with `ECONNREFUSED 127.0.0.1:8083`. @@ -61,7 +61,7 @@ If you would rather not build one, the UI runs entirely on in-browser fixtures: ```sh cd ui -ENABLE_MOCK_UI=true yarn dev +KAGENT_UI_ENABLE_MOCK=true yarn dev ``` Every page works and says on the page that the data is not real. `?mock=empty`, diff --git a/scripts/setup-cluster/setup-cluster.sh b/scripts/setup-cluster/setup-cluster.sh index 600e4b39bd..822d09a57f 100755 --- a/scripts/setup-cluster/setup-cluster.sh +++ b/scripts/setup-cluster/setup-cluster.sh @@ -85,7 +85,6 @@ make helm-install KAGENT_HELM_EXTRA_ARGS="\ --set controller.substrate.enabled=true \ --set controller.substrate.ateApiEndpoint=dns:///api.ate-system.svc:443 \ --set controller.substrate.atenetRouterURL=http://atenet-router.ate-system.svc:80 \ - --set controller.substrate.defaultWorkerPool.name=kagent-default \ --set substrateWorkerPool.create=true \ --set substrateWorkerPool.replicas=8 \ --set-string substrateWorkerPool.workerImage=ghcr.io/kagent-dev/substrate/ateom-gvisor:v${SUBSTRATE_VERSION}" @@ -121,7 +120,7 @@ docker buildx build --push --platform "linux/${ARCH}" \ --build-arg BUILD_PACKAGE=adk/cmd/main.go \ -t localhost:5001/kagent-dev/kagent/golang-adk:dev -f go/Dockerfile ./go HARNESS_DIGEST="$(docker buildx imagetools inspect localhost:5001/kagent-dev/kagent/golang-adk:dev \ - | awk '/^Digest:/{print $2; exit}')" + | awk '/^Digest:/{print $2}')" step "9/10 An Agent with inline template and Harness" kubectl apply -f - < { `${baseUrl} was expected to serve the app with the example extension ` + `installed (project "${project.name}"), but no extension points are ` + `mounted. The server on that port came up without ` + - `VITE_EXAMPLE_EXTENSION=true.`, + `KAGENT_UI_VITE_EXAMPLE_EXTENSION=true.`, ); } if (!wantsExtension && slots > 0) { diff --git a/ui/scripts/init.sh b/ui/scripts/init.sh index e117ee9324..b7268985df 100644 --- a/ui/scripts/init.sh +++ b/ui/scripts/init.sh @@ -25,10 +25,10 @@ mkdir -p /tmp/nginx/client_temp \ # awaited fetch to have landed. Keep the keys in sync with `ui/src/env.ts`. CONFIG_PATH=/tmp/kagent/env-config.js -API_BASE_URL="${KAGENT_API_BASE_URL:-/api}" -SSO_REDIRECT_PATH="${SSO_REDIRECT_PATH:-/oauth2/start}" -STREAM_TIMEOUT_MS="${KAGENT_STREAM_TIMEOUT_MS:-1800000}" -ENABLE_MOCK_UI="${ENABLE_MOCK_UI:-false}" +API_BASE_URL="${KAGENT_UI_API_BASE_URL:-/api}" +SSO_REDIRECT_PATH="${KAGENT_UI_SSO_REDIRECT_PATH:-/oauth2/start}" +STREAM_TIMEOUT_MS="${KAGENT_UI_STREAM_TIMEOUT_MS:-1800000}" +ENABLE_MOCK_UI="${KAGENT_UI_ENABLE_MOCK:-false}" # Public path prefix when a reverse proxy serves the UI under a sub-path, e.g. /ui. BASE_PATH="${KAGENT_UI_BASE_PATH:-}" BASE_PATH="${BASE_PATH%/}" @@ -42,7 +42,7 @@ fi # A non-numeric timeout would abort every chat stream immediately, which looks # like the backend hanging up rather than like a bad value. Fall back instead. if ! [[ "$STREAM_TIMEOUT_MS" =~ ^[0-9]+$ ]]; then - echo "init.sh: KAGENT_STREAM_TIMEOUT_MS='${STREAM_TIMEOUT_MS}' is not a number; using 1800000" >&2 + echo "init.sh: KAGENT_UI_STREAM_TIMEOUT_MS='${STREAM_TIMEOUT_MS}' is not a number; using 1800000" >&2 STREAM_TIMEOUT_MS=1800000 fi @@ -57,7 +57,7 @@ json_escape() { } # Anything an installed extension reads, passed through verbatim and by name rather -# than by list: whatever the chart sets as `EXTENSION_*` arrives, so this image needs +# than by list: whatever the chart sets as `KAGENT_UI_EXTENSION_*` arrives, so this image needs # no change when an extension grows a setting, and this repository does not enumerate # another product's configuration. The application ignores keys it has no use for. # @@ -66,7 +66,7 @@ extension_json="" while IFS='=' read -r name value; do extension_json+=" \"$(json_escape "$name")\": \"$(json_escape "$value")\", " -done < <(env | grep '^EXTENSION_' | sort) +done < <(env | grep '^KAGENT_UI_EXTENSION_' | sort) cat > "$CONFIG_PATH" < { describe("readEnv", () => { it("reads a key the application knows nothing about", () => { - window.environmentVariables = { EXTENSION_ANYTHING: "a-value" }; - expect(readEnv("EXTENSION_ANYTHING", "a-fallback")).toBe("a-value"); + window.environmentVariables = { KAGENT_UI_EXTENSION_ANYTHING: "a-value" }; + expect(readEnv("KAGENT_UI_EXTENSION_ANYTHING", "a-fallback")).toBe("a-value"); }); it("uses the caller's fallback when it is absent", () => { - expect(readEnv("EXTENSION_ANYTHING", "a-fallback")).toBe("a-fallback"); + expect(readEnv("KAGENT_UI_EXTENSION_ANYTHING", "a-fallback")).toBe("a-fallback"); }); }); diff --git a/ui/src/env.ts b/ui/src/env.ts index ca24e011ec..e4bdf2017b 100644 --- a/ui/src/env.ts +++ b/ui/src/env.ts @@ -36,17 +36,17 @@ export interface EnvironmentVariables { * publish every credential on the developer's machine into the HTML. * * An extension's own keys are not listed here, and are not listed anywhere: anything - * named `EXTENSION_*` is passed through by the dev server and by `scripts/init.sh`, + * named `KAGENT_UI_EXTENSION_*` is passed through by the dev server and by `scripts/init.sh`, * and read back with `readEnv`, which takes any key. This application does not know * what an extension's settings are and does not need to. */ -export const CORE_ENV_KEYS = [ - "API_BASE_URL", - "SSO_REDIRECT_PATH", - "STREAM_TIMEOUT_MS", - "ENABLE_MOCK_UI", - "BASE_PATH", -] as const satisfies readonly (keyof EnvironmentVariables)[]; +export const CORE_ENV_VARS = { + API_BASE_URL: "KAGENT_UI_API_BASE_URL", + SSO_REDIRECT_PATH: "KAGENT_UI_SSO_REDIRECT_PATH", + STREAM_TIMEOUT_MS: "KAGENT_UI_STREAM_TIMEOUT_MS", + ENABLE_MOCK_UI: "KAGENT_UI_ENABLE_MOCK", + BASE_PATH: "KAGENT_UI_BASE_PATH", +} as const satisfies Record; /** * What the app uses when a key is absent — the normal case for `yarn dev` and diff --git a/ui/src/mocks/startMockBackend.ts b/ui/src/mocks/startMockBackend.ts index e80a1fb632..335e61ac7b 100644 --- a/ui/src/mocks/startMockBackend.ts +++ b/ui/src/mocks/startMockBackend.ts @@ -9,7 +9,7 @@ * * Order matters: the transport is installed before anything can call an * operation, which is why this is awaited in `main.tsx` before the app renders. - * Only `main.tsx` calls it, and only when `VITE_API_MODE` is not `"live"` — a page + * Only `main.tsx` calls it, and only when `KAGENT_UI_VITE_API_MODE` is not `"live"` — a page * that quietly served fixtures because the backend was unreachable would look * healthy while showing data that was never real. */ diff --git a/ui/src/vite-env.d.ts b/ui/src/vite-env.d.ts index b85fa3d6f1..47fe7590a6 100644 --- a/ui/src/vite-env.d.ts +++ b/ui/src/vite-env.d.ts @@ -1,10 +1,8 @@ /// interface ImportMetaEnv { - /** "mock" (default) serves from the in-browser mock backend; "live" talks to a real API. */ - readonly VITE_API_MODE?: "mock" | "live"; - /** Base URL of the real API, used when VITE_API_MODE is "live". */ - readonly VITE_API_BASE_URL?: string; + /** Optional build-time override; normal development uses runtime configuration. */ + readonly KAGENT_UI_VITE_API_MODE?: "mock" | "live"; /** * `"true"` installs the bundled Example App Extension. * @@ -12,7 +10,7 @@ interface ImportMetaEnv { * extension has to be imported to be in the bundle at all, so which ones are * installed is the array in `appExtensions/activeExtensions.ts`. */ - readonly VITE_EXAMPLE_EXTENSION?: "true" | "false"; + readonly KAGENT_UI_VITE_EXAMPLE_EXTENSION?: "true" | "false"; } interface ImportMeta { diff --git a/ui/vite.config.ts b/ui/vite.config.ts index 8582030b76..817b8317da 100644 --- a/ui/vite.config.ts +++ b/ui/vite.config.ts @@ -2,7 +2,7 @@ import { defineConfig } from "vitest/config"; import { loadEnv, type Plugin } from "vite"; import react from "@vitejs/plugin-react"; import path from "node:path"; -import { CORE_ENV_KEYS, ENV_DEFAULTS } from "./src/env.ts"; +import { CORE_ENV_VARS, ENV_DEFAULTS } from "./src/env.ts"; /** * Where the dev server forwards `/api` and `/a2a`. @@ -21,8 +21,8 @@ import { CORE_ENV_KEYS, ENV_DEFAULTS } from "./src/env.ts"; function devProxy(mode: string) { const fromFiles = loadEnv(mode, import.meta.dirname, ""); const target = - process.env.KAGENT_DEV_CONTROLLER_URL || - fromFiles.KAGENT_DEV_CONTROLLER_URL || + process.env.KAGENT_UI_DEV_CONTROLLER_URL || + fromFiles.KAGENT_UI_DEV_CONTROLLER_URL || "http://127.0.0.1:8083"; return { @@ -42,17 +42,17 @@ function devProxy(mode: string) { * * A prefix instead. `readEnv` already takes any key and `window.environmentVariables` * is already an open record, so nothing in the application changes when an extension - * adds a setting: it names it `EXTENSION_SOMETHING` and it arrives. + * adds a setting: it names it `KAGENT_UI_EXTENSION_SOMETHING` and it arrives. * - * Still bounded, which is the point of `CORE_ENV_KEYS` being a list at all: the dev + * Still bounded, which is the point of `CORE_ENV_VARS` being an explicit map: the dev * server inlines these into the document, so a wholesale copy of the environment would * publish every credential on the machine into the HTML. A variable has to be * deliberately named for an extension to be copied — but only that deliberately, so a - * stray `EXTENSION_` variable in a shell will be inlined. It is a development server + * stray `KAGENT_UI_EXTENSION_` variable in a shell will be inlined. It is a development server * rendering a page for the developer who started it; the container path below is the * one that faces a cluster, and there the environment is the chart's own. */ -const EXTENSION_ENV_PREFIX = "EXTENSION_"; +const EXTENSION_ENV_PREFIX = "KAGENT_UI_EXTENSION_"; /** * Serves the deployment configuration the way the container does. @@ -84,7 +84,11 @@ function devEnvConfig(mode: string): Plugin { ...Object.keys(process.env), ].filter((key) => key.startsWith(EXTENSION_ENV_PREFIX)); - for (const key of [...CORE_ENV_KEYS, ...extensionKeys]) { + for (const [key, variable] of Object.entries(CORE_ENV_VARS)) { + const value = process.env[variable] ?? fromFiles[variable]; + if (value !== undefined && value !== "") merged[key] = value; + } + for (const key of extensionKeys) { const value = process.env[key] ?? fromFiles[key]; if (value !== undefined && value !== "") merged[key] = value; } @@ -114,6 +118,8 @@ function devEnvConfig(mode: string): Plugin { } export default defineConfig(({ mode }) => ({ + // Only explicit build-time settings are exposed through import.meta.env. + envPrefix: "KAGENT_UI_VITE_", // Relative asset URLs, resolved against the `` in index.html. base: "./", // JSX is transformed by oxc, which routes the factory at @emotion/react — @@ -142,7 +148,7 @@ export default defineConfig(({ mode }) => ({ alias: { "@": path.resolve(import.meta.dirname, "./src") }, }, server: { - port: Number(process.env.UI_LOOP_PORT ?? 8001), + port: Number(process.env.KAGENT_E2E_UI_LOOP_PORT ?? 8001), host: "0.0.0.0", // Stands in for what nginx does in a deployed cluster, so a dev server // talking to a real controller uses the same relative URLs as production. @@ -151,7 +157,7 @@ export default defineConfig(({ mode }) => ({ proxy: devProxy(mode), }, preview: { - port: Number(process.env.UI_LOOP_PORT ?? 8001), + port: Number(process.env.KAGENT_E2E_UI_LOOP_PORT ?? 8001), host: "0.0.0.0", }, test: { @@ -166,6 +172,6 @@ export default defineConfig(({ mode }) => ({ // mode is stated rather than inherited. It used to come free from the dev // default; that default is gone, because a page that quietly serves fixtures // when the backend is down is worse than one that says so. - env: { VITE_API_MODE: "mock" }, + env: { KAGENT_UI_VITE_API_MODE: "mock" }, }, }));