Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
aa48639
refactor: build the observability connectors in composition_support
hellices Oct 29, 2026
ffd1311
ui: render evidence observations with one shared renderer
hellices Oct 29, 2026
a229796
ui: show a ConfigMap's or Secret's consumers by mode
hellices Oct 29, 2026
b85256e
k8s: name what a diagnostic needs of its client
hellices Oct 29, 2026
dd88151
ui: run the propagation diagnostic for the selected object
hellices Oct 29, 2026
0918579
ui: open the propagation view with :consumers
hellices Oct 29, 2026
73bae49
tests: verify ConfigMap consumers by mode on every supported version
hellices Oct 29, 2026
bb570f3
docs: explain configuration propagation and :consumers
hellices Oct 29, 2026
ab1ee9f
docs: plan the configuration propagation view (#410 P1)
hellices Oct 29, 2026
028f433
ui: age the open propagation view, and name the forms it checks
hellices Oct 29, 2026
55319e4
ui: open a propagation result only where it was asked for
hellices Oct 29, 2026
6411e1f
ui: report a failed propagation run instead of dropping it
hellices Oct 29, 2026
2bb52ad
docs: say the open view ages and which forms are checked
hellices Oct 29, 2026
0abfb8f
ui: stop calling an aged absence a deletion
hellices Oct 29, 2026
c828a49
docs: record that keybinding the view waits for #510
hellices Oct 29, 2026
932e32a
docs: say what reaches korvid and what the view keeps
hellices Oct 29, 2026
5292316
ui: tell an ephemeral consumer that a restart will not help
hellices Oct 29, 2026
36ee923
ui: show the ephemeral note only where a new container is needed
hellices Oct 29, 2026
86ca83e
tests: pin each age unit at its exact boundary
hellices Oct 29, 2026
67cc1ec
docs: fill in the plan's workflow test block
hellices Oct 29, 2026
97bf42e
tests: KubeClient must be a concrete EvidenceClient
hellices Oct 29, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/workflows/k8s-version-matrix.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,13 @@ on:
- src/korvid/core/api_versions.py
- src/korvid/core/capability.py
- src/korvid/core/capability_classifier.py
- src/korvid/core/config_consumers.py
- src/korvid/core/config_propagation.py
- src/korvid/core/evidence_reads.py
- src/korvid/core/run_tokens.py
- src/korvid/k8s/bounded_transport.py
- src/korvid/k8s/client.py
- src/korvid/k8s/config_references.py
- src/korvid/k8s/discovery.py
- src/korvid/k8s/discovery_snapshot.py
- src/korvid/k8s/errors.py
Expand All @@ -32,10 +35,13 @@ on:
- src/korvid/core/api_versions.py
- src/korvid/core/capability.py
- src/korvid/core/capability_classifier.py
- src/korvid/core/config_consumers.py
- src/korvid/core/config_propagation.py
- src/korvid/core/evidence_reads.py
- src/korvid/core/run_tokens.py
- src/korvid/k8s/bounded_transport.py
- src/korvid/k8s/client.py
- src/korvid/k8s/config_references.py
- src/korvid/k8s/discovery.py
- src/korvid/k8s/discovery_snapshot.py
- src/korvid/k8s/errors.py
Expand Down
1 change: 1 addition & 0 deletions docs/keybindings.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ Korvid shows only the keys that act on the current view. Press `?` for the compl
| `:` | Open the command bar |
| `:pulse` / `:problems` | Inspect [current problems, recent warnings, and coverage](pulse.md) |
| `:outcomes` / `:deployment-outcomes` | Open the latest bounded Deployment scale/restart outcome |
| `:consumers` / `:config-consumers` | Show which Pods use the selected ConfigMap or Secret, and [what a change means](tui.md#configuration-propagation) for each |
| `Ctrl-P` | Search app actions and built-in commands by intent |
| `:keys` / `:keybindings` | Edit, review, and save app keybindings |
| `:slots` / `:ns-slots` | Preview and repack the `1`–`9` namespace shortcuts |
Expand Down
4 changes: 4 additions & 0 deletions docs/kubernetes-support.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,10 @@ read-only scenarios against each:
`events.k8s.io`; by one allowed only `events.k8s.io`; and by one allowed
only core `v1`, which falls through to core after `events.k8s.io` is
denied.
- a Pod that consumes a ConfigMap as environment variables, as a mounted
volume and through a `subPath` mount, each found in its own mode, without
reading the ConfigMap's data and without the Pod's literal environment
values reaching the report.

The workflow runs on demand, and whenever a pull request or a push to `main`
changes the modules those scenarios exercise or the project's dependencies. Each passing job records the
Expand Down
10 changes: 10 additions & 0 deletions docs/release-notes/unreleased.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,16 @@ remains the stable install until v0.6.1's publication is verified. See the
[current release milestone, v0.7.0](https://github.com/hellices/korvid/milestone/9)
tracks the next release scope.

## Configuration propagation

- `:consumers` (or **Explain configuration propagation** in the Action
Palette) shows which Pods use the selected ConfigMap or Secret: through
environment variables, a mounted volume, a `subPath` mount, an unmounted
volume or an image pull secret, and what a change means for each. It reads
only the object's metadata and the namespace's Pods, and says when the set
of consumers is not established. See
[Configuration propagation](../tui.md#configuration-propagation).

## Current configuration and extension contracts

Existing migration links continue in the
Expand Down
1,948 changes: 1,948 additions & 0 deletions docs/superpowers/plans/2026-10-30-config-propagation-view.md

Large diffs are not rendered by default.

31 changes: 31 additions & 0 deletions docs/tui.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,37 @@ dropped. Streams reconnect automatically on a transient error or an
unexpected EOF; after five consecutive failed attempts the header shows an
error state and a notification is raised.

### Configuration propagation

Changing a ConfigMap or Secret does not always change what a running
container sees. Select one in its table and run `:consumers` (or
**Explain configuration propagation** in the Action Palette) to see each Pod
container in its namespace that uses it, grouped by how:

| How it is used | What a change means |
|---|---|
| Environment variables (`envFrom`, `env[].valueFrom`) | Read when the container starts; a change needs a rollout restart, which asks for your approval |
| A mounted volume, plain or projected | Updated in place by the kubelet, eventually, unless the object is immutable; whether the application rereads it is not verified |
| A `subPath` or `subPathExpr` mount | Never updated; a change needs a rollout restart |
| A volume no container mounts | Nothing reads it |
| An image pull secret | Used only when an image is pulled |

Comment thread
hellices marked this conversation as resolved.
Resolved
Init and ephemeral containers get notes of their own: a finished init
container sees a change only when its Pod restarts, and an ephemeral
container cannot be restarted, so a rollout leaves it behind: a new one is
needed for it to read a changed environment variable or `subPath` file.

The view reads the object's metadata and the namespace's Pods when it opens,
never the object's data. Pods arrive whole, so their literal `env[].value`
fields reach korvid too, but the view keeps and shows only references, never
a value. Each source gets a
line saying what was read, its state and how old it is, and an open view
marks it stale once it ages. "No Pod references it" appears only when every
Pod could be listed; a denied, truncated or stale list says why the set of
consumers is not established. CSI and other volume plugins are not checked. Whether the object is
immutable is never claimed, because only its metadata is read. Enter on a
row opens that Pod.

## Change scope without losing context

korvid always watches exactly one explicit scope. The startup namespace
Expand Down
147 changes: 16 additions & 131 deletions src/korvid/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,9 @@
_validate_ca_bundle,
_warn_agent_disabled,
)
from korvid.composition_support import (
_build_observability as _build_observability,
)
from korvid.composition_support import (
# Deliberate re-export (hence the alias): _close_runner and its tests,
# which arm and invoke the shutdown watchdog, reach it by this name.
Expand All @@ -78,7 +81,6 @@
KorvidConfig,
ModelConnectionConfig,
ModelConnectionsWriter,
ObservabilityBackend,
load_config,
)
from korvid.core.config_store import save_topbar_state
Expand Down Expand Up @@ -136,6 +138,7 @@
)
from korvid.ui.bridge_dispatch import AppContextDispatch
from korvid.ui.command_router import CommandRouter
from korvid.ui.config_propagation_controller import ConfigPropagationController
from korvid.ui.context_switch_coordinator import ContextSwitchCoordinator, ContextSwitchResult
from korvid.ui.debug import DebugController, DebugSettings
from korvid.ui.deployment_outcome_actions import DeploymentOutcomePodActions
Expand Down Expand Up @@ -228,136 +231,6 @@
#: keyring is absent), which would misreport the capability as installed.
_MCP_EXTRA_ROOTS = frozenset({"mcp", "httpx2", "anyio", "starlette", "uvicorn"})
_AGENT_EXTRA_ROOTS = frozenset({"httpx", "keyring"})
#: The observability connectors need only an HTTP client.
_OBSERVABILITY_EXTRA_ROOTS = frozenset({"httpx"})
_OBSERVABILITY_INSTALL_HINT = (
"an observability backend is configured (observability.prometheus/loki in "
f"config.yaml) but its dependencies are not installed — {isolated_install_hint(feature='observability')}"
)


def _build_observability(config: KorvidConfig) -> ObservabilityWiring:
"""Build the configured read-only observability connectors (issue #193).

Nothing configured means nothing imported: a base installation never
pulls in the HTTP stack for a feature it is not using. A configured
backend without the extra fails with an install hint rather than
degrading, because the user asked for it explicitly.

Every client comes from the providers' trust builder, so one
`network.ca_bundle` governs every korvid-owned HTTPS client and an
unloadable bundle fails startup instead of falling back to default
trust.

Raises:
SystemExit: when a backend is configured but the extra or the
configured CA bundle is unusable.
"""
prometheus = config.observability_prometheus
loki = config.observability_loki
if prometheus is None and loki is None:
return ObservabilityWiring()
if _missing_extra_packages(_OBSERVABILITY_EXTRA_ROOTS):
raise SystemExit(f"korvid: {_OBSERVABILITY_INSTALL_HINT}")

from korvid.obs.connector import ConnectorError, QueryLimits
from korvid.providers import net

def limits(backend: ObservabilityBackend) -> QueryLimits:
return QueryLimits(
timeout_seconds=backend.timeout_seconds,
default_window_minutes=backend.default_window_minutes,
max_window_minutes=backend.max_window_minutes,
max_series=backend.max_series,
max_lines=backend.max_lines,
max_response_bytes=backend.max_response_bytes,
max_concurrency=backend.max_concurrency,
)

def client(backend: ObservabilityBackend) -> Any:
# Looked up through the module so a test (and a future refactor)
# sees one trust builder rather than a captured function.
try:
return net.make_client(config.network_ca_bundle, timeout=backend.timeout_seconds)
except ValueError as exc:
raise SystemExit(f"korvid: {exc}") from exc

try:
_validate_observability(prometheus, loki)
# Built before any client: `QueryLimits` validates as well, and it
# was evaluated *after* the client in the constructor's argument
# list — so a bad limit stranded a client nobody could close.
prometheus_limits = limits(prometheus) if prometheus is not None else None
loki_limits = limits(loki) if loki is not None else None
return _connectors(prometheus, loki, prometheus_limits, loki_limits, client)
except (ConnectorError, ValueError) as exc:
# Two refusal types, one outcome: a connector-level invariant
# (`ConnectorError`) and an unusable limit (`ValueError` from
# `QueryLimits`). Neither should reach the user as a traceback,
# and both mean the same thing to them.
raise SystemExit(f"korvid: observability configuration is unusable: {exc}") from exc


def _validate_observability(
prometheus: ObservabilityBackend | None, loki: ObservabilityBackend | None
) -> None:
"""Refuse an unusable configuration before any client is allocated.

The connectors validate in their constructors, but their arguments —
the HTTP client among them — are evaluated first, so a refusal there
would strand a client nobody can close.

Raises:
ConnectorError: `config` for anything a connector would refuse.
"""
from korvid.obs import loki as loki_module
from korvid.obs.http import validate_endpoint

if prometheus is not None:
validate_endpoint(prometheus.url, "prometheus")
if loki is not None:
validate_endpoint(loki.url, "loki")
loki_module.validate_options(tenant=loki.tenant, label_mappings=loki.label_mappings)


def _connectors(
prometheus: ObservabilityBackend | None,
loki: ObservabilityBackend | None,
prometheus_limits: Any,
loki_limits: Any,
client: Callable[[ObservabilityBackend], Any],
) -> ObservabilityWiring:
"""Construct whichever connectors are configured (see `_build_observability`)."""
from korvid.obs.loki import LokiConnector
from korvid.obs.prometheus import PrometheusConnector

metrics = (
PrometheusConnector(
prometheus.url,
client=client(prometheus),
limits=prometheus_limits,
token_env=prometheus.token_env,
token_file=prometheus.token_file,
mask_labels=frozenset(prometheus.mask_labels),
)
if prometheus is not None
else None
)
logs = (
LokiConnector(
loki.url,
client=client(loki),
limits=loki_limits,
token_env=loki.token_env,
token_file=loki.token_file,
tenant=loki.tenant,
label_mappings=loki.label_mappings,
mask_labels=frozenset(loki.mask_labels),
)
if loki is not None
else None
)
return ObservabilityWiring(metrics=metrics, logs=logs)


def _build_mcp_controller(
Expand Down Expand Up @@ -1110,6 +983,15 @@ def _construct_app_runtime(app: KorvidApp, inputs: AppRuntimeInputs) -> AppRunti
events=lambda: app._get_events,
),
)
config_propagation = ConfigPropagationController(
ui=AppUiSurface(app),
view=view,
context=context,
client=inputs.evidence_client,
navigate=lambda namespace, name, epoch, uid: workspace_ref.get().jump_to_object(
"pods", namespace, name, epoch=epoch, expected_uid=uid
),
)
bridge_dispatch = AppContextDispatch()
inspect_surface = AppInspectSurface(app)
inspect_controller = ResourceInspectController(
Expand Down Expand Up @@ -1393,6 +1275,7 @@ def _construct_app_runtime(app: KorvidApp, inputs: AppRuntimeInputs) -> AppRunti
keybindings=keybindings.unavailable_reason,
slots=slots.unavailable_reason,
outcomes=deployment_outcomes.unavailable_reason,
propagation=config_propagation.unavailable_reason,
),
)
commands = CommandRouter(
Expand All @@ -1406,6 +1289,7 @@ def _construct_app_runtime(app: KorvidApp, inputs: AppRuntimeInputs) -> AppRunti
keybindings=keybindings,
slots=slots,
outcomes=deployment_outcomes,
propagation=config_propagation,
)
return AppRuntime(
view=view,
Expand Down Expand Up @@ -1612,6 +1496,7 @@ async def fetch(
if (server := kube.connected_api_server()) is not None
else None
),
evidence_client=kube,
)
app = assemble_app_runtime(app)
app_box.append(app)
Expand Down
Loading
Loading