From aa48639eb708b7fcfc2146990b26fa41223dd695 Mon Sep 17 00:00:00 2001 From: hellices Date: Fri, 30 Oct 2026 02:08:07 +0900 Subject: [PATCH 01/21] refactor: build the observability connectors in composition_support The observability builders turn config into connectors and hold no wiring of their own, so they move next to ObservabilityWiring in composition_support, as earlier helpers did. __main__ keeps the _build_observability name it imports back, and the two tests that patched _missing_extra_packages on __main__ patch it where the builder now looks it up. No behaviour changes. This frees the composition root for the configuration propagation view's wiring. Part of #502. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/korvid/__main__.py | 134 +-------------------------- src/korvid/composition_support.py | 139 ++++++++++++++++++++++++++++- tests/test_observability_wiring.py | 8 +- 3 files changed, 145 insertions(+), 136 deletions(-) diff --git a/src/korvid/__main__.py b/src/korvid/__main__.py index e257ccac0..88a1bfc4c 100644 --- a/src/korvid/__main__.py +++ b/src/korvid/__main__.py @@ -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. @@ -78,7 +81,6 @@ KorvidConfig, ModelConnectionConfig, ModelConnectionsWriter, - ObservabilityBackend, load_config, ) from korvid.core.config_store import save_topbar_state @@ -228,136 +230,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( diff --git a/src/korvid/composition_support.py b/src/korvid/composition_support.py index fd98ebe50..282a26dc9 100644 --- a/src/korvid/composition_support.py +++ b/src/korvid/composition_support.py @@ -21,7 +21,12 @@ UiAction, UiActionResult, ) -from korvid.core.config import KorvidConfig, ModelConnectionConfig, context_is_protected +from korvid.core.config import ( + KorvidConfig, + ModelConnectionConfig, + ObservabilityBackend, + context_is_protected, +) from korvid.core.mcp import MCPControllerBase from korvid.core.store import ALL_NAMESPACES, Summary from korvid.k8s.client import KubeClient @@ -110,6 +115,138 @@ async def aclose(self) -> None: await self.logs.aclose() +#: 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 _custom_column_names(config: KorvidConfig) -> dict[str, tuple[str, ...]]: """Return configured custom-column names by resource alias.""" return { diff --git a/tests/test_observability_wiring.py b/tests/test_observability_wiring.py index b7e1ce9f2..99bc20537 100644 --- a/tests/test_observability_wiring.py +++ b/tests/test_observability_wiring.py @@ -174,9 +174,9 @@ class TestMissingExtra: async def test_a_configured_backend_without_the_extra_fails_actionably( self, monkeypatch: pytest.MonkeyPatch ) -> None: - import korvid.__main__ as main + import korvid.composition_support as support - monkeypatch.setattr(main, "_missing_extra_packages", lambda roots: ["httpx"]) + monkeypatch.setattr(support, "_missing_extra_packages", lambda roots: ["httpx"]) requirement = f"korvid[all,entra]=={__version__}" with pytest.raises( SystemExit, @@ -194,9 +194,9 @@ async def test_a_configured_backend_without_the_extra_fails_actionably( async def test_no_configured_backend_without_the_extra_is_silent( self, monkeypatch: pytest.MonkeyPatch ) -> None: - import korvid.__main__ as main + import korvid.composition_support as support - monkeypatch.setattr(main, "_missing_extra_packages", lambda roots: ["httpx"]) + monkeypatch.setattr(support, "_missing_extra_packages", lambda roots: ["httpx"]) wiring = _build_observability(_config()) assert wiring.backends == frozenset() From ffd13116be67bfaddac32296e863debe9ea5fb1e Mon Sep 17 00:00:00 2001 From: hellices Date: Fri, 30 Oct 2026 02:09:52 +0900 Subject: [PATCH 02/21] ui: render evidence observations with one shared renderer evidence_text turns an observation into one line - what the source reads and where, its state, a sentence for its basis, and its age - so widgets never interpret a basis themselves. Every Basis has its own sentence, and denied, unsupported, unavailable and partial reads say different things. The line is marked stale by the source's own freshness policy, the one its declaration and conclusive() use, and never when nothing was observed. Cluster-provided names go into a plain Text, so a name containing markup is shown literally. Part of #502 (#486, S6 of #409). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/korvid/ui/evidence_text.py | 126 ++++++++++++++++++++++++++ tests/ui/test_evidence_text.py | 159 +++++++++++++++++++++++++++++++++ 2 files changed, 285 insertions(+) create mode 100644 src/korvid/ui/evidence_text.py create mode 100644 tests/ui/test_evidence_text.py diff --git a/src/korvid/ui/evidence_text.py b/src/korvid/ui/evidence_text.py new file mode 100644 index 000000000..8d2ab6961 --- /dev/null +++ b/src/korvid/ui/evidence_text.py @@ -0,0 +1,126 @@ +"""The one place evidence observations become text (#486, S6 of #409). + +Every diagnostic panel shows its sources the same way, one line each: + + : — · observed + +Widgets call these functions and never read a `Basis` themselves, so a +denied read, an API the cluster does not serve and a server error read the +same everywhere. Cluster-provided names go into a plain `Text`, which never +parses markup. +""" + +from __future__ import annotations + +from collections.abc import Mapping +from datetime import datetime +from types import MappingProxyType + +from rich.text import Text + +from korvid.core.capability import ( + ApiSource, + Basis, + DiscoverySource, + FreshnessPolicy, + Observation, + ScopeKind, + Source, + SourceScope, + is_fresh, +) + +#: One sentence for every basis. A new `Basis` without one fails the tests. +BASIS_SENTENCES: Mapping[Basis, str] = MappingProxyType( + { + Basis.NOT_STARTED: "not read yet", + Basis.IN_FLIGHT: "reading", + Basis.OBSERVED: "read", + Basis.OBJECT_ABSENT: "the object does not exist", + Basis.NO_DATA: "the provider returned no data", + Basis.TRUNCATED: "stopped at the page limit", + Basis.DEADLINE: "stopped at the deadline", + Basis.TOO_LARGE: "a response was too large to read", + Basis.CONTINUATION_EXPIRED: "the list changed while it was being read", + Basis.SUBSCOPE_FAILED: "part of the scope could not be read", + Basis.STALE_STATUS: "its status lags behind its specification", + Basis.NOT_SERVED: "the cluster does not serve this API", + Basis.OPERATION_UNSUPPORTED: "the API does not allow this read", + Basis.NO_KNOWN_VERSION: "the cluster serves only versions korvid cannot read", + Basis.PROJECTION_UNSUPPORTED: "the API cannot return metadata only", + Basis.PROVIDER_NOT_CONFIGURED: "no provider is configured", + Basis.FEATURE_DISABLED: "the feature is disabled", + Basis.FORBIDDEN: "your permissions do not allow this read", + Basis.UNAUTHENTICATED: "the cluster did not accept your credentials", + Basis.DISCOVERY_FORBIDDEN: "your permissions do not allow API discovery", + Basis.THROTTLED: "the API server asked korvid to slow down", + Basis.SERVER_ERROR: "the API server returned an error", + Basis.UNEXPECTED_STATUS: "the API server returned an unexpected status", + Basis.TRANSPORT: "the API server could not be reached", + Basis.MALFORMED: "the response could not be parsed", + Basis.DISCOVERY_FAILED: "API discovery failed", + Basis.DISCOVERY_STALE: "API discovery is out of date", + } +) + +_UNITS = ((86400, "d"), (3600, "h"), (60, "m"), (1, "s")) + + +def basis_sentence(basis: Basis) -> str: + """What `basis` means, as a sentence fragment a user can act on.""" + return BASIS_SENTENCES[basis] + + +def age_text(observed_at: datetime | None, now: datetime) -> str: + """How long ago an observation was made, in its largest whole unit.""" + if observed_at is None: + return "not observed" + seconds = (now - observed_at).total_seconds() + if seconds < 0: + return "observed in the future" + for size, unit in _UNITS: + if seconds >= size: + return f"observed {int(seconds // size)}{unit} ago" + return "observed just now" + + +def _scope_text(scope: SourceScope) -> str: + if scope.kind is ScopeKind.CLUSTER: + return "cluster-wide" + if scope.kind is ScopeKind.NAMESPACE: + return f"in namespace {scope.name}" + if scope.kind is ScopeKind.NODE: + return f"on node {scope.name}" + return f"from provider {scope.name}" + + +def source_text(source: Source) -> str: + """What a source reads, and where.""" + if isinstance(source, ApiSource): + api = source.group or "core" + if source.version: + api = f"{source.group}/{source.version}" if source.group else source.version + return f"{source.resource} {api} {_scope_text(source.scope)}" + if isinstance(source, DiscoverySource): + return f"discovery {source.path} {_scope_text(source.scope)}" + return f"{source.provider_id} {source.query_kind} {_scope_text(source.scope)}" + + +def observation_line(observation: Observation, policy: FreshnessPolicy, now: datetime) -> Text: + """One source's line: what it read, its state and why, and how old it is. + + `policy` is the source's own, the one its declaration and `conclusive` + use, so the line never calls fresh what the baseline treats as stale. + """ + line = ( + f"{source_text(observation.source)}: {observation.state.value} — " + f"{basis_sentence(observation.basis)} · {age_text(observation.observed_at, now)}" + ) + if observation.observed_at is not None and not is_fresh(observation, policy, now): + line = f"{line} · stale" + return Text(line) + + +def not_established(what: str) -> Text: + """A conclusion the evidence does not support, said as such.""" + return Text(f"Not established: {what}") diff --git a/tests/ui/test_evidence_text.py b/tests/ui/test_evidence_text.py new file mode 100644 index 000000000..efde5b9ad --- /dev/null +++ b/tests/ui/test_evidence_text.py @@ -0,0 +1,159 @@ +"""The shared evidence renderer (#486, S6 of #409).""" + +from __future__ import annotations + +from datetime import UTC, datetime, timedelta + +import pytest +from rich.text import Text + +from korvid.core.capability import ( + ApiSource, + Basis, + DiscoverySource, + EvidenceState, + FreshnessPolicy, + Observation, + ProviderSource, + Source, + SourceScope, + Verb, +) +from korvid.ui.evidence_text import ( + age_text, + basis_sentence, + not_established, + observation_line, +) + +NOW = datetime(2026, 10, 30, 9, 0, tzinfo=UTC) +POLICY = FreshnessPolicy() +CONFIG_MAPS = ApiSource("", "configmaps", SourceScope.namespace("team-a"), "v1", Verb.LIST) + + +def _observed(source: Source, state: EvidenceState, basis: Basis, seconds: float) -> Observation: + return Observation(source, state, basis, NOW - timedelta(seconds=seconds)) + + +def test_every_basis_has_its_own_sentence() -> None: + sentences = [basis_sentence(basis) for basis in Basis] + + assert all(sentences) + assert len(set(sentences)) == len(Basis) + + +@pytest.mark.parametrize( + ("basis", "words"), + [ + (Basis.FORBIDDEN, "permissions"), + (Basis.NOT_SERVED, "does not serve"), + (Basis.SERVER_ERROR, "returned an error"), + (Basis.TRUNCATED, "page limit"), + (Basis.DEADLINE, "deadline"), + (Basis.TOO_LARGE, "too large"), + (Basis.CONTINUATION_EXPIRED, "changed while"), + ], +) +def test_denied_unsupported_unavailable_and_partial_read_differently( + basis: Basis, words: str +) -> None: + assert words in basis_sentence(basis) + + +def test_an_api_line_names_the_resource_version_scope_state_basis_and_age() -> None: + observation = _observed(CONFIG_MAPS, EvidenceState.AVAILABLE, Basis.OBSERVED, 2) + + line = observation_line(observation, POLICY, NOW) + + assert isinstance(line, Text) + assert line.plain == "configmaps v1 in namespace team-a: available — read · observed 2s ago" + + +def test_a_grouped_cluster_wide_source_names_its_group() -> None: + events = ApiSource("events.k8s.io", "events", SourceScope.cluster(), "v1", Verb.LIST) + observation = _observed(events, EvidenceState.DENIED, Basis.FORBIDDEN, 5) + + assert observation_line(observation, POLICY, NOW).plain == ( + "events events.k8s.io/v1 cluster-wide: denied — " + "your permissions do not allow this read · observed 5s ago" + ) + + +def test_an_unresolved_source_names_its_group_without_a_version() -> None: + events = ApiSource("events.k8s.io", "events", SourceScope.namespace("a")) + observation = _observed(events, EvidenceState.UNSUPPORTED, Basis.NOT_SERVED, 1) + + assert observation_line(observation, POLICY, NOW).plain.startswith( + "events events.k8s.io in namespace a: unsupported — " + ) + + +def test_discovery_and_provider_sources_name_what_they_read() -> None: + discovery = _observed( + DiscoverySource.group_version("apps", "v1"), + EvidenceState.UNAVAILABLE, + Basis.DISCOVERY_FAILED, + 3, + ) + provider = _observed( + ProviderSource("prometheus", "cpu", SourceScope.node("n1")), + EvidenceState.UNSUPPORTED, + Basis.PROVIDER_NOT_CONFIGURED, + 3, + ) + + assert observation_line(discovery, POLICY, NOW).plain.startswith( + "discovery /apis/apps/v1 cluster-wide: unavailable — API discovery failed" + ) + assert observation_line(provider, POLICY, NOW).plain.startswith( + "prometheus cpu on node n1: unsupported — no provider is configured" + ) + + +def test_staleness_follows_the_sources_own_policy() -> None: + observation = _observed(CONFIG_MAPS, EvidenceState.AVAILABLE, Basis.OBSERVED, 60) + short = FreshnessPolicy(max_age=timedelta(seconds=30)) + long = FreshnessPolicy(max_age=timedelta(minutes=5)) + + assert observation_line(observation, short, NOW).plain.endswith("observed 1m ago · stale") + assert observation_line(observation, long, NOW).plain.endswith("observed 1m ago") + + +def test_an_observation_never_made_is_not_called_stale() -> None: + observation = Observation(CONFIG_MAPS, EvidenceState.UNKNOWN, Basis.NOT_STARTED) + + assert observation_line(observation, POLICY, NOW).plain == ( + "configmaps v1 in namespace team-a: unknown — not read yet · not observed" + ) + + +@pytest.mark.parametrize( + ("seconds", "text"), + [ + (0.4, "observed just now"), + (59, "observed 59s ago"), + (61, "observed 1m ago"), + (2 * 3600 + 5, "observed 2h ago"), + (3 * 86400, "observed 3d ago"), + (-5, "observed in the future"), + ], +) +def test_ages_are_whole_units(seconds: float, text: str) -> None: + assert age_text(NOW - timedelta(seconds=seconds), NOW) == text + + +def test_cluster_provided_names_are_rendered_literally() -> None: + hostile = ApiSource("", "configmaps", SourceScope.namespace("[bold]x[/bold]"), "v1", Verb.LIST) + observation = _observed(hostile, EvidenceState.AVAILABLE, Basis.OBSERVED, 1) + + line = observation_line(observation, POLICY, NOW) + + assert "in namespace [bold]x[/bold]:" in line.plain + assert line.spans == [] + + +def test_not_established_says_why() -> None: + line = not_established("whether every consumer is listed") + + assert line.plain == "Not established: whether every consumer is listed" + assert not_established("[red]x").plain == "Not established: [red]x" From a2297961cb67beccc707c22e7a8004febe4b6733 Mon Sep 17 00:00:00 2001 From: hellices Date: Fri, 30 Oct 2026 02:12:30 +0900 Subject: [PATCH 03/21] ui: show a ConfigMap's or Secret's consumers by mode ConfigPropagationScreen shows one PropagationReport: the object's and the Pod list's lines from the shared renderer, then the consumers under a heading per mode saying what a change means - environment variables and subPath mounts need a rollout restart, volumes are updated eventually unless the object is immutable, unmounted volumes and image pull secrets read nothing at run time - then what the evidence does not establish. "No Pod references it" appears only when the Pod list is conclusive; otherwise the view says why the consumer set is not established. A gone or recreated object is said, and immutability is never claimed either way. Enter on a consumer row returns it so the caller can open its Pod; a heading does nothing. Every cell is plain Text. Part of #502. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../ui/widgets/config_propagation_screen.py | 260 +++++++++++++++++ tests/ui/test_config_propagation_screen.py | 271 ++++++++++++++++++ 2 files changed, 531 insertions(+) create mode 100644 src/korvid/ui/widgets/config_propagation_screen.py create mode 100644 tests/ui/test_config_propagation_screen.py diff --git a/src/korvid/ui/widgets/config_propagation_screen.py b/src/korvid/ui/widgets/config_propagation_screen.py new file mode 100644 index 000000000..f895f28c6 --- /dev/null +++ b/src/korvid/ui/widgets/config_propagation_screen.py @@ -0,0 +1,260 @@ +"""Which Pods consume a ConfigMap or Secret, and what a change means for each (#410). + +`ConfigPropagationScreen` shows one `PropagationReport`: a line per evidence +source from the shared renderer, then the consumers grouped by how they +consume the object, then what the evidence does not establish. It performs +no I/O. Enter on a consumer row dismisses with that `Consumer`, so the +caller can open its Pod; Escape dismisses with `None`. + +Every cell is a plain `Text`, so a cluster-provided name containing markup +is shown literally. +""" + +from __future__ import annotations + +import re +from collections.abc import Sequence +from dataclasses import dataclass +from datetime import datetime +from typing import ClassVar + +from rich.text import Text +from textual.app import ComposeResult +from textual.binding import Binding +from textual.screen import ModalScreen +from textual.widgets import DataTable, Footer, Static + +from korvid.core.capability import EvidenceState, is_fresh +from korvid.core.config_consumers import Consumer, ConsumptionMode, ContainerCategory +from korvid.core.config_propagation import PropagationReport, declarations +from korvid.ui.evidence_text import basis_sentence, not_established, observation_line + +#: The sections, in display order, with what a change means for each. +_SECTIONS: tuple[tuple[frozenset[ConsumptionMode], str], ...] = ( + ( + frozenset({ConsumptionMode.ENV_FROM, ConsumptionMode.ENV_KEY}), + "Environment variables — read when the container starts; a change needs a " + "rollout restart, which asks for your approval", + ), + ( + frozenset({ConsumptionMode.VOLUME}), + "Volumes — updated in place by the kubelet, eventually, unless the object is " + "immutable; whether the application rereads them is not verified", + ), + ( + frozenset({ConsumptionMode.SUBPATH}), + "subPath mounts — never updated; a change needs a rollout restart, which asks " + "for your approval", + ), + ( + frozenset({ConsumptionMode.UNMOUNTED}), + "Unmounted volumes — no container mounts them, so nothing reads them", + ), + ( + frozenset({ConsumptionMode.IMAGE_PULL}), + "Image pull secrets — used only when an image is pulled", + ), +) +_COLUMNS = ("Pod", "Container", "Reference") +_IN_CONTAINER = re.compile(r"^spec\.(?:containers|initContainers|ephemeralContainers)\[\d+\]\.") +_INIT_NOTE = ( + "An init container runs to completion before the other containers start; once it " + "has finished, a change reaches it only when the Pod restarts." +) + + +@dataclass(frozen=True, slots=True) +class Heading: + """A section heading: the mode, then what a change means for it.""" + + text: str + + +@dataclass(frozen=True, slots=True) +class ConsumerRow: + """One consumer and its Pod, Container and Reference cells.""" + + consumer: Consumer + cells: tuple[str, str, str] + + +TableRow = Heading | ConsumerRow + + +def _container_cell(consumer: Consumer) -> str: + if consumer.category is None: + return "— (Pod)" + name = consumer.container or "?" + if consumer.category is ContainerCategory.CONTAINER: + return name + return f"{name} ({consumer.category.value})" + + +def _volume_text(consumer: Consumer) -> str: + volume = f"volume {consumer.volume or '(unnamed)'}" + if consumer.mode is ConsumptionMode.UNMOUNTED: + return volume + text = f"{volume} → {consumer.mount_path or '?'}" + if consumer.sub_path is not None: + text = f"{text} (subPath {consumer.sub_path})" + return text + + +def _reference_cell(consumer: Consumer) -> str: + relative = _IN_CONTAINER.sub("", consumer.path) + if consumer.mode is ConsumptionMode.ENV_KEY: + variable = relative.split(".valueFrom", 1)[0] + text = f"{variable} {consumer.env_name or '?'} ← key {consumer.key or '?'}" + elif consumer.mode is ConsumptionMode.ENV_FROM: + text = relative + elif consumer.mode is ConsumptionMode.IMAGE_PULL: + text = consumer.path.removeprefix("spec.") + else: + text = _volume_text(consumer) + return f"{text} (optional)" if consumer.optional else text + + +def table_rows(consumers: Sequence[Consumer]) -> list[TableRow]: + """The consumers under their section headings; an empty section is left out.""" + rows: list[TableRow] = [] + for modes, heading in _SECTIONS: + section = [consumer for consumer in consumers if consumer.mode in modes] + if section: + rows.append(Heading(heading)) + rows += ( + ConsumerRow(c, (c.pod.name, _container_cell(c), _reference_cell(c))) + for c in section + ) + return rows + + +def evidence_lines(report: PropagationReport, now: datetime) -> list[Text]: + """The object's and the Pod list's lines, each judged by its own policy.""" + policies = {declaration.key: declaration.policy for declaration in declarations(report.target)} + return [ + observation_line(report.object, policies["config-object"], now), + observation_line(report.pods, policies["pods"], now), + ] + + +def _object_notes(report: PropagationReport) -> list[Text]: + kind = report.target.kind + if report.object_absent: + tail = " The Pods below still reference it." if report.consumers else "" + return [Text(f"The {kind} no longer exists.{tail}")] + if report.recreated: + return [ + Text( + f"The {kind} was recreated since it was selected: only the new one's " + "evidence is shown, and whether existing volume mounts see it is " + "not established." + ) + ] + return [] + + +def _completeness_note(report: PropagationReport, now: datetime) -> Text | None: + target = report.target + if report.consumers_conclusive(now): + if report.consumers: + return None + return Text( + f"No Pod in namespace {target.namespace} references this {target.kind} " + "through environment variables, volumes or image pull secrets." + ) + pods = report.pods + stale = pods.state is EvidenceState.AVAILABLE and not is_fresh( + pods, declarations(target)[1].policy, now + ) + reason = "the Pod list is stale" if stale else basis_sentence(pods.basis) + return not_established(f"whether every consumer is listed: {reason}") + + +def report_notes(report: PropagationReport, now: datetime) -> list[Text]: + """What the evidence says about the object, and what it does not establish.""" + notes = _object_notes(report) + if any(consumer.category is ContainerCategory.INIT for consumer in report.consumers): + notes.append(Text(_INIT_NOTE)) + completeness = _completeness_note(report, now) + if completeness is not None: + notes.append(completeness) + notes.append( + not_established( + f"whether the {report.target.kind} is immutable (only its metadata is read)" + ) + ) + return notes + + +class ConfigPropagationScreen(ModalScreen[Consumer | None]): + """One ConfigMap's or Secret's consumers, by mode, with their evidence.""" + + BINDINGS: ClassVar[list[Binding | tuple[str, str] | tuple[str, str, str]]] = [ + Binding("escape", "close", "Close", show=True), + Binding("q", "close", "Close", show=False), + ] + + DEFAULT_CSS = """ + ConfigPropagationScreen { + layout: vertical; + background: $background; + } + ConfigPropagationScreen #propagation-title { + padding: 0 1; + text-style: bold; + } + ConfigPropagationScreen #propagation-evidence, ConfigPropagationScreen #propagation-notes { + padding: 0 1; + color: $text-muted; + } + ConfigPropagationScreen DataTable { + height: 1fr; + } + """ + + def __init__(self, report: PropagationReport, now: datetime) -> None: + super().__init__() + self.report = report + self._now = now + self._rows: dict[str, Consumer] = {} + + def compose(self) -> ComposeResult: + target = self.report.target + yield Footer() + yield Static( + f"{target.kind} {target.namespace}/{target.name}", + id="propagation-title", + markup=False, + ) + yield Static( + Text("\n").join(evidence_lines(self.report, self._now)), id="propagation-evidence" + ) + yield DataTable[Text](id="propagation-table") + yield Static(Text("\n").join(report_notes(self.report, self._now)), id="propagation-notes") + + def on_mount(self) -> None: + table: DataTable[Text] = self.query_one(DataTable) + table.cursor_type = "row" + table.add_columns(*_COLUMNS) + first: int | None = None + for index, row in enumerate(table_rows(self.report.consumers)): + if isinstance(row, Heading): + title, _, meaning = row.text.partition(" — ") + table.add_row(Text(title, style="bold"), Text(""), Text(meaning), key=f"h{index}") + continue + key = f"r{index}" + self._rows[key] = row.consumer + table.add_row(*(Text(cell) for cell in row.cells), key=key) + first = index if first is None else first + if first is not None: + table.move_cursor(row=first) + table.focus() + + def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None: + """Enter on a consumer row returns it; a heading does nothing.""" + consumer = self._rows.get(str(event.row_key.value)) + if consumer is not None: + self.dismiss(consumer) + + def action_close(self) -> None: + self.dismiss(None) diff --git a/tests/ui/test_config_propagation_screen.py b/tests/ui/test_config_propagation_screen.py new file mode 100644 index 000000000..00526907b --- /dev/null +++ b/tests/ui/test_config_propagation_screen.py @@ -0,0 +1,271 @@ +"""The configuration propagation view (#410): rows, notes and keys.""" + +from __future__ import annotations + +from datetime import UTC, datetime, timedelta + +from rich.text import Text +from textual.app import App +from textual.widgets import DataTable, Static + +from korvid.core.capability import ( + ApiSource, + Baseline, + Basis, + EvidenceState, + Observation, + Projection, + SourceScope, + Verb, +) +from korvid.core.config_consumers import ( + Consumer, + ConsumptionMode, + ContainerCategory, + PodIdentity, +) +from korvid.core.config_propagation import ConfigTarget, PropagationReport +from korvid.ui.widgets.config_propagation_screen import ( + ConfigPropagationScreen, + ConsumerRow, + Heading, + evidence_lines, + report_notes, + table_rows, +) + +from .waits import screen_ready, until + +NOW = datetime(2026, 10, 30, 9, 0, tzinfo=UTC) +TARGET = ConfigTarget("ConfigMap", "team-a", "settings", "uid-settings") +SCOPE = SourceScope.namespace("team-a") +OBJECT = ApiSource( + "", + "configmaps", + SCOPE, + "v1", + Verb.GET, + projection=Projection.METADATA, + name="settings", + expected_uid="uid-settings", +) +PODS = ApiSource("", "pods", SCOPE, "v1", Verb.LIST) +POD = PodIdentity("team-a", "api-1", "uid-api-1", "ReplicaSet", "api-7d9f") + + +def _seen( + source: ApiSource, basis: Basis = Basis.OBSERVED, observed_uid: str | None = None +) -> Observation: + state = { + Basis.OBSERVED: EvidenceState.AVAILABLE, + Basis.OBJECT_ABSENT: EvidenceState.AVAILABLE, + Basis.TRUNCATED: EvidenceState.PARTIAL, + Basis.FORBIDDEN: EvidenceState.DENIED, + }[basis] + return Observation(source, state, basis, NOW - timedelta(seconds=2), observed_uid=observed_uid) + + +def _report( + *consumers: Consumer, + object_basis: Basis = Basis.OBSERVED, + pods_basis: Basis = Basis.OBSERVED, + observed_uid: str | None = "uid-settings", +) -> PropagationReport: + uid = observed_uid if object_basis is Basis.OBSERVED else None + return PropagationReport( + TARGET, + _seen(OBJECT, object_basis, uid), + _seen(PODS, pods_basis), + consumers, + Baseline(EvidenceState.AVAILABLE), + ) + + +ENV = Consumer( + POD, + ConsumptionMode.ENV_FROM, + "spec.containers[0].envFrom[0].configMapRef", + False, + "app", + ContainerCategory.CONTAINER, +) +KEY = Consumer( + POD, + ConsumptionMode.ENV_KEY, + "spec.containers[0].env[1].valueFrom.configMapKeyRef", + True, + "app", + ContainerCategory.CONTAINER, + env_name="LOG_LEVEL", + key="log-level", +) +VOLUME = Consumer( + POD, + ConsumptionMode.VOLUME, + "spec.volumes[0].configMap", + False, + "app", + ContainerCategory.CONTAINER, + volume="config", + mount_path="/etc/app", +) +SUBPATH = Consumer( + POD, + ConsumptionMode.SUBPATH, + "spec.volumes[0].configMap", + False, + "setup", + ContainerCategory.INIT, + volume="config", + mount_path="/etc/app.yaml", + sub_path="app.yaml", +) +UNMOUNTED = Consumer(POD, ConsumptionMode.UNMOUNTED, "spec.volumes[1].configMap", volume="spare") + + +def _plain(lines: list[Text]) -> list[str]: + return [line.plain for line in lines] + + +def test_rows_are_grouped_by_mode_under_one_heading_each() -> None: + rows = table_rows((VOLUME, ENV, SUBPATH, KEY)) + + assert [type(row).__name__ for row in rows] == [ + "Heading", + "ConsumerRow", + "ConsumerRow", + "Heading", + "ConsumerRow", + "Heading", + "ConsumerRow", + ] + headings = [row.text for row in rows if isinstance(row, Heading)] + assert headings[0].startswith("Environment variables — read when the container starts") + assert headings[1].startswith("Volumes — updated in place by the kubelet, eventually") + assert "unless the object is immutable" in headings[1] + assert headings[2].startswith("subPath mounts — never updated") + assert [row.consumer for row in rows if isinstance(row, ConsumerRow)] == [ + ENV, + KEY, + VOLUME, + SUBPATH, + ] + + +def test_a_row_names_the_pod_container_and_reference_but_no_value() -> None: + rows = { + row.consumer: row.cells + for row in table_rows((KEY, VOLUME, SUBPATH, UNMOUNTED)) + if isinstance(row, ConsumerRow) + } + + assert rows[KEY] == ("api-1", "app", "env[1] LOG_LEVEL ← key log-level (optional)") + assert rows[VOLUME] == ("api-1", "app", "volume config → /etc/app") + assert rows[SUBPATH] == ( + "api-1", + "setup (init container)", + "volume config → /etc/app.yaml (subPath app.yaml)", + ) + assert rows[UNMOUNTED] == ("api-1", "— (Pod)", "volume spare") + + +def test_the_evidence_lines_come_from_the_shared_renderer() -> None: + lines = _plain(evidence_lines(_report(ENV), NOW)) + + assert lines == [ + "configmaps v1 in namespace team-a: available — read · observed 2s ago", + "pods v1 in namespace team-a: available — read · observed 2s ago", + ] + + +def test_no_consumer_is_said_only_when_the_pod_list_is_conclusive() -> None: + complete = _plain(report_notes(_report(), NOW)) + truncated = _plain(report_notes(_report(pods_basis=Basis.TRUNCATED), NOW)) + denied = _plain(report_notes(_report(pods_basis=Basis.FORBIDDEN), NOW)) + + assert any( + n.startswith("No Pod in namespace team-a references this ConfigMap") for n in complete + ) + assert not any(n.startswith("No Pod") for n in truncated + denied) + assert ( + "Not established: whether every consumer is listed: stopped at the page limit" in truncated + ) + assert ( + "Not established: whether every consumer is listed: your permissions do not allow this read" + ) in denied + + +def test_immutability_is_always_not_established() -> None: + notes = _plain(report_notes(_report(ENV), NOW)) + + assert ( + "Not established: whether the ConfigMap is immutable (only its metadata is read)" in notes + ) + + +def test_a_gone_or_recreated_object_is_said() -> None: + gone = _plain(report_notes(_report(ENV, object_basis=Basis.OBJECT_ABSENT), NOW)) + recreated = _plain(report_notes(_report(VOLUME, observed_uid="uid-new"), NOW)) + + assert any("no longer exists" in n for n in gone) + assert any("was recreated" in n and "volume mounts" in n for n in recreated) + assert not any("was recreated" in n for n in _plain(report_notes(_report(VOLUME), NOW))) + + +def test_an_init_container_row_explains_when_it_runs() -> None: + with_init = _plain(report_notes(_report(SUBPATH), NOW)) + without = _plain(report_notes(_report(ENV), NOW)) + + assert any(n.startswith("An init container runs to completion") for n in with_init) + assert not any(n.startswith("An init container") for n in without) + + +class _Host(App[None]): + def __init__(self, screen: ConfigPropagationScreen) -> None: + super().__init__() + self.prompt = screen + self.result: list[Consumer | None] = [] + + def on_mount(self) -> None: + self.push_screen(self.prompt, self.result.append) + + +async def test_enter_on_a_consumer_row_returns_that_consumer() -> None: + app = _Host(ConfigPropagationScreen(_report(ENV, VOLUME), NOW)) + async with app.run_test() as pilot: + await until(pilot, lambda: screen_ready(app, ConfigPropagationScreen), label="view open") + table = app.screen.query_one(DataTable) + assert table.cursor_row == 1 + await pilot.press("down", "down", "enter") + await until(pilot, lambda: bool(app.result), label="view closed") + + assert app.result == [VOLUME] + + +async def test_enter_on_a_heading_does_nothing_and_escape_closes() -> None: + app = _Host(ConfigPropagationScreen(_report(ENV), NOW)) + async with app.run_test() as pilot: + await until(pilot, lambda: screen_ready(app, ConfigPropagationScreen), label="view open") + await pilot.press("up", "enter") + await pilot.pause() + assert app.result == [] + await pilot.press("escape") + await until(pilot, lambda: bool(app.result), label="view closed") + + assert app.result == [None] + + +async def test_the_title_shows_cluster_names_literally() -> None: + hostile = ConfigTarget("ConfigMap", "team-a", "[bold]settings[/bold]") + report = PropagationReport( + hostile, + _seen(OBJECT, observed_uid="uid-settings"), + _seen(PODS), + (), + Baseline(EvidenceState.AVAILABLE), + ) + app = _Host(ConfigPropagationScreen(report, NOW)) + async with app.run_test() as pilot: + await until(pilot, lambda: screen_ready(app, ConfigPropagationScreen), label="view open") + title = app.screen.query_one("#propagation-title", Static) + assert "[bold]settings[/bold]" in str(title.render()) From b85256e5d6da78e6f0311e7b23f028fcee800bb0 Mon Sep 17 00:00:00 2001 From: hellices Date: Fri, 30 Oct 2026 02:14:24 +0900 Subject: [PATCH 04/21] k8s: name what a diagnostic needs of its client EvidenceClient extends EvidenceReader with discovery_snapshot() and the session generation, the three things a diagnostic run needs: it resolves its sources from discovery, reads them, and records the session in its run token. KubeClient implements it, so the UI can take a diagnostic client without depending on KubeClient itself. Part of #502. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/korvid/k8s/client.py | 2 +- src/korvid/k8s/evidence_requests.py | 19 +++++++++++++++++++ tests/k8s/test_evidence_requests.py | 7 +++++++ 3 files changed, 27 insertions(+), 1 deletion(-) diff --git a/src/korvid/k8s/client.py b/src/korvid/k8s/client.py index 98dcef969..539ea31e9 100644 --- a/src/korvid/k8s/client.py +++ b/src/korvid/k8s/client.py @@ -201,7 +201,7 @@ async def _refresh(configuration: k8s_client.Configuration) -> None: client_configuration.refresh_api_key_hook = _refresh -class KubeClient(ReadOps, WriteOps, pulse.PulseReader, evidence_requests.EvidenceReader): +class KubeClient(ReadOps, WriteOps, pulse.PulseReader, evidence_requests.EvidenceClient): """Thin wrapper over kubernetes_asyncio; returns typed summaries.""" def __init__( diff --git a/src/korvid/k8s/evidence_requests.py b/src/korvid/k8s/evidence_requests.py index e2ba9910c..655148137 100644 --- a/src/korvid/k8s/evidence_requests.py +++ b/src/korvid/k8s/evidence_requests.py @@ -21,6 +21,7 @@ decode_list_page, read_bounded_json, ) +from korvid.k8s.discovery_snapshot import RawDiscovery METADATA_LIST = "application/json;as=PartialObjectMetadataList;g=meta.k8s.io;v=v1" METADATA_OBJECT = "application/json;as=PartialObjectMetadata;g=meta.k8s.io;v=v1" @@ -69,6 +70,24 @@ async def get_evidence_object( """The object `name`, read within `max_bytes`.""" +class EvidenceClient(EvidenceReader): + """What a diagnostic needs of its client: evidence reads, discovery and the session. + + `KubeClient` is the implementation. A diagnostic resolves its sources from + `discovery_snapshot()` and records `session_generation` in its run token, + so a result read under other credentials is dropped. + """ + + @property + @abstractmethod + def session_generation(self) -> int: + """An opaque counter that changes on every connect and context switch.""" + + @abstractmethod + async def discovery_snapshot(self) -> RawDiscovery: + """This session's raw discovery.""" + + async def read_evidence_page( api: ApiClient | None, target: EvidenceTarget, diff --git a/tests/k8s/test_evidence_requests.py b/tests/k8s/test_evidence_requests.py index 17d5b463b..3e4fb1eca 100644 --- a/tests/k8s/test_evidence_requests.py +++ b/tests/k8s/test_evidence_requests.py @@ -15,6 +15,7 @@ from korvid.k8s.evidence_requests import ( METADATA_LIST, METADATA_OBJECT, + EvidenceClient, EvidencePage, EvidenceReader, EvidenceTarget, @@ -167,3 +168,9 @@ async def test_an_object_failure_keeps_the_status_for_the_classifier() -> None: await client.get_evidence_object(DEPLOYMENTS, "web", max_bytes=64) assert json.loads(failure.value.body) == status + + +def test_the_kubernetes_client_is_an_evidence_client() -> None: + """A diagnostic needs reads, discovery and the session generation from one client.""" + assert issubclass(KubeClient, EvidenceClient) + assert {"session_generation", "discovery_snapshot"} <= EvidenceClient.__abstractmethods__ From dd881516370f2a20f13301430f4d9445bf63d44b Mon Sep 17 00:00:00 2001 From: hellices Date: Fri, 30 Oct 2026 02:16:24 +0900 Subject: [PATCH 05/21] ui: run the propagation diagnostic for the selected object ConfigPropagationController is available on a selected ConfigMap or Secret in the core group, and says why it is not: no connection, the wrong view, or no selection. A refused source never disables it; the view says so instead. Opening it runs explain_propagation in one exclusive worker under a run token, and shows the view only while that token is current, so a context switch, new credentials or a newer run drops the result, as does a dialog that opened meanwhile. Choosing a consumer opens its Pod in the epoch the run started in, with the Pod's UID. cancel_workers() lets the context switch stop a run that holds the old client. Part of #502. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../ui/config_propagation_controller.py | 137 ++++++++ .../ui/test_config_propagation_controller.py | 296 ++++++++++++++++++ 2 files changed, 433 insertions(+) create mode 100644 src/korvid/ui/config_propagation_controller.py create mode 100644 tests/ui/test_config_propagation_controller.py diff --git a/src/korvid/ui/config_propagation_controller.py b/src/korvid/ui/config_propagation_controller.py new file mode 100644 index 000000000..56f9d016d --- /dev/null +++ b/src/korvid/ui/config_propagation_controller.py @@ -0,0 +1,137 @@ +"""Open the configuration propagation view for the selected ConfigMap or Secret (#410). + +One exclusive worker reads the object's metadata and its namespace's Pods +through `explain_propagation`, under a run token. The view opens only while +that token is current: a context switch, new credentials or a newer run +drops the result, as `RunTracker` decides, and so does a dialog that opened +meanwhile. The context-switch coordinator cancels the worker group before +it swaps the client. +""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable +from datetime import UTC, datetime + +from korvid.core.api_versions import observe_discovery +from korvid.core.capability import RunToken +from korvid.core.config_consumers import Consumer +from korvid.core.config_propagation import ConfigTarget, explain_propagation +from korvid.core.run_tokens import RunTracker +from korvid.k8s.evidence_requests import EvidenceClient +from korvid.ui.action_availability import AvailabilityCode, UnavailableReason +from korvid.ui.ui_surface import UiSurface +from korvid.ui.view_state import ViewState +from korvid.ui.widgets.config_propagation_screen import ConfigPropagationScreen +from korvid.ui.workspace_controller import ContextGuard + +CONFIG_PROPAGATION_GROUP = "config-propagation" +_KINDS = frozenset({"ConfigMap", "Secret"}) +_UNCONNECTED = UnavailableReason( + AvailabilityCode.MISSING_CAPABILITY, "Configuration propagation needs a cluster connection" +) +_WRONG_VIEW = UnavailableReason( + AvailabilityCode.WRONG_VIEW, "Open ConfigMaps or Secrets to explain their propagation" +) +_NO_SELECTION = UnavailableReason(AvailabilityCode.NO_SELECTION, "Select a ConfigMap or Secret") + +#: Opens a consumer's Pod: (namespace, name, epoch, expected UID). +PodNavigator = Callable[[str, str, int, str | None], Awaitable[None]] + + +def _now() -> datetime: + return datetime.now(UTC) + + +class ConfigPropagationController: + """Runs the propagation diagnostic and shows its result while it is current.""" + + def __init__( + self, + *, + ui: UiSurface, + view: ViewState, + context: ContextGuard, + client: EvidenceClient | None, + navigate: PodNavigator, + clock: Callable[[], datetime] = _now, + ) -> None: + self._ui = ui + self._view = view + self._context = context + self._client = client + self._navigate = navigate + self._clock = clock + self._tracker = RunTracker( + epoch=context.epoch, + session_generation=lambda: client.session_generation if client is not None else 0, + switching=context.switching, + ) + + def _selected_kind(self) -> str | None: + meta = self._view.aliases().get(self._view.canonical_kind(self._view.current_kind())) + if meta is None or meta.group or meta.kind not in _KINDS: + return None + return meta.kind + + def unavailable_reason(self) -> UnavailableReason | None: + """Why the view cannot open now, or None. + + Only for a missing connection, the wrong view or no selection: a + source the cluster refuses never disables it, the view says so. + """ + if self._client is None: + return _UNCONNECTED + if self._selected_kind() is None: + return _WRONG_VIEW + namespace, name = self._view.selected_ns_name(notify=False) + if not namespace or not name: + return _NO_SELECTION + return None + + def open_selected(self) -> None: + """Explain the selected ConfigMap or Secret in a worker.""" + reason = self.unavailable_reason() + if reason is not None: + self._ui.notify(reason.message, severity="warning") + return + kind = self._selected_kind() + namespace, name = self._view.selected_ns_name() + if not self._context.reads_allowed() or kind is None or not namespace or not name: + return + target = ConfigTarget(kind, namespace, name, self._view.selected_uid(namespace, name)) + token = self._tracker.next_run() + self._ui.run_worker( + self._explain(target, token, self._context.epoch()), + exclusive=True, + group=CONFIG_PROPAGATION_GROUP, + exit_on_error=False, + ) + + async def _explain(self, target: ConfigTarget, token: RunToken, epoch: int) -> None: + client = self._client + if client is None: + return + snapshot = observe_discovery(await client.discovery_snapshot()) + report = await explain_propagation(client, snapshot, target, token=token, clock=self._clock) + if self._ui.screen_depth() > 1: # another dialog opened during the reads + return + screen = ConfigPropagationScreen(report, self._clock()) + self._tracker.apply_if_current( + token, + lambda: self._ui.push_screen(screen, lambda chosen: self._open_pod(chosen, epoch)), + ) + + def _open_pod(self, consumer: Consumer | None, epoch: int) -> None: + if consumer is None: + return + pod = consumer.pod + self._ui.run_worker( + self._navigate(pod.namespace, pod.name, epoch, pod.uid), + group=CONFIG_PROPAGATION_GROUP, + exit_on_error=False, + ) + + async def cancel_workers(self) -> None: + """Stop a run holding the old client, before a context switch swaps it.""" + await self._ui.cancel_workers(CONFIG_PROPAGATION_GROUP) diff --git a/tests/ui/test_config_propagation_controller.py b/tests/ui/test_config_propagation_controller.py new file mode 100644 index 000000000..94cbc68b9 --- /dev/null +++ b/tests/ui/test_config_propagation_controller.py @@ -0,0 +1,296 @@ +"""The configuration propagation controller: availability, runs and late results (#410).""" + +from __future__ import annotations + +from collections.abc import Awaitable, Mapping +from datetime import UTC, datetime +from typing import Any + +from korvid.core.config_consumers import Consumer, ConsumptionMode, PodIdentity +from korvid.core.config_propagation import ConfigTarget +from korvid.k8s.discovery import ResourceMeta +from korvid.k8s.discovery_snapshot import ( + GroupVersionOutcome, + RawDiscovery, + RootOutcome, + ServedResource, +) +from korvid.k8s.evidence_requests import EvidenceClient, EvidencePage, EvidenceTarget +from korvid.ui.action_availability import AvailabilityCode +from korvid.ui.config_propagation_controller import ( + CONFIG_PROPAGATION_GROUP, + ConfigPropagationController, +) +from korvid.ui.view_state import ViewState +from korvid.ui.widgets.config_propagation_screen import ConfigPropagationScreen +from korvid.ui.workspace_controller import ContextGuard + +from .test_session_timeline_controller import FakeUiSurface + +NOW = datetime(2026, 10, 30, 9, 0, tzinfo=UTC) +CONFIG_MAPS = ResourceMeta("ConfigMap", "configmaps", "", "v1", True) +SECRETS = ResourceMeta("Secret", "secrets", "", "v1", True) +PODS = ResourceMeta("Pod", "pods", "", "v1", True) +FOREIGN = ResourceMeta("ConfigMap", "configmaps", "vendor.example.com", "v1", True) +SETTINGS_POD = { + "metadata": {"name": "api-1", "namespace": "team-a", "uid": "uid-api-1"}, + "spec": {"containers": [{"name": "app", "envFrom": [{"configMapRef": {"name": "settings"}}]}]}, +} + + +class FakeView(ViewState): + def __init__(self, kind: str = "configmaps", selected: tuple[str, str] | None = None) -> None: + self.kind = kind + self.selected = selected + self.alias_map = { + "configmaps": CONFIG_MAPS, + "secrets": SECRETS, + "pods": PODS, + "configmaps.vendor.example.com": FOREIGN, + } + + def current_kind(self) -> str: + return self.kind + + def canonical_kind(self, kind: str) -> str: + return kind + + def aliases(self) -> Mapping[str, ResourceMeta]: + return self.alias_map + + def selected_ns_name(self, *, notify: bool = True) -> tuple[str | None, str | None]: + return self.selected if self.selected is not None else (None, None) + + def selected_uid(self, namespace: str | None, name: str) -> str | None: + return f"uid-{name}" + + def current_scope(self) -> str: + return "team-a" # pragma: no cover + + def resources(self, kind: str, scope: str) -> list[Any]: + return [] # pragma: no cover + + def readonly(self) -> bool: + return False # pragma: no cover + + def default_namespace(self) -> str | None: + return None # pragma: no cover + + def gvr_label(self, meta: ResourceMeta) -> str: + return meta.plural # pragma: no cover + + def write_locus(self, namespace: str | None) -> str: + return "" # pragma: no cover + + +class FakeContext(ContextGuard): + def __init__(self) -> None: + self.current = 1 + self.in_switch = False + self.allowed = True + + def epoch(self) -> int: + return self.current + + def switching(self) -> bool: + return self.in_switch + + def reads_allowed(self) -> bool: + return self.allowed + + def crossed(self, epoch: int) -> bool: + return self.in_switch or epoch != self.current + + +class FakeClient(EvidenceClient): + def __init__(self) -> None: + self.generation = 3 + self.gets: list[tuple[EvidenceTarget, str]] = [] + + @property + def session_generation(self) -> int: + return self.generation + + async def discovery_snapshot(self) -> RawDiscovery: + served = tuple( + ServedResource(name, frozenset({"get", "list"})) + for name in ("configmaps", "secrets", "pods") + ) + return RawDiscovery( + self.generation, + NOW, + 0.0, + RootOutcome("/api", (("", "v1"),)), + RootOutcome("/apis", ()), + (GroupVersionOutcome("", "v1", served),), + ) + + async def list_evidence_page( + self, target: EvidenceTarget, *, limit: int, continuation: str, max_bytes: int + ) -> EvidencePage: + return EvidencePage((SETTINGS_POD,)) + + async def get_evidence_object( + self, target: EvidenceTarget, name: str, *, max_bytes: int + ) -> dict[str, Any]: + self.gets.append((target, name)) + return {"metadata": {"name": name, "namespace": "team-a", "uid": f"uid-{name}"}} + + +class Harness: + def __init__(self, *, client: FakeClient | None = None, view: FakeView | None = None) -> None: + self.ui = FakeUiSurface(close_worker_coroutines=False) + self.view = view or FakeView(selected=("team-a", "settings")) + self.context = FakeContext() + self.client = FakeClient() if client is None else client + self.navigations: list[tuple[str, str, int, str | None]] = [] + self.controller = ConfigPropagationController( + ui=self.ui, + view=self.view, + context=self.context, + client=self.client, + navigate=self._navigate, + clock=lambda: NOW, + ) + + async def _navigate(self, namespace: str, name: str, epoch: int, uid: str | None) -> None: + self.navigations.append((namespace, name, epoch, uid)) + + async def run(self, index: int = -1) -> None: + work = self.ui.workers[index] + assert isinstance(work, Awaitable) + await work + + +def _code(harness: Harness) -> AvailabilityCode | None: + reason = harness.controller.unavailable_reason() + return None if reason is None else reason.code + + +def test_it_is_available_on_a_selected_config_map_or_secret() -> None: + assert _code(Harness()) is None + assert _code(Harness(view=FakeView("secrets", ("team-a", "creds")))) is None + + +def test_it_says_why_it_is_unavailable() -> None: + unconnected = ConfigPropagationController( + ui=FakeUiSurface(), + view=FakeView(selected=("team-a", "settings")), + context=FakeContext(), + client=None, + navigate=Harness()._navigate, + ) + + reason = unconnected.unavailable_reason() + assert reason is not None + assert reason.code is AvailabilityCode.MISSING_CAPABILITY + assert _code(Harness(view=FakeView("pods", ("team-a", "api-1")))) is AvailabilityCode.WRONG_VIEW + assert ( + _code(Harness(view=FakeView("configmaps.vendor.example.com", ("team-a", "x")))) + is AvailabilityCode.WRONG_VIEW + ) + assert _code(Harness(view=FakeView("configmaps", None))) is AvailabilityCode.NO_SELECTION + + +async def test_opening_reads_the_selected_object_and_shows_its_consumers() -> None: + harness = Harness() + + harness.controller.open_selected() + await harness.run() + + assert harness.ui.worker_groups == [(CONFIG_PROPAGATION_GROUP, False)] + (screen,) = harness.ui.screens + assert isinstance(screen, ConfigPropagationScreen) + assert screen.report.target == ConfigTarget("ConfigMap", "team-a", "settings", "uid-settings") + assert [(c.pod.name, c.mode) for c in screen.report.consumers] == [ + ("api-1", ConsumptionMode.ENV_FROM) + ] + assert [(target.resource, name) for target, name in harness.client.gets] == [ + ("configmaps", "settings") + ] + + +def test_opening_where_it_is_unavailable_says_why_and_reads_nothing() -> None: + harness = Harness(view=FakeView("pods", ("team-a", "api-1"))) + + harness.controller.open_selected() + + assert harness.ui.workers == [] + assert [n.severity for n in harness.ui.notifications] == ["warning"] + + +def test_opening_while_reads_are_held_reads_nothing() -> None: + harness = Harness() + harness.context.allowed = False + + harness.controller.open_selected() + + assert harness.ui.workers == [] + + +async def test_a_newer_run_drops_an_older_result() -> None: + harness = Harness() + + harness.controller.open_selected() + harness.controller.open_selected() + await harness.run(0) + + assert harness.ui.screens == [] + await harness.run(1) + assert len(harness.ui.screens) == 1 + + +async def test_a_context_switch_drops_the_result() -> None: + harness = Harness() + + harness.controller.open_selected() + harness.context.current = 2 + await harness.run() + + assert harness.ui.screens == [] + + +async def test_new_credentials_drop_the_result() -> None: + harness = Harness() + + harness.controller.open_selected() + harness.client.generation = 4 + await harness.run() + + assert harness.ui.screens == [] + + +async def test_choosing_a_consumer_opens_its_pod_in_the_run_epoch() -> None: + harness = Harness() + harness.controller.open_selected() + await harness.run() + consumer = Consumer( + PodIdentity("team-a", "api-1", "uid-api-1"), ConsumptionMode.ENV_FROM, "spec.x" + ) + + assert harness.ui.screen_callback is not None + harness.ui.screen_callback(None) + assert len(harness.ui.workers) == 1 + harness.context.current = 2 + harness.ui.screen_callback(consumer) + await harness.run() + + assert harness.navigations == [("team-a", "api-1", 1, "uid-api-1")] + + +async def test_a_dialog_opened_during_the_reads_keeps_the_view_closed() -> None: + harness = Harness() + harness.controller.open_selected() + harness.ui.screen_depth = lambda: 2 # type: ignore[method-assign] # a dialog opened + + await harness.run() + + assert harness.ui.screens == [] + + +async def test_the_worker_group_is_cancelled_on_request() -> None: + harness = Harness() + + await harness.controller.cancel_workers() + + assert harness.ui.cancelled_groups == [CONFIG_PROPAGATION_GROUP] From 09185791b164eaa975253a2c49c4f32ffc688707 Mon Sep 17 00:00:00 2001 From: hellices Date: Fri, 30 Oct 2026 02:35:57 +0900 Subject: [PATCH 06/21] ui: open the propagation view with :consumers The :consumers command (also :config-consumers), and its palette entry Explain configuration propagation, open the view on the selected ConfigMap or Secret. It has no default key; the palette greys it out with the controller's own reason. :propagation would have made 'prop' ambiguous with :proposals in the command bar, so it is not an alias. The composition root hands the session's KubeClient to the controller as its EvidenceClient, and the context switch cancels the controller's worker group right after the workspace quiesce, before the client swaps. An unavailable reason is shown without markup, like every other owner's. Part of #502. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/korvid/__main__.py | 13 ++ src/korvid/ui/action_policy.py | 5 + src/korvid/ui/app.py | 3 + src/korvid/ui/app_runtime.py | 3 + src/korvid/ui/command.py | 16 ++ src/korvid/ui/command_router.py | 17 +++ .../ui/config_propagation_controller.py | 2 +- src/korvid/ui/context_switch_coordinator.py | 4 + src/korvid/ui/messages.py | 1 + tests/test_main_wiring.py | 20 +++ tests/ui/test_action_policy.py | 22 +++ tests/ui/test_command_router.py | 30 ++++ tests/ui/test_config_propagation_workflow.py | 144 ++++++++++++++++++ tests/ui/test_context_switch_coordinator.py | 2 + 14 files changed, 281 insertions(+), 1 deletion(-) create mode 100644 tests/ui/test_config_propagation_workflow.py diff --git a/src/korvid/__main__.py b/src/korvid/__main__.py index 88a1bfc4c..57550bdbb 100644 --- a/src/korvid/__main__.py +++ b/src/korvid/__main__.py @@ -138,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 @@ -982,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( @@ -1265,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( @@ -1278,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, @@ -1484,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) diff --git a/src/korvid/ui/action_policy.py b/src/korvid/ui/action_policy.py index 105d41d90..7d1c63abb 100644 --- a/src/korvid/ui/action_policy.py +++ b/src/korvid/ui/action_policy.py @@ -210,6 +210,7 @@ def compose_command_reasons( keybindings: Callable[[], UnavailableReason | None] | None = None, slots: Callable[[], UnavailableReason | None] | None = None, outcomes: Callable[[], UnavailableReason | None] | None = None, + propagation: Callable[[], UnavailableReason | None] | None = None, ) -> dict[str, Callable[[], UnavailableReason | None]]: """Build `ActionPolicy(reason_by_command=...)` from its owners. @@ -254,6 +255,9 @@ def compose_command_reasons( keybindings: The keybinding editor's protected-surface and persistence probe. slots: `NamespaceSlotController.unavailable_reason` - whether `:slots` has a listing to reallocate from and a surface to preview on. + propagation: `ConfigPropagationController.unavailable_reason` - + whether `:consumers` has a connection and a selected ConfigMap or + Secret to explain. Returns: The canonical command text -> reason-resolver map. @@ -281,6 +285,7 @@ def model_reason() -> UnavailableReason | None: **({"keys": keybindings} if keybindings is not None else {}), **({"slots": slots} if slots is not None else {}), **({"outcomes": outcomes} if outcomes is not None else {}), + **({"consumers": propagation} if propagation is not None else {}), } diff --git a/src/korvid/ui/app.py b/src/korvid/ui/app.py index b7a31fe99..2380915f7 100644 --- a/src/korvid/ui/app.py +++ b/src/korvid/ui/app.py @@ -45,6 +45,7 @@ ) from korvid.k8s.deployment_outcomes import DeploymentOutcomeReader from korvid.k8s.discovery import PODS_META, ResourceMeta, canonical_resource_alias +from korvid.k8s.evidence_requests import EvidenceClient from korvid.k8s.helm import HelmReleaseIdentity from korvid.k8s.helmcli import HelmCLI from korvid.k8s.logs import LogLine @@ -195,6 +196,7 @@ def __init__( approval_timeout_seconds: float | None = None, deployment_outcome_reader: DeploymentOutcomeReader | None = None, deployment_cluster_id: Callable[[], str | None] = lambda: None, + evidence_client: EvidenceClient | None = None, ) -> None: super().__init__() if approval_timeout_seconds is not None and ( @@ -249,6 +251,7 @@ def __init__( approval_timeout_seconds=approval_timeout_seconds, deployment_outcome_reader=deployment_outcome_reader, deployment_cluster_id=deployment_cluster_id, + evidence_client=evidence_client, ) self._runtime_bound = False self._runtime_inputs = runtime_inputs diff --git a/src/korvid/ui/app_runtime.py b/src/korvid/ui/app_runtime.py index b048b6fbf..65404d2e1 100644 --- a/src/korvid/ui/app_runtime.py +++ b/src/korvid/ui/app_runtime.py @@ -21,6 +21,7 @@ from korvid.k8s.components import ComponentRef from korvid.k8s.deployment_outcomes import DeploymentOutcomeReader from korvid.k8s.discovery import ResourceMeta + from korvid.k8s.evidence_requests import EvidenceClient from korvid.k8s.helm import HelmReleaseIdentity from korvid.k8s.helmcli import HelmCLI from korvid.k8s.logs import LogLine @@ -134,6 +135,8 @@ class AppRuntimeInputs: approval_timeout_seconds: float | None deployment_outcome_reader: DeploymentOutcomeReader | None deployment_cluster_id: Callable[[], str | None] + #: Diagnostics' client: evidence reads, discovery and the session generation. + evidence_client: EvidenceClient | None = None @dataclasses.dataclass(frozen=True) diff --git a/src/korvid/ui/command.py b/src/korvid/ui/command.py index 079d3ce84..77c74885e 100644 --- a/src/korvid/ui/command.py +++ b/src/korvid/ui/command.py @@ -109,6 +109,22 @@ def __post_init__(self) -> None: ("deployment", "scale", "restart", "rollout"), ), ), + CommandDescriptor( + aliases=("consumers", "config-consumers"), + help=( + ( + ":consumers|:config-consumers", + "ConfigMap or Secret consumers: Pods, mount modes, and what a change means", + ), + ), + operation=BuiltinOperation.CONFIG_PROPAGATION, + maximum_arguments=0, + palette=PaletteCommand( + "Explain configuration propagation", + "consumers", + ("propagation", "configmap", "secret", "mount", "subpath", "restart"), + ), + ), CommandDescriptor( aliases=("q", "quit"), help=((":q", "Quit (also :quit)"),), diff --git a/src/korvid/ui/command_router.py b/src/korvid/ui/command_router.py index dbb54363c..657efb3b9 100644 --- a/src/korvid/ui/command_router.py +++ b/src/korvid/ui/command_router.py @@ -92,6 +92,12 @@ class OutcomeCommands(Protocol): def open_latest(self) -> None: ... +class PropagationCommands(Protocol): + """The configuration propagation view's owner.""" + + def open_selected(self) -> None: ... + + class CommandRouter: """Dispatches typed commands to the owner that implements them.""" @@ -108,6 +114,7 @@ def __init__( keybindings: KeybindingCommands, slots: SlotCommands, outcomes: OutcomeCommands | None = None, + propagation: PropagationCommands | None = None, ) -> None: self._ui = ui self._agent = agent @@ -119,6 +126,7 @@ def __init__( self._keybindings = keybindings self._slots = slots self._outcomes = outcomes + self._propagation = propagation def route_builtin(self, command: BuiltinCommand) -> None: """Dispatch an app-owned command by canonical operation identity.""" @@ -131,6 +139,7 @@ def route_builtin(self, command: BuiltinCommand) -> None: BuiltinOperation.KEYBINDINGS: self._keybindings.open_editor, BuiltinOperation.NAMESPACE_SLOTS: self._slots.open_reallocation, BuiltinOperation.DEPLOYMENT_OUTCOMES: self._open_outcomes, + BuiltinOperation.CONFIG_PROPAGATION: self._open_propagation, } handler = no_argument_handlers.get(operation) if handler is not None: @@ -156,6 +165,14 @@ def _open_outcomes(self) -> None: return self._outcomes.open_latest() + def _open_propagation(self) -> None: + if self._propagation is None: + self._ui.notify( + "Configuration propagation is unavailable in this session", severity="warning" + ) + return + self._propagation.open_selected() + def _route_agent(self, command: BuiltinCommand) -> None: if not self._agent.available: self._report_unknown(_builtin_text(command)) diff --git a/src/korvid/ui/config_propagation_controller.py b/src/korvid/ui/config_propagation_controller.py index 56f9d016d..f25033056 100644 --- a/src/korvid/ui/config_propagation_controller.py +++ b/src/korvid/ui/config_propagation_controller.py @@ -93,7 +93,7 @@ def open_selected(self) -> None: """Explain the selected ConfigMap or Secret in a worker.""" reason = self.unavailable_reason() if reason is not None: - self._ui.notify(reason.message, severity="warning") + self._ui.notify(reason.message, severity="warning", markup=False) return kind = self._selected_kind() namespace, name = self._view.selected_ns_name() diff --git a/src/korvid/ui/context_switch_coordinator.py b/src/korvid/ui/context_switch_coordinator.py index 76477e59d..4d7f03347 100644 --- a/src/korvid/ui/context_switch_coordinator.py +++ b/src/korvid/ui/context_switch_coordinator.py @@ -63,6 +63,7 @@ AvailabilityCode, UnavailableReason, ) +from korvid.ui.config_propagation_controller import CONFIG_PROPAGATION_GROUP from korvid.ui.ui_surface import UiSurface from korvid.ui.view_state import ViewState from korvid.ui.widgets.pick_screen import PickScreen @@ -838,6 +839,9 @@ async def _teardown(self) -> None: # caller) so those pollers and workers release the old connection # before the wholesale watch stop below. await self._workspace().quiesce_for_context_switch() + # A configuration propagation run reads through the old client; its + # run token would drop the result anyway, but the reads must stop too. + self._surface.cancel_worker_group(CONFIG_PROPAGATION_GROUP) # An old-cluster namespace prefetch still in flight could land after # the new cluster's and overwrite its completions — cancel it first. # Both steps run on the workspace controller: it owns the prefetch diff --git a/src/korvid/ui/messages.py b/src/korvid/ui/messages.py index b58a6439a..093d781d7 100644 --- a/src/korvid/ui/messages.py +++ b/src/korvid/ui/messages.py @@ -120,6 +120,7 @@ class BuiltinOperation(Enum): KEYBINDINGS = "keys" NAMESPACE_SLOTS = "slots" DEPLOYMENT_OUTCOMES = "outcomes" + CONFIG_PROPAGATION = "consumers" class BuiltinCommand(Message): diff --git a/tests/test_main_wiring.py b/tests/test_main_wiring.py index 1e77e67b5..5899a7e5c 100644 --- a/tests/test_main_wiring.py +++ b/tests/test_main_wiring.py @@ -1873,6 +1873,26 @@ async def test_wire_and_run_wires_deployment_outcome_reader_from_kube( assert captured["deployment_cluster_id"]() == "(kubeconfig default)|https://replacement.example" +async def test_wire_and_run_hands_the_kube_client_to_diagnostics( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The configuration propagation view reads through the session's own client.""" + import korvid.__main__ as main_mod + from korvid.core.config import KorvidConfig + + monkeypatch.setattr(main_mod, "KorvidApp", _FakeAppCapturesKwargs) + monkeypatch.setattr(main_mod, "assemble_app_runtime", lambda app: app) + _FakeAppCapturesKwargs.instances.clear() + + kube = _FakeKubeForWiring() + state = main_mod._RunState() + await main_mod._wire_and_run(KorvidConfig(readonly=True), cast("Any", kube), state) + if state.discovery_box: + await state.discovery_box[0] + + assert _FakeAppCapturesKwargs.instances[0].captured["evidence_client"] is kube + + async def test_wire_and_run_wires_helm_release_identity_reader_from_kube( monkeypatch: pytest.MonkeyPatch, ) -> None: diff --git a/tests/ui/test_action_policy.py b/tests/ui/test_action_policy.py index 19970415a..c96567485 100644 --- a/tests/ui/test_action_policy.py +++ b/tests/ui/test_action_policy.py @@ -593,6 +593,28 @@ def test_composed_command_reasons_route_the_forward_list_command() -> None: assert reasons["pf"]() == missing +def test_composed_command_reasons_ask_the_propagation_owner_about_consumers() -> None: + """`:consumers` is greyed out by its owner's own answer: no connection, + the wrong view, or no selected ConfigMap or Secret.""" + wrong_view = UnavailableReason(AvailabilityCode.WRONG_VIEW, "Open ConfigMaps or Secrets") + answer: UnavailableReason | None = wrong_view + reasons = compose_command_reasons( + agent_available=lambda: True, + agent_setup_available=lambda: True, + agent_session_configured=lambda: True, + mcp=lambda: None, + telepresence=lambda: None, + proposals=lambda: None, + namespace=lambda: None, + context=lambda: None, + port_forwards=lambda: None, + propagation=lambda: answer, + ) + assert reasons["consumers"]() == wrong_view + answer = None + assert reasons["consumers"]() is None + + def test_composed_command_reasons_share_one_agent_answer() -> None: """`:ai` and `:model` have no owner at all without the [agent] extra, which is the same absence the bound Ctrl-A key refuses with - so both diff --git a/tests/ui/test_command_router.py b/tests/ui/test_command_router.py index 6a804f44a..739aba013 100644 --- a/tests/ui/test_command_router.py +++ b/tests/ui/test_command_router.py @@ -98,6 +98,14 @@ def open_latest(self) -> None: self.opens += 1 +class FakePropagation: + def __init__(self) -> None: + self.opens = 0 + + def open_selected(self) -> None: + self.opens += 1 + + class Harness: def __init__( self, @@ -105,6 +113,7 @@ def __init__( agent_available: bool = True, catalog_missing: bool = False, outcomes: FakeOutcomes | None = None, + propagation: FakePropagation | None = None, ) -> None: self.ui = FakeUi() self.agent = FakeAgent(agent_available) @@ -126,6 +135,7 @@ def __init__( keybindings=self.keybindings, slots=self.slots, outcomes=outcomes, + propagation=propagation, ) @@ -267,3 +277,23 @@ def test_an_empty_command_is_reported_not_routed() -> None: assert h.agent.commands == [] assert h.integrations.mcp == [] assert len(h.ui.notifications) == 1 + + +def test_configuration_propagation_reaches_its_owner_once() -> None: + propagation = FakePropagation() + harness = Harness(propagation=propagation) + + harness.router.route_builtin(_builtin(BuiltinOperation.CONFIG_PROPAGATION)) + + assert propagation.opens == 1 + assert harness.ui.notifications == [] + + +def test_configuration_propagation_without_owner_warns_unavailable() -> None: + harness = Harness() + + harness.router.route_builtin(_builtin(BuiltinOperation.CONFIG_PROPAGATION)) + + assert harness.ui.notifications == [ + ("Configuration propagation is unavailable in this session", "warning") + ] diff --git a/tests/ui/test_config_propagation_workflow.py b/tests/ui/test_config_propagation_workflow.py new file mode 100644 index 000000000..d48397dd6 --- /dev/null +++ b/tests/ui/test_config_propagation_workflow.py @@ -0,0 +1,144 @@ +"""`:consumers` end to end: a ConfigMap row, its consumers, and their Pod (#410).""" + +from __future__ import annotations + +import asyncio +from collections.abc import AsyncIterator +from datetime import UTC, datetime +from typing import Any + +from korvid.core.config import KorvidConfig +from korvid.core.store import ResourceStore, Summary +from korvid.core.watch import WatchManager +from korvid.k8s.discovery import PODS_META, ResourceMeta, build_alias_map +from korvid.k8s.discovery_snapshot import ( + GroupVersionOutcome, + RawDiscovery, + RootOutcome, + ServedResource, +) +from korvid.k8s.evidence_requests import EvidenceClient, EvidencePage, EvidenceTarget +from korvid.k8s.models import GenericSummary, PodSummary +from korvid.ui.widgets.config_propagation_screen import ConfigPropagationScreen +from tests.app_factory import build_test_app +from tests.ui.waits import screen_ready, until + +CONFIG_MAPS = ResourceMeta("ConfigMap", "configmaps", "", "v1", True) +SETTINGS_POD = { + "metadata": {"name": "api-1", "namespace": "default", "uid": "uid-api-1"}, + "spec": { + "containers": [ + { + "name": "app", + "envFrom": [{"configMapRef": {"name": "settings"}}], + "env": [{"name": "TOKEN", "value": "s3cr3t-literal"}], + } + ] + }, +} + + +class Client(EvidenceClient): + """Serves configmaps, secrets and pods, and one Pod that reads `settings`.""" + + def __init__(self) -> None: + self.reads: list[str] = [] + + @property + def session_generation(self) -> int: + return 1 + + async def discovery_snapshot(self) -> RawDiscovery: + served = tuple( + ServedResource(name, frozenset({"get", "list"})) + for name in ("configmaps", "secrets", "pods") + ) + return RawDiscovery( + 1, + datetime.now(UTC), + 0.0, + RootOutcome("/api", (("", "v1"),)), + RootOutcome("/apis", ()), + (GroupVersionOutcome("", "v1", served),), + ) + + async def list_evidence_page( + self, target: EvidenceTarget, *, limit: int, continuation: str, max_bytes: int + ) -> EvidencePage: + self.reads.append(f"list {target.resource}") + return EvidencePage((SETTINGS_POD,)) + + async def get_evidence_object( + self, target: EvidenceTarget, name: str, *, max_bytes: int + ) -> dict[str, Any]: + self.reads.append(f"get {target.resource}/{name} metadata={target.metadata_only}") + return {"metadata": {"name": name, "namespace": "default", "uid": "uid-settings"}} + + +async def _source(kind: str, scope: str) -> AsyncIterator[tuple[str, Summary]]: + if kind == "configmaps": + yield ("ADDED", GenericSummary("settings", "default", "ConfigMap", "", uid="uid-settings")) + if kind == "pods": + pod = PodSummary("api-1", "default", "Running", "1/1", 0, None, uid="uid-api-1") + yield ("ADDED", pod) + await asyncio.Event().wait() + + +async def _command(pilot: Any, text: str) -> None: + await pilot.press("colon", *text, "enter") + + +async def test_consumers_of_a_config_map_lead_to_their_pod() -> None: + client = Client() + store = ResourceStore() + app = build_test_app( + config=KorvidConfig(namespace="default"), + store=store, + watch_manager=WatchManager(store, _source), + aliases=build_alias_map([PODS_META, CONFIG_MAPS]), + agent_available=False, + evidence_client=client, + ) + async with app.run_test() as pilot: + await _command(pilot, "configmaps") + await until( + pilot, + lambda: app._view.selected_ns_name(notify=False) == ("default", "settings"), + label="ConfigMap selected", + ) + + await _command(pilot, "consumers") + await until(pilot, lambda: screen_ready(app, ConfigPropagationScreen), label="view open") + screen = app.screen + assert isinstance(screen, ConfigPropagationScreen) + assert [c.pod.name for c in screen.report.consumers] == ["api-1"] + assert "s3cr3t-literal" not in repr(screen.report) + assert client.reads == ["get configmaps/settings metadata=True", "list pods"] + + await pilot.press("enter") + await until(pilot, lambda: app.current_kind == "pods", label="Pod view") + await until( + pilot, + lambda: app._view.selected_ns_name(notify=False) == ("default", "api-1"), + label="consumer Pod selected", + ) + + +async def test_consumers_on_another_view_says_what_to_open() -> None: + store = ResourceStore() + app = build_test_app( + config=KorvidConfig(namespace="default"), + store=store, + watch_manager=WatchManager(store, _source), + aliases=build_alias_map([PODS_META, CONFIG_MAPS]), + agent_available=False, + evidence_client=Client(), + ) + async with app.run_test() as pilot: + await _command(pilot, "consumers") + await until( + pilot, + lambda: any("Open ConfigMaps or Secrets" in n.message for n in app._notifications), + label="reason shown", + ) + assert not isinstance(app.screen, ConfigPropagationScreen) diff --git a/tests/ui/test_context_switch_coordinator.py b/tests/ui/test_context_switch_coordinator.py index 289947a18..4cd4fcfe4 100644 --- a/tests/ui/test_context_switch_coordinator.py +++ b/tests/ui/test_context_switch_coordinator.py @@ -30,6 +30,7 @@ from korvid.core.session_timeline import SessionTimeline from korvid.k8s.errors import KubeClientError from korvid.ui.action_availability import AvailabilityCode +from korvid.ui.config_propagation_controller import CONFIG_PROPAGATION_GROUP from korvid.ui.context_switch_coordinator import ( HINT_EVENTS_GROUP, ContextSurface, @@ -479,6 +480,7 @@ def _forward_record(port: int) -> ForwardRecord: "logs-close", "describe-hide", "workspace-quiesce", + f"workers-cancelled:{CONFIG_PROPAGATION_GROUP}", "ns-prefetch-cancelled", "ns-words-cleared", "watch-stop-all", From 73bae49d3f91df94029220ecee9ca8118a9250ff Mon Sep 17 00:00:00 2001 From: hellices Date: Fri, 30 Oct 2026 02:36:47 +0900 Subject: [PATCH 07/21] tests: verify ConfigMap consumers by mode on every supported version The version-matrix fixtures gain a Pod that consumes settings through envFrom, a mounted volume and a subPath mount, on the pause image every kind node already has. The scenario runs explain_propagation as the admin principal and finds the three modes, with a conclusive Pod list, and neither the ConfigMap's data nor the Pod's literal env value in the report. The workflow's path filters and the support page cover the new modules and scenario. Part of #502. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/k8s-version-matrix.yml | 6 +++++ docs/kubernetes-support.md | 3 +++ tests/version_matrix/fixtures.yaml | 29 ++++++++++++++++++++++++ tests/version_matrix/test_scenarios.py | 22 ++++++++++++++++++ 4 files changed, 60 insertions(+) diff --git a/.github/workflows/k8s-version-matrix.yml b/.github/workflows/k8s-version-matrix.yml index bc0bf52ea..c12e87804 100644 --- a/.github/workflows/k8s-version-matrix.yml +++ b/.github/workflows/k8s-version-matrix.yml @@ -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 @@ -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 diff --git a/docs/kubernetes-support.md b/docs/kubernetes-support.md index eb5aad99f..952cecf32 100644 --- a/docs/kubernetes-support.md +++ b/docs/kubernetes-support.md @@ -67,6 +67,9 @@ 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 or the Pod's literal values. 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 diff --git a/tests/version_matrix/fixtures.yaml b/tests/version_matrix/fixtures.yaml index e7512cbae..2747bf2f9 100644 --- a/tests/version_matrix/fixtures.yaml +++ b/tests/version_matrix/fixtures.yaml @@ -71,6 +71,35 @@ metadata: data: password: matrix-configmap-payload --- +# Consumes `settings` three ways: every key as environment variables, a +# mounted volume, and one key mounted with subPath. It only has to exist; +# the pause image is already on every kind node. +apiVersion: v1 +kind: Pod +metadata: + name: consumer + namespace: korvid-matrix +spec: + containers: + - name: app + image: registry.k8s.io/pause:3.10 + envFrom: + - configMapRef: + name: settings + env: + - name: LITERAL + value: matrix-pod-literal + volumeMounts: + - name: settings + mountPath: /etc/settings + - name: settings + mountPath: /etc/app/password + subPath: password + volumes: + - name: settings + configMap: + name: settings +--- apiVersion: v1 kind: Secret metadata: diff --git a/tests/version_matrix/test_scenarios.py b/tests/version_matrix/test_scenarios.py index 518e47ffa..123daca54 100644 --- a/tests/version_matrix/test_scenarios.py +++ b/tests/version_matrix/test_scenarios.py @@ -9,6 +9,7 @@ from __future__ import annotations import json +from datetime import UTC, datetime import pytest @@ -22,6 +23,8 @@ SourceScope, Verb, ) +from korvid.core.config_consumers import ConsumptionMode +from korvid.core.config_propagation import ConfigTarget, explain_propagation from korvid.core.evidence_reads import ( LAST_APPLIED_ANNOTATION, read_list, @@ -245,3 +248,22 @@ async def test_events_fall_through_to_core_when_events_k8s_io_is_denied( assert _group(result.observation) == "" assert _state(result.observation) == (EvidenceState.AVAILABLE, Basis.OBSERVED) assert "matrix-scenario" in [item["metadata"]["name"] for item in result.items] + + +async def test_a_config_maps_consumers_are_read_by_mode(connect: Connect) -> None: + kube = await connect(ADMIN_CONTEXT) + + report = await explain_propagation( + kube, await snapshot_of(kube), ConfigTarget("ConfigMap", NAMESPACE, "settings") + ) + + assert _state(report.object) == (EvidenceState.AVAILABLE, Basis.OBSERVED) + assert _state(report.pods) == (EvidenceState.AVAILABLE, Basis.OBSERVED) + assert sorted((c.pod.name, c.container, c.mode) for c in report.consumers) == [ + ("consumer", "app", ConsumptionMode.ENV_FROM), + ("consumer", "app", ConsumptionMode.SUBPATH), + ("consumer", "app", ConsumptionMode.VOLUME), + ] + assert report.consumers_conclusive(datetime.now(UTC)) + assert "matrix-configmap-payload" not in repr(report) + assert "matrix-pod-literal" not in repr(report) From bb570f34b691fe267bb803cf77c0f411a285b61e Mon Sep 17 00:00:00 2001 From: hellices Date: Fri, 30 Oct 2026 02:37:35 +0900 Subject: [PATCH 08/21] docs: explain configuration propagation and :consumers The TUI guide gains a Configuration propagation section: what each consumption mode means for a change, what the view reads and never reads, when it says no Pod references the object, and that immutability is never claimed. The keybindings table lists :consumers, and the unreleased notes announce it. Part of #502. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/keybindings.md | 1 + docs/release-notes/unreleased.md | 10 ++++++++++ docs/tui.md | 23 +++++++++++++++++++++++ 3 files changed, 34 insertions(+) diff --git a/docs/keybindings.md b/docs/keybindings.md index eea41dcc9..39d8bd503 100644 --- a/docs/keybindings.md +++ b/docs/keybindings.md @@ -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 | diff --git a/docs/release-notes/unreleased.md b/docs/release-notes/unreleased.md index fcd59aa56..4e4e3eed5 100644 --- a/docs/release-notes/unreleased.md +++ b/docs/release-notes/unreleased.md @@ -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 diff --git a/docs/tui.md b/docs/tui.md index 0cbe04d98..41faf4816 100644 --- a/docs/tui.md +++ b/docs/tui.md @@ -114,6 +114,29 @@ 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 | + +The view reads the object's metadata and the namespace's Pods when it opens, +never the object's data or a Pod's environment values. Each source gets a +line saying what was read, its state and how old it is. "No Pod references +it" appears only when every Pod could be listed; a denied or truncated list +says why the set of consumers is not established. 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 From ab1ee9f9e0cfb10be435537106cdac46d2d010da Mon Sep 17 00:00:00 2001 From: hellices Date: Fri, 30 Oct 2026 02:42:30 +0900 Subject: [PATCH 09/21] docs: plan the configuration propagation view (#410 P1) The plan for #502 records four departures from the design: the command is :consumers, so prop stays unambiguous with :proposals; the context switch cancels the worker group by name; EvidenceClient lives in k8s; and the observability builders move out of __main__ to make room. Part of #502. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../2026-10-30-config-propagation-view.md | 1602 +++++++++++++++++ 1 file changed, 1602 insertions(+) create mode 100644 docs/superpowers/plans/2026-10-30-config-propagation-view.md diff --git a/docs/superpowers/plans/2026-10-30-config-propagation-view.md b/docs/superpowers/plans/2026-10-30-config-propagation-view.md new file mode 100644 index 000000000..50955399e --- /dev/null +++ b/docs/superpowers/plans/2026-10-30-config-propagation-view.md @@ -0,0 +1,1602 @@ +# ConfigMap and Secret Consumers, View (#410 P1) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Show, for the selected ConfigMap or Secret, which Pods consume it and what a change means for each, through the shared evidence renderer (#502, the view half of slice P1 of #410; ships #486, S6 of #409). + +**Architecture:** `ui/evidence_text.py` is the one place an `Observation` becomes text. `ui/widgets/config_propagation_screen.py` shows one `PropagationReport` (from #503) with the consumers grouped by mode. `ui/config_propagation_controller.py` runs `explain_propagation` in a worker under a run token and opens the view only while the token is current. A `:consumers` command and palette entry reach it; the composition root hands it the session's `KubeClient` as an `EvidenceClient`. + +**Tech Stack:** Python 3.11+, Textual, Rich `Text`, pytest with `asyncio_mode = "auto"`, mypy `--strict`, ruff, tach. + +## Global Constraints + +- **Design:** `docs/superpowers/specs/2026-10-28-config-propagation-design.md`, and the presentation contract in `docs/superpowers/specs/2026-10-25-capability-aware-diagnostics-design.md`. Where this plan departs from them, the section below says why. +- **One line per source:** ` : — · observed `, stale marked by the source's own `FreshnessPolicy`. +- **Widgets never interpret bases:** every `Basis` has one sentence in `evidence_text`. +- **Literal names:** every cluster-provided name is a plain `Text`, never parsed as markup. +- **Conclusions from absence:** "no Pod references it" only when the Pod list is conclusive; otherwise "Not established" with the reason. +- **Availability:** the entry is unavailable only without a connection, on the wrong view, or without a selection. +- **Late results:** dropped after a context switch, new credentials or a newer run; the context switch cancels the worker group. +- **No values, no writes:** the view shows references only; restarting is a suggestion. +- **Size limits:** `scripts/check_source_size.py` is not edited; `__main__.py` and `ui/app.py` make room by moving code. +- **Code rules:** no `assert` in `src/`; every `pytest.raises` takes `match=`; maximum complexity 10; Google-style docstrings. +- **Commands:** use `uv run --frozen …`. Behind the corporate mirror, call the project venv's tools directly with `PYTHONPATH=src`. + +## Departures from the design + +- **The command is `:consumers`, not `:propagation`.** `:propagation` would make `prop` ambiguous with `:proposals` in the command bar's first-word completion, which a test pins. The palette entry is still **Explain configuration propagation**, and `propagation` is one of its search terms. +- **The context switch cancels the group by name.** The design names a `cancel_config_propagation_workers()` hook; the coordinator already cancels `hint-events` by name with `cancel_worker_group`, so it does the same here and needs no new constructor argument. The controller's `cancel_workers()` remains for other callers. +- **`EvidenceClient` lives in `k8s`.** The controller needs evidence reads, discovery and the session generation from one client; naming that in `k8s/evidence_requests.py` lets `KubeClient` declare it and the UI depend on the boundary instead of `KubeClient`. +- **The observability builders move to `composition_support.py`** to make room in `__main__.py`, which was three lines under its limit. + +## File structure + +| File | Change | Responsibility | +| --- | --- | --- | +| `src/korvid/composition_support.py` | modify | gains the observability builders from `__main__.py` | +| `src/korvid/__main__.py` | modify | imports `_build_observability` back; wires the controller, command and client | +| `src/korvid/k8s/evidence_requests.py` | modify | `EvidenceClient` | +| `src/korvid/k8s/client.py` | modify | `KubeClient` implements `EvidenceClient` | +| `src/korvid/ui/evidence_text.py` | create | basis sentences, ages, source text, observation lines | +| `src/korvid/ui/widgets/config_propagation_screen.py` | create | rows by mode, notes, the modal screen | +| `src/korvid/ui/config_propagation_controller.py` | create | availability, the run, late results, Pod navigation | +| `src/korvid/ui/messages.py`, `ui/command.py`, `ui/command_router.py`, `ui/action_policy.py` | modify | `:consumers` and its palette availability | +| `src/korvid/ui/context_switch_coordinator.py` | modify | cancel the worker group before the swap | +| `src/korvid/ui/app.py`, `ui/app_runtime.py` | modify | the `evidence_client` input | +| `tests/version_matrix/fixtures.yaml`, `test_scenarios.py` | modify | a consumer Pod and its scenario | +| `.github/workflows/k8s-version-matrix.yml` | modify | path filters for the new modules | +| `docs/tui.md`, `docs/keybindings.md`, `docs/kubernetes-support.md`, `docs/release-notes/unreleased.md` | modify | user documentation | + +--- + +### Task 1: Make room in the composition root + +**Files:** +- Modify: `src/korvid/__main__.py`, `src/korvid/composition_support.py` +- Test: `tests/test_observability_wiring.py` + +- [ ] **Step 1: Move the builders** + +Move `_OBSERVABILITY_EXTRA_ROOTS`, `_OBSERVABILITY_INSTALL_HINT`, `_build_observability`, `_validate_observability` and `_connectors` from `__main__.py` to `composition_support.py`, after `ObservabilityWiring`, importing `ObservabilityBackend` there. In `__main__.py`, import the builder back: + +```python +from korvid.composition_support import ( + _build_observability as _build_observability, +) +``` + +- [ ] **Step 2: Patch where the builder looks things up** + +In `tests/test_observability_wiring.py`, the two `TestMissingExtra` tests patch `korvid.composition_support._missing_extra_packages` instead of `korvid.__main__`'s. + +- [ ] **Step 3: Run the tests that touch it** + +Run: `uv run --frozen pytest -p no:tach tests/test_observability_wiring.py tests/test_agent_cleanup.py tests/test_optional_extras.py tests/test_main_wiring.py -q` +Expected: all pass. `python scripts/check_source_size.py` passes, and `__main__.py` drops by about 130 lines. + +- [ ] **Step 4: Commit** + +```bash +git commit -m "refactor: build the observability connectors in composition_support" +``` + +### Task 2: The shared evidence renderer + +**Files:** +- Create: `src/korvid/ui/evidence_text.py` +- Test: `tests/ui/test_evidence_text.py` + +**Interfaces:** +- Produces: `BASIS_SENTENCES`, `basis_sentence(basis) -> str`, `age_text(observed_at, now) -> str`, `source_text(source) -> str`, `observation_line(observation, policy, now) -> Text`, `not_established(what) -> Text`. + +- [ ] **Step 1: Write the failing tests** + +Create `tests/ui/test_evidence_text.py` with: + +```python +"""The shared evidence renderer (#486, S6 of #409).""" + +from __future__ import annotations + +from datetime import UTC, datetime, timedelta + +import pytest +from rich.text import Text + +from korvid.core.capability import ( + ApiSource, + Basis, + DiscoverySource, + EvidenceState, + FreshnessPolicy, + Observation, + ProviderSource, + Source, + SourceScope, + Verb, +) +from korvid.ui.evidence_text import ( + age_text, + basis_sentence, + not_established, + observation_line, +) + +NOW = datetime(2026, 10, 30, 9, 0, tzinfo=UTC) +POLICY = FreshnessPolicy() +CONFIG_MAPS = ApiSource("", "configmaps", SourceScope.namespace("team-a"), "v1", Verb.LIST) + + +def _observed(source: Source, state: EvidenceState, basis: Basis, seconds: float) -> Observation: + return Observation(source, state, basis, NOW - timedelta(seconds=seconds)) + + +def test_every_basis_has_its_own_sentence() -> None: + sentences = [basis_sentence(basis) for basis in Basis] + + assert all(sentences) + assert len(set(sentences)) == len(Basis) + + +@pytest.mark.parametrize( + ("basis", "words"), + [ + (Basis.FORBIDDEN, "permissions"), + (Basis.NOT_SERVED, "does not serve"), + (Basis.SERVER_ERROR, "returned an error"), + (Basis.TRUNCATED, "page limit"), + (Basis.DEADLINE, "deadline"), + (Basis.TOO_LARGE, "too large"), + (Basis.CONTINUATION_EXPIRED, "changed while"), + ], +) +def test_denied_unsupported_unavailable_and_partial_read_differently( + basis: Basis, words: str +) -> None: + assert words in basis_sentence(basis) + + +def test_an_api_line_names_the_resource_version_scope_state_basis_and_age() -> None: + observation = _observed(CONFIG_MAPS, EvidenceState.AVAILABLE, Basis.OBSERVED, 2) + + line = observation_line(observation, POLICY, NOW) + + assert isinstance(line, Text) + assert line.plain == "configmaps v1 in namespace team-a: available — read · observed 2s ago" + + +def test_a_grouped_cluster_wide_source_names_its_group() -> None: + events = ApiSource("events.k8s.io", "events", SourceScope.cluster(), "v1", Verb.LIST) + observation = _observed(events, EvidenceState.DENIED, Basis.FORBIDDEN, 5) + + assert observation_line(observation, POLICY, NOW).plain == ( + "events events.k8s.io/v1 cluster-wide: denied — " + "your permissions do not allow this read · observed 5s ago" + ) + + +def test_an_unresolved_source_names_its_group_without_a_version() -> None: + events = ApiSource("events.k8s.io", "events", SourceScope.namespace("a")) + observation = _observed(events, EvidenceState.UNSUPPORTED, Basis.NOT_SERVED, 1) + + assert observation_line(observation, POLICY, NOW).plain.startswith( + "events events.k8s.io in namespace a: unsupported — " + ) + + +def test_discovery_and_provider_sources_name_what_they_read() -> None: + discovery = _observed( + DiscoverySource.group_version("apps", "v1"), + EvidenceState.UNAVAILABLE, + Basis.DISCOVERY_FAILED, + 3, + ) + provider = _observed( + ProviderSource("prometheus", "cpu", SourceScope.node("n1")), + EvidenceState.UNSUPPORTED, + Basis.PROVIDER_NOT_CONFIGURED, + 3, + ) + + assert observation_line(discovery, POLICY, NOW).plain.startswith( + "discovery /apis/apps/v1 cluster-wide: unavailable — API discovery failed" + ) + assert observation_line(provider, POLICY, NOW).plain.startswith( + "prometheus cpu on node n1: unsupported — no provider is configured" + ) + + +def test_staleness_follows_the_sources_own_policy() -> None: + observation = _observed(CONFIG_MAPS, EvidenceState.AVAILABLE, Basis.OBSERVED, 60) + short = FreshnessPolicy(max_age=timedelta(seconds=30)) + long = FreshnessPolicy(max_age=timedelta(minutes=5)) + + assert observation_line(observation, short, NOW).plain.endswith("observed 1m ago · stale") + assert observation_line(observation, long, NOW).plain.endswith("observed 1m ago") + + +def test_an_observation_never_made_is_not_called_stale() -> None: + observation = Observation(CONFIG_MAPS, EvidenceState.UNKNOWN, Basis.NOT_STARTED) + + assert observation_line(observation, POLICY, NOW).plain == ( + "configmaps v1 in namespace team-a: unknown — not read yet · not observed" + ) + + +@pytest.mark.parametrize( + ("seconds", "text"), + [ + (0.4, "observed just now"), + (59, "observed 59s ago"), + (61, "observed 1m ago"), + (2 * 3600 + 5, "observed 2h ago"), + (3 * 86400, "observed 3d ago"), + (-5, "observed in the future"), + ], +) +def test_ages_are_whole_units(seconds: float, text: str) -> None: + assert age_text(NOW - timedelta(seconds=seconds), NOW) == text + + +def test_cluster_provided_names_are_rendered_literally() -> None: + hostile = ApiSource("", "configmaps", SourceScope.namespace("[bold]x[/bold]"), "v1", Verb.LIST) + observation = _observed(hostile, EvidenceState.AVAILABLE, Basis.OBSERVED, 1) + + line = observation_line(observation, POLICY, NOW) + + assert "in namespace [bold]x[/bold]:" in line.plain + assert line.spans == [] + + +def test_not_established_says_why() -> None: + line = not_established("whether every consumer is listed") + + assert line.plain == "Not established: whether every consumer is listed" + assert not_established("[red]x").plain == "Not established: [red]x" +``` + +- [ ] **Step 2: Run them to verify they fail** + +Run: `uv run --frozen pytest -p no:tach tests/ui/test_evidence_text.py -q` +Expected: `ModuleNotFoundError: No module named 'korvid.ui.evidence_text'`. + +- [ ] **Step 3: Write the module** + +Create `src/korvid/ui/evidence_text.py` with: + +```python +"""The one place evidence observations become text (#486, S6 of #409). + +Every diagnostic panel shows its sources the same way, one line each: + + : — · observed + +Widgets call these functions and never read a `Basis` themselves, so a +denied read, an API the cluster does not serve and a server error read the +same everywhere. Cluster-provided names go into a plain `Text`, which never +parses markup. +""" + +from __future__ import annotations + +from collections.abc import Mapping +from datetime import datetime +from types import MappingProxyType + +from rich.text import Text + +from korvid.core.capability import ( + ApiSource, + Basis, + DiscoverySource, + FreshnessPolicy, + Observation, + ScopeKind, + Source, + SourceScope, + is_fresh, +) + +#: One sentence for every basis. A new `Basis` without one fails the tests. +BASIS_SENTENCES: Mapping[Basis, str] = MappingProxyType( + { + Basis.NOT_STARTED: "not read yet", + Basis.IN_FLIGHT: "reading", + Basis.OBSERVED: "read", + Basis.OBJECT_ABSENT: "the object does not exist", + Basis.NO_DATA: "the provider returned no data", + Basis.TRUNCATED: "stopped at the page limit", + Basis.DEADLINE: "stopped at the deadline", + Basis.TOO_LARGE: "a response was too large to read", + Basis.CONTINUATION_EXPIRED: "the list changed while it was being read", + Basis.SUBSCOPE_FAILED: "part of the scope could not be read", + Basis.STALE_STATUS: "its status lags behind its specification", + Basis.NOT_SERVED: "the cluster does not serve this API", + Basis.OPERATION_UNSUPPORTED: "the API does not allow this read", + Basis.NO_KNOWN_VERSION: "the cluster serves only versions korvid cannot read", + Basis.PROJECTION_UNSUPPORTED: "the API cannot return metadata only", + Basis.PROVIDER_NOT_CONFIGURED: "no provider is configured", + Basis.FEATURE_DISABLED: "the feature is disabled", + Basis.FORBIDDEN: "your permissions do not allow this read", + Basis.UNAUTHENTICATED: "the cluster did not accept your credentials", + Basis.DISCOVERY_FORBIDDEN: "your permissions do not allow API discovery", + Basis.THROTTLED: "the API server asked korvid to slow down", + Basis.SERVER_ERROR: "the API server returned an error", + Basis.UNEXPECTED_STATUS: "the API server returned an unexpected status", + Basis.TRANSPORT: "the API server could not be reached", + Basis.MALFORMED: "the response could not be parsed", + Basis.DISCOVERY_FAILED: "API discovery failed", + Basis.DISCOVERY_STALE: "API discovery is out of date", + } +) + +_UNITS = ((86400, "d"), (3600, "h"), (60, "m"), (1, "s")) + + +def basis_sentence(basis: Basis) -> str: + """What `basis` means, as a sentence fragment a user can act on.""" + return BASIS_SENTENCES[basis] + + +def age_text(observed_at: datetime | None, now: datetime) -> str: + """How long ago an observation was made, in its largest whole unit.""" + if observed_at is None: + return "not observed" + seconds = (now - observed_at).total_seconds() + if seconds < 0: + return "observed in the future" + for size, unit in _UNITS: + if seconds >= size: + return f"observed {int(seconds // size)}{unit} ago" + return "observed just now" + + +def _scope_text(scope: SourceScope) -> str: + if scope.kind is ScopeKind.CLUSTER: + return "cluster-wide" + if scope.kind is ScopeKind.NAMESPACE: + return f"in namespace {scope.name}" + if scope.kind is ScopeKind.NODE: + return f"on node {scope.name}" + return f"from provider {scope.name}" + + +def source_text(source: Source) -> str: + """What a source reads, and where.""" + if isinstance(source, ApiSource): + api = source.group or "core" + if source.version: + api = f"{source.group}/{source.version}" if source.group else source.version + return f"{source.resource} {api} {_scope_text(source.scope)}" + if isinstance(source, DiscoverySource): + return f"discovery {source.path} {_scope_text(source.scope)}" + return f"{source.provider_id} {source.query_kind} {_scope_text(source.scope)}" + + +def observation_line(observation: Observation, policy: FreshnessPolicy, now: datetime) -> Text: + """One source's line: what it read, its state and why, and how old it is. + + `policy` is the source's own, the one its declaration and `conclusive` + use, so the line never calls fresh what the baseline treats as stale. + """ + line = ( + f"{source_text(observation.source)}: {observation.state.value} — " + f"{basis_sentence(observation.basis)} · {age_text(observation.observed_at, now)}" + ) + if observation.observed_at is not None and not is_fresh(observation, policy, now): + line = f"{line} · stale" + return Text(line) + + +def not_established(what: str) -> Text: + """A conclusion the evidence does not support, said as such.""" + return Text(f"Not established: {what}") +``` + +- [ ] **Step 4: Run the tests, and check the mutations** + +Expected: `22 passed`. Each change must fail a test: mark stale without an observation; never mark stale; parse the line as markup; drop the group from a grouped version; use `>` for unit boundaries. + +- [ ] **Step 5: Commit** + +```bash +git commit -m "ui: render evidence observations with one shared renderer" +``` + +### Task 3: The view + +**Files:** +- Create: `src/korvid/ui/widgets/config_propagation_screen.py` +- Test: `tests/ui/test_config_propagation_screen.py` + +**Interfaces:** +- Consumes: `PropagationReport`, `declarations` (#503); `observation_line`, `not_established`, `basis_sentence` (Task 2). +- Produces: `Heading`, `ConsumerRow`, `table_rows(consumers)`, `evidence_lines(report, now)`, `report_notes(report, now)`, `ConfigPropagationScreen(report, now)`, a `ModalScreen[Consumer | None]`. + +- [ ] **Step 1: Write the failing tests** + +Create `tests/ui/test_config_propagation_screen.py` with: + +```python +"""The configuration propagation view (#410): rows, notes and keys.""" + +from __future__ import annotations + +from datetime import UTC, datetime, timedelta + +from rich.text import Text +from textual.app import App +from textual.widgets import DataTable, Static + +from korvid.core.capability import ( + ApiSource, + Baseline, + Basis, + EvidenceState, + Observation, + Projection, + SourceScope, + Verb, +) +from korvid.core.config_consumers import ( + Consumer, + ConsumptionMode, + ContainerCategory, + PodIdentity, +) +from korvid.core.config_propagation import ConfigTarget, PropagationReport +from korvid.ui.widgets.config_propagation_screen import ( + ConfigPropagationScreen, + ConsumerRow, + Heading, + evidence_lines, + report_notes, + table_rows, +) + +from .waits import screen_ready, until + +NOW = datetime(2026, 10, 30, 9, 0, tzinfo=UTC) +TARGET = ConfigTarget("ConfigMap", "team-a", "settings", "uid-settings") +SCOPE = SourceScope.namespace("team-a") +OBJECT = ApiSource( + "", + "configmaps", + SCOPE, + "v1", + Verb.GET, + projection=Projection.METADATA, + name="settings", + expected_uid="uid-settings", +) +PODS = ApiSource("", "pods", SCOPE, "v1", Verb.LIST) +POD = PodIdentity("team-a", "api-1", "uid-api-1", "ReplicaSet", "api-7d9f") + + +def _seen( + source: ApiSource, basis: Basis = Basis.OBSERVED, observed_uid: str | None = None +) -> Observation: + state = { + Basis.OBSERVED: EvidenceState.AVAILABLE, + Basis.OBJECT_ABSENT: EvidenceState.AVAILABLE, + Basis.TRUNCATED: EvidenceState.PARTIAL, + Basis.FORBIDDEN: EvidenceState.DENIED, + }[basis] + return Observation(source, state, basis, NOW - timedelta(seconds=2), observed_uid=observed_uid) + + +def _report( + *consumers: Consumer, + object_basis: Basis = Basis.OBSERVED, + pods_basis: Basis = Basis.OBSERVED, + observed_uid: str | None = "uid-settings", +) -> PropagationReport: + uid = observed_uid if object_basis is Basis.OBSERVED else None + return PropagationReport( + TARGET, + _seen(OBJECT, object_basis, uid), + _seen(PODS, pods_basis), + consumers, + Baseline(EvidenceState.AVAILABLE), + ) + + +ENV = Consumer( + POD, + ConsumptionMode.ENV_FROM, + "spec.containers[0].envFrom[0].configMapRef", + False, + "app", + ContainerCategory.CONTAINER, +) +KEY = Consumer( + POD, + ConsumptionMode.ENV_KEY, + "spec.containers[0].env[1].valueFrom.configMapKeyRef", + True, + "app", + ContainerCategory.CONTAINER, + env_name="LOG_LEVEL", + key="log-level", +) +VOLUME = Consumer( + POD, + ConsumptionMode.VOLUME, + "spec.volumes[0].configMap", + False, + "app", + ContainerCategory.CONTAINER, + volume="config", + mount_path="/etc/app", +) +SUBPATH = Consumer( + POD, + ConsumptionMode.SUBPATH, + "spec.volumes[0].configMap", + False, + "setup", + ContainerCategory.INIT, + volume="config", + mount_path="/etc/app.yaml", + sub_path="app.yaml", +) +UNMOUNTED = Consumer(POD, ConsumptionMode.UNMOUNTED, "spec.volumes[1].configMap", volume="spare") + + +def _plain(lines: list[Text]) -> list[str]: + return [line.plain for line in lines] + + +def test_rows_are_grouped_by_mode_under_one_heading_each() -> None: + rows = table_rows((VOLUME, ENV, SUBPATH, KEY)) + + assert [type(row).__name__ for row in rows] == [ + "Heading", + "ConsumerRow", + "ConsumerRow", + "Heading", + "ConsumerRow", + "Heading", + "ConsumerRow", + ] + headings = [row.text for row in rows if isinstance(row, Heading)] + assert headings[0].startswith("Environment variables — read when the container starts") + assert headings[1].startswith("Volumes — updated in place by the kubelet, eventually") + assert "unless the object is immutable" in headings[1] + assert headings[2].startswith("subPath mounts — never updated") + assert [row.consumer for row in rows if isinstance(row, ConsumerRow)] == [ + ENV, + KEY, + VOLUME, + SUBPATH, + ] + + +def test_a_row_names_the_pod_container_and_reference_but_no_value() -> None: + rows = { + row.consumer: row.cells + for row in table_rows((KEY, VOLUME, SUBPATH, UNMOUNTED)) + if isinstance(row, ConsumerRow) + } + + assert rows[KEY] == ("api-1", "app", "env[1] LOG_LEVEL ← key log-level (optional)") + assert rows[VOLUME] == ("api-1", "app", "volume config → /etc/app") + assert rows[SUBPATH] == ( + "api-1", + "setup (init container)", + "volume config → /etc/app.yaml (subPath app.yaml)", + ) + assert rows[UNMOUNTED] == ("api-1", "— (Pod)", "volume spare") + + +def test_the_evidence_lines_come_from_the_shared_renderer() -> None: + lines = _plain(evidence_lines(_report(ENV), NOW)) + + assert lines == [ + "configmaps v1 in namespace team-a: available — read · observed 2s ago", + "pods v1 in namespace team-a: available — read · observed 2s ago", + ] + + +def test_no_consumer_is_said_only_when_the_pod_list_is_conclusive() -> None: + complete = _plain(report_notes(_report(), NOW)) + truncated = _plain(report_notes(_report(pods_basis=Basis.TRUNCATED), NOW)) + denied = _plain(report_notes(_report(pods_basis=Basis.FORBIDDEN), NOW)) + + assert any( + n.startswith("No Pod in namespace team-a references this ConfigMap") for n in complete + ) + assert not any(n.startswith("No Pod") for n in truncated + denied) + assert ( + "Not established: whether every consumer is listed: stopped at the page limit" in truncated + ) + assert ( + "Not established: whether every consumer is listed: your permissions do not allow this read" + ) in denied + + +def test_immutability_is_always_not_established() -> None: + notes = _plain(report_notes(_report(ENV), NOW)) + + assert ( + "Not established: whether the ConfigMap is immutable (only its metadata is read)" in notes + ) + + +def test_a_gone_or_recreated_object_is_said() -> None: + gone = _plain(report_notes(_report(ENV, object_basis=Basis.OBJECT_ABSENT), NOW)) + recreated = _plain(report_notes(_report(VOLUME, observed_uid="uid-new"), NOW)) + + assert any("no longer exists" in n for n in gone) + assert any("was recreated" in n and "volume mounts" in n for n in recreated) + assert not any("was recreated" in n for n in _plain(report_notes(_report(VOLUME), NOW))) + + +def test_an_init_container_row_explains_when_it_runs() -> None: + with_init = _plain(report_notes(_report(SUBPATH), NOW)) + without = _plain(report_notes(_report(ENV), NOW)) + + assert any(n.startswith("An init container runs to completion") for n in with_init) + assert not any(n.startswith("An init container") for n in without) + + +class _Host(App[None]): + def __init__(self, screen: ConfigPropagationScreen) -> None: + super().__init__() + self.prompt = screen + self.result: list[Consumer | None] = [] + + def on_mount(self) -> None: + self.push_screen(self.prompt, self.result.append) + + +async def test_enter_on_a_consumer_row_returns_that_consumer() -> None: + app = _Host(ConfigPropagationScreen(_report(ENV, VOLUME), NOW)) + async with app.run_test() as pilot: + await until(pilot, lambda: screen_ready(app, ConfigPropagationScreen), label="view open") + table = app.screen.query_one(DataTable) + assert table.cursor_row == 1 + await pilot.press("down", "down", "enter") + await until(pilot, lambda: bool(app.result), label="view closed") + + assert app.result == [VOLUME] + + +async def test_enter_on_a_heading_does_nothing_and_escape_closes() -> None: + app = _Host(ConfigPropagationScreen(_report(ENV), NOW)) + async with app.run_test() as pilot: + await until(pilot, lambda: screen_ready(app, ConfigPropagationScreen), label="view open") + await pilot.press("up", "enter") + await pilot.pause() + assert app.result == [] + await pilot.press("escape") + await until(pilot, lambda: bool(app.result), label="view closed") + + assert app.result == [None] + + +async def test_the_title_shows_cluster_names_literally() -> None: + hostile = ConfigTarget("ConfigMap", "team-a", "[bold]settings[/bold]") + report = PropagationReport( + hostile, + _seen(OBJECT, observed_uid="uid-settings"), + _seen(PODS), + (), + Baseline(EvidenceState.AVAILABLE), + ) + app = _Host(ConfigPropagationScreen(report, NOW)) + async with app.run_test() as pilot: + await until(pilot, lambda: screen_ready(app, ConfigPropagationScreen), label="view open") + title = app.screen.query_one("#propagation-title", Static) + assert "[bold]settings[/bold]" in str(title.render()) +``` + +- [ ] **Step 2: Run them to verify they fail** + +Expected: `ModuleNotFoundError` for the screen module. + +- [ ] **Step 3: Write the screen** + +Create `src/korvid/ui/widgets/config_propagation_screen.py` with: + +```python +"""Which Pods consume a ConfigMap or Secret, and what a change means for each (#410). + +`ConfigPropagationScreen` shows one `PropagationReport`: a line per evidence +source from the shared renderer, then the consumers grouped by how they +consume the object, then what the evidence does not establish. It performs +no I/O. Enter on a consumer row dismisses with that `Consumer`, so the +caller can open its Pod; Escape dismisses with `None`. + +Every cell is a plain `Text`, so a cluster-provided name containing markup +is shown literally. +""" + +from __future__ import annotations + +import re +from collections.abc import Sequence +from dataclasses import dataclass +from datetime import datetime +from typing import ClassVar + +from rich.text import Text +from textual.app import ComposeResult +from textual.binding import Binding +from textual.screen import ModalScreen +from textual.widgets import DataTable, Footer, Static + +from korvid.core.capability import EvidenceState, is_fresh +from korvid.core.config_consumers import Consumer, ConsumptionMode, ContainerCategory +from korvid.core.config_propagation import PropagationReport, declarations +from korvid.ui.evidence_text import basis_sentence, not_established, observation_line + +#: The sections, in display order, with what a change means for each. +_SECTIONS: tuple[tuple[frozenset[ConsumptionMode], str], ...] = ( + ( + frozenset({ConsumptionMode.ENV_FROM, ConsumptionMode.ENV_KEY}), + "Environment variables — read when the container starts; a change needs a " + "rollout restart, which asks for your approval", + ), + ( + frozenset({ConsumptionMode.VOLUME}), + "Volumes — updated in place by the kubelet, eventually, unless the object is " + "immutable; whether the application rereads them is not verified", + ), + ( + frozenset({ConsumptionMode.SUBPATH}), + "subPath mounts — never updated; a change needs a rollout restart, which asks " + "for your approval", + ), + ( + frozenset({ConsumptionMode.UNMOUNTED}), + "Unmounted volumes — no container mounts them, so nothing reads them", + ), + ( + frozenset({ConsumptionMode.IMAGE_PULL}), + "Image pull secrets — used only when an image is pulled", + ), +) +_COLUMNS = ("Pod", "Container", "Reference") +_IN_CONTAINER = re.compile(r"^spec\.(?:containers|initContainers|ephemeralContainers)\[\d+\]\.") +_INIT_NOTE = ( + "An init container runs to completion before the other containers start; once it " + "has finished, a change reaches it only when the Pod restarts." +) + + +@dataclass(frozen=True, slots=True) +class Heading: + """A section heading: the mode, then what a change means for it.""" + + text: str + + +@dataclass(frozen=True, slots=True) +class ConsumerRow: + """One consumer and its Pod, Container and Reference cells.""" + + consumer: Consumer + cells: tuple[str, str, str] + + +TableRow = Heading | ConsumerRow + + +def _container_cell(consumer: Consumer) -> str: + if consumer.category is None: + return "— (Pod)" + name = consumer.container or "?" + if consumer.category is ContainerCategory.CONTAINER: + return name + return f"{name} ({consumer.category.value})" + + +def _volume_text(consumer: Consumer) -> str: + volume = f"volume {consumer.volume or '(unnamed)'}" + if consumer.mode is ConsumptionMode.UNMOUNTED: + return volume + text = f"{volume} → {consumer.mount_path or '?'}" + if consumer.sub_path is not None: + text = f"{text} (subPath {consumer.sub_path})" + return text + + +def _reference_cell(consumer: Consumer) -> str: + relative = _IN_CONTAINER.sub("", consumer.path) + if consumer.mode is ConsumptionMode.ENV_KEY: + variable = relative.split(".valueFrom", 1)[0] + text = f"{variable} {consumer.env_name or '?'} ← key {consumer.key or '?'}" + elif consumer.mode is ConsumptionMode.ENV_FROM: + text = relative + elif consumer.mode is ConsumptionMode.IMAGE_PULL: + text = consumer.path.removeprefix("spec.") + else: + text = _volume_text(consumer) + return f"{text} (optional)" if consumer.optional else text + + +def table_rows(consumers: Sequence[Consumer]) -> list[TableRow]: + """The consumers under their section headings; an empty section is left out.""" + rows: list[TableRow] = [] + for modes, heading in _SECTIONS: + section = [consumer for consumer in consumers if consumer.mode in modes] + if section: + rows.append(Heading(heading)) + rows += ( + ConsumerRow(c, (c.pod.name, _container_cell(c), _reference_cell(c))) + for c in section + ) + return rows + + +def evidence_lines(report: PropagationReport, now: datetime) -> list[Text]: + """The object's and the Pod list's lines, each judged by its own policy.""" + policies = {declaration.key: declaration.policy for declaration in declarations(report.target)} + return [ + observation_line(report.object, policies["config-object"], now), + observation_line(report.pods, policies["pods"], now), + ] + + +def _object_notes(report: PropagationReport) -> list[Text]: + kind = report.target.kind + if report.object_absent: + tail = " The Pods below still reference it." if report.consumers else "" + return [Text(f"The {kind} no longer exists.{tail}")] + if report.recreated: + return [ + Text( + f"The {kind} was recreated since it was selected: only the new one's " + "evidence is shown, and whether existing volume mounts see it is " + "not established." + ) + ] + return [] + + +def _completeness_note(report: PropagationReport, now: datetime) -> Text | None: + target = report.target + if report.consumers_conclusive(now): + if report.consumers: + return None + return Text( + f"No Pod in namespace {target.namespace} references this {target.kind} " + "through environment variables, volumes or image pull secrets." + ) + pods = report.pods + stale = pods.state is EvidenceState.AVAILABLE and not is_fresh( + pods, declarations(target)[1].policy, now + ) + reason = "the Pod list is stale" if stale else basis_sentence(pods.basis) + return not_established(f"whether every consumer is listed: {reason}") + + +def report_notes(report: PropagationReport, now: datetime) -> list[Text]: + """What the evidence says about the object, and what it does not establish.""" + notes = _object_notes(report) + if any(consumer.category is ContainerCategory.INIT for consumer in report.consumers): + notes.append(Text(_INIT_NOTE)) + completeness = _completeness_note(report, now) + if completeness is not None: + notes.append(completeness) + notes.append( + not_established( + f"whether the {report.target.kind} is immutable (only its metadata is read)" + ) + ) + return notes + + +class ConfigPropagationScreen(ModalScreen[Consumer | None]): + """One ConfigMap's or Secret's consumers, by mode, with their evidence.""" + + BINDINGS: ClassVar[list[Binding | tuple[str, str] | tuple[str, str, str]]] = [ + Binding("escape", "close", "Close", show=True), + Binding("q", "close", "Close", show=False), + ] + + DEFAULT_CSS = """ + ConfigPropagationScreen { + layout: vertical; + background: $background; + } + ConfigPropagationScreen #propagation-title { + padding: 0 1; + text-style: bold; + } + ConfigPropagationScreen #propagation-evidence, ConfigPropagationScreen #propagation-notes { + padding: 0 1; + color: $text-muted; + } + ConfigPropagationScreen DataTable { + height: 1fr; + } + """ + + def __init__(self, report: PropagationReport, now: datetime) -> None: + super().__init__() + self.report = report + self._now = now + self._rows: dict[str, Consumer] = {} + + def compose(self) -> ComposeResult: + target = self.report.target + yield Footer() + yield Static( + f"{target.kind} {target.namespace}/{target.name}", + id="propagation-title", + markup=False, + ) + yield Static( + Text("\n").join(evidence_lines(self.report, self._now)), id="propagation-evidence" + ) + yield DataTable[Text](id="propagation-table") + yield Static(Text("\n").join(report_notes(self.report, self._now)), id="propagation-notes") + + def on_mount(self) -> None: + table: DataTable[Text] = self.query_one(DataTable) + table.cursor_type = "row" + table.add_columns(*_COLUMNS) + first: int | None = None + for index, row in enumerate(table_rows(self.report.consumers)): + if isinstance(row, Heading): + title, _, meaning = row.text.partition(" — ") + table.add_row(Text(title, style="bold"), Text(""), Text(meaning), key=f"h{index}") + continue + key = f"r{index}" + self._rows[key] = row.consumer + table.add_row(*(Text(cell) for cell in row.cells), key=key) + first = index if first is None else first + if first is not None: + table.move_cursor(row=first) + table.focus() + + def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None: + """Enter on a consumer row returns it; a heading does nothing.""" + consumer = self._rows.get(str(event.row_key.value)) + if consumer is not None: + self.dismiss(consumer) + + def action_close(self) -> None: + self.dismiss(None) +``` + +- [ ] **Step 4: Run the tests, and check the mutations** + +Expected: `10 passed`. Each change must fail a test: show empty sections; treat any Pod list as conclusive; dismiss on a heading; always add the init note; always call the object recreated; render the title as markup. + +- [ ] **Step 5: Commit** + +```bash +git commit -m "ui: show a ConfigMap's or Secret's consumers by mode" +``` + +### Task 4: The client boundary and the controller + +**Files:** +- Modify: `src/korvid/k8s/evidence_requests.py`, `src/korvid/k8s/client.py` +- Create: `src/korvid/ui/config_propagation_controller.py` +- Test: `tests/k8s/test_evidence_requests.py`, `tests/ui/test_config_propagation_controller.py` + +**Interfaces:** +- Produces: `EvidenceClient(EvidenceReader)` with `session_generation` and `discovery_snapshot()`; `CONFIG_PROPAGATION_GROUP`; `ConfigPropagationController(*, ui, view, context, client, navigate, clock)` with `unavailable_reason()`, `open_selected()`, `cancel_workers()`. + +- [ ] **Step 1: Name the client boundary** + +In `src/korvid/k8s/evidence_requests.py`, after `EvidenceReader`: + +```python +class EvidenceClient(EvidenceReader): + """What a diagnostic needs of its client: evidence reads, discovery and the session. + + `KubeClient` is the implementation. A diagnostic resolves its sources from + `discovery_snapshot()` and records `session_generation` in its run token, + so a result read under other credentials is dropped. + """ + + @property + @abstractmethod + def session_generation(self) -> int: + """An opaque counter that changes on every connect and context switch.""" + + @abstractmethod + async def discovery_snapshot(self) -> RawDiscovery: + """This session's raw discovery.""" +``` + +`KubeClient` then derives from `evidence_requests.EvidenceClient` instead of `EvidenceReader`. A test asserts `issubclass(KubeClient, EvidenceClient)`. + +- [ ] **Step 2: Write the controller's failing tests** + +Create `tests/ui/test_config_propagation_controller.py` with: + +```python +"""The configuration propagation controller: availability, runs and late results (#410).""" + +from __future__ import annotations + +from collections.abc import Awaitable, Mapping +from datetime import UTC, datetime +from typing import Any + +from korvid.core.config_consumers import Consumer, ConsumptionMode, PodIdentity +from korvid.core.config_propagation import ConfigTarget +from korvid.k8s.discovery import ResourceMeta +from korvid.k8s.discovery_snapshot import ( + GroupVersionOutcome, + RawDiscovery, + RootOutcome, + ServedResource, +) +from korvid.k8s.evidence_requests import EvidenceClient, EvidencePage, EvidenceTarget +from korvid.ui.action_availability import AvailabilityCode +from korvid.ui.config_propagation_controller import ( + CONFIG_PROPAGATION_GROUP, + ConfigPropagationController, +) +from korvid.ui.view_state import ViewState +from korvid.ui.widgets.config_propagation_screen import ConfigPropagationScreen +from korvid.ui.workspace_controller import ContextGuard + +from .test_session_timeline_controller import FakeUiSurface + +NOW = datetime(2026, 10, 30, 9, 0, tzinfo=UTC) +CONFIG_MAPS = ResourceMeta("ConfigMap", "configmaps", "", "v1", True) +SECRETS = ResourceMeta("Secret", "secrets", "", "v1", True) +PODS = ResourceMeta("Pod", "pods", "", "v1", True) +FOREIGN = ResourceMeta("ConfigMap", "configmaps", "vendor.example.com", "v1", True) +SETTINGS_POD = { + "metadata": {"name": "api-1", "namespace": "team-a", "uid": "uid-api-1"}, + "spec": {"containers": [{"name": "app", "envFrom": [{"configMapRef": {"name": "settings"}}]}]}, +} + + +class FakeView(ViewState): + def __init__(self, kind: str = "configmaps", selected: tuple[str, str] | None = None) -> None: + self.kind = kind + self.selected = selected + self.alias_map = { + "configmaps": CONFIG_MAPS, + "secrets": SECRETS, + "pods": PODS, + "configmaps.vendor.example.com": FOREIGN, + } + + def current_kind(self) -> str: + return self.kind + + def canonical_kind(self, kind: str) -> str: + return kind + + def aliases(self) -> Mapping[str, ResourceMeta]: + return self.alias_map + + def selected_ns_name(self, *, notify: bool = True) -> tuple[str | None, str | None]: + return self.selected if self.selected is not None else (None, None) + + def selected_uid(self, namespace: str | None, name: str) -> str | None: + return f"uid-{name}" + + def current_scope(self) -> str: + return "team-a" # pragma: no cover + + def resources(self, kind: str, scope: str) -> list[Any]: + return [] # pragma: no cover + + def readonly(self) -> bool: + return False # pragma: no cover + + def default_namespace(self) -> str | None: + return None # pragma: no cover + + def gvr_label(self, meta: ResourceMeta) -> str: + return meta.plural # pragma: no cover + + def write_locus(self, namespace: str | None) -> str: + return "" # pragma: no cover + + +class FakeContext(ContextGuard): + def __init__(self) -> None: + self.current = 1 + self.in_switch = False + self.allowed = True + + def epoch(self) -> int: + return self.current + + def switching(self) -> bool: + return self.in_switch + + def reads_allowed(self) -> bool: + return self.allowed + + def crossed(self, epoch: int) -> bool: + return self.in_switch or epoch != self.current + + +class FakeClient(EvidenceClient): + def __init__(self) -> None: + self.generation = 3 + self.gets: list[tuple[EvidenceTarget, str]] = [] + + @property + def session_generation(self) -> int: + return self.generation + + async def discovery_snapshot(self) -> RawDiscovery: + served = tuple( + ServedResource(name, frozenset({"get", "list"})) + for name in ("configmaps", "secrets", "pods") + ) + return RawDiscovery( + self.generation, + NOW, + 0.0, + RootOutcome("/api", (("", "v1"),)), + RootOutcome("/apis", ()), + (GroupVersionOutcome("", "v1", served),), + ) + + async def list_evidence_page( + self, target: EvidenceTarget, *, limit: int, continuation: str, max_bytes: int + ) -> EvidencePage: + return EvidencePage((SETTINGS_POD,)) + + async def get_evidence_object( + self, target: EvidenceTarget, name: str, *, max_bytes: int + ) -> dict[str, Any]: + self.gets.append((target, name)) + return {"metadata": {"name": name, "namespace": "team-a", "uid": f"uid-{name}"}} + + +class Harness: + def __init__(self, *, client: FakeClient | None = None, view: FakeView | None = None) -> None: + self.ui = FakeUiSurface(close_worker_coroutines=False) + self.view = view or FakeView(selected=("team-a", "settings")) + self.context = FakeContext() + self.client = FakeClient() if client is None else client + self.navigations: list[tuple[str, str, int, str | None]] = [] + self.controller = ConfigPropagationController( + ui=self.ui, + view=self.view, + context=self.context, + client=self.client, + navigate=self._navigate, + clock=lambda: NOW, + ) + + async def _navigate(self, namespace: str, name: str, epoch: int, uid: str | None) -> None: + self.navigations.append((namespace, name, epoch, uid)) + + async def run(self, index: int = -1) -> None: + work = self.ui.workers[index] + assert isinstance(work, Awaitable) + await work + + +def _code(harness: Harness) -> AvailabilityCode | None: + reason = harness.controller.unavailable_reason() + return None if reason is None else reason.code + + +def test_it_is_available_on_a_selected_config_map_or_secret() -> None: + assert _code(Harness()) is None + assert _code(Harness(view=FakeView("secrets", ("team-a", "creds")))) is None + + +def test_it_says_why_it_is_unavailable() -> None: + unconnected = ConfigPropagationController( + ui=FakeUiSurface(), + view=FakeView(selected=("team-a", "settings")), + context=FakeContext(), + client=None, + navigate=Harness()._navigate, + ) + + reason = unconnected.unavailable_reason() + assert reason is not None + assert reason.code is AvailabilityCode.MISSING_CAPABILITY + assert _code(Harness(view=FakeView("pods", ("team-a", "api-1")))) is AvailabilityCode.WRONG_VIEW + assert ( + _code(Harness(view=FakeView("configmaps.vendor.example.com", ("team-a", "x")))) + is AvailabilityCode.WRONG_VIEW + ) + assert _code(Harness(view=FakeView("configmaps", None))) is AvailabilityCode.NO_SELECTION + + +async def test_opening_reads_the_selected_object_and_shows_its_consumers() -> None: + harness = Harness() + + harness.controller.open_selected() + await harness.run() + + assert harness.ui.worker_groups == [(CONFIG_PROPAGATION_GROUP, False)] + (screen,) = harness.ui.screens + assert isinstance(screen, ConfigPropagationScreen) + assert screen.report.target == ConfigTarget("ConfigMap", "team-a", "settings", "uid-settings") + assert [(c.pod.name, c.mode) for c in screen.report.consumers] == [ + ("api-1", ConsumptionMode.ENV_FROM) + ] + assert [(target.resource, name) for target, name in harness.client.gets] == [ + ("configmaps", "settings") + ] + + +def test_opening_where_it_is_unavailable_says_why_and_reads_nothing() -> None: + harness = Harness(view=FakeView("pods", ("team-a", "api-1"))) + + harness.controller.open_selected() + + assert harness.ui.workers == [] + assert [n.severity for n in harness.ui.notifications] == ["warning"] + + +def test_opening_while_reads_are_held_reads_nothing() -> None: + harness = Harness() + harness.context.allowed = False + + harness.controller.open_selected() + + assert harness.ui.workers == [] + + +async def test_a_newer_run_drops_an_older_result() -> None: + harness = Harness() + + harness.controller.open_selected() + harness.controller.open_selected() + await harness.run(0) + + assert harness.ui.screens == [] + await harness.run(1) + assert len(harness.ui.screens) == 1 + + +async def test_a_context_switch_drops_the_result() -> None: + harness = Harness() + + harness.controller.open_selected() + harness.context.current = 2 + await harness.run() + + assert harness.ui.screens == [] + + +async def test_new_credentials_drop_the_result() -> None: + harness = Harness() + + harness.controller.open_selected() + harness.client.generation = 4 + await harness.run() + + assert harness.ui.screens == [] + + +async def test_choosing_a_consumer_opens_its_pod_in_the_run_epoch() -> None: + harness = Harness() + harness.controller.open_selected() + await harness.run() + consumer = Consumer( + PodIdentity("team-a", "api-1", "uid-api-1"), ConsumptionMode.ENV_FROM, "spec.x" + ) + + assert harness.ui.screen_callback is not None + harness.ui.screen_callback(None) + assert len(harness.ui.workers) == 1 + harness.context.current = 2 + harness.ui.screen_callback(consumer) + await harness.run() + + assert harness.navigations == [("team-a", "api-1", 1, "uid-api-1")] + + +async def test_a_dialog_opened_during_the_reads_keeps_the_view_closed() -> None: + harness = Harness() + harness.controller.open_selected() + harness.ui.screen_depth = lambda: 2 # type: ignore[method-assign] # a dialog opened + + await harness.run() + + assert harness.ui.screens == [] + + +async def test_the_worker_group_is_cancelled_on_request() -> None: + harness = Harness() + + await harness.controller.cancel_workers() + + assert harness.ui.cancelled_groups == [CONFIG_PROPAGATION_GROUP] +``` + +- [ ] **Step 3: Write the controller** + +Create `src/korvid/ui/config_propagation_controller.py` with: + +```python +"""Open the configuration propagation view for the selected ConfigMap or Secret (#410). + +One exclusive worker reads the object's metadata and its namespace's Pods +through `explain_propagation`, under a run token. The view opens only while +that token is current: a context switch, new credentials or a newer run +drops the result, as `RunTracker` decides, and so does a dialog that opened +meanwhile. The context-switch coordinator cancels the worker group before +it swaps the client. +""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable +from datetime import UTC, datetime + +from korvid.core.api_versions import observe_discovery +from korvid.core.capability import RunToken +from korvid.core.config_consumers import Consumer +from korvid.core.config_propagation import ConfigTarget, explain_propagation +from korvid.core.run_tokens import RunTracker +from korvid.k8s.evidence_requests import EvidenceClient +from korvid.ui.action_availability import AvailabilityCode, UnavailableReason +from korvid.ui.ui_surface import UiSurface +from korvid.ui.view_state import ViewState +from korvid.ui.widgets.config_propagation_screen import ConfigPropagationScreen +from korvid.ui.workspace_controller import ContextGuard + +CONFIG_PROPAGATION_GROUP = "config-propagation" +_KINDS = frozenset({"ConfigMap", "Secret"}) +_UNCONNECTED = UnavailableReason( + AvailabilityCode.MISSING_CAPABILITY, "Configuration propagation needs a cluster connection" +) +_WRONG_VIEW = UnavailableReason( + AvailabilityCode.WRONG_VIEW, "Open ConfigMaps or Secrets to explain their propagation" +) +_NO_SELECTION = UnavailableReason(AvailabilityCode.NO_SELECTION, "Select a ConfigMap or Secret") + +#: Opens a consumer's Pod: (namespace, name, epoch, expected UID). +PodNavigator = Callable[[str, str, int, str | None], Awaitable[None]] + + +def _now() -> datetime: + return datetime.now(UTC) + + +class ConfigPropagationController: + """Runs the propagation diagnostic and shows its result while it is current.""" + + def __init__( + self, + *, + ui: UiSurface, + view: ViewState, + context: ContextGuard, + client: EvidenceClient | None, + navigate: PodNavigator, + clock: Callable[[], datetime] = _now, + ) -> None: + self._ui = ui + self._view = view + self._context = context + self._client = client + self._navigate = navigate + self._clock = clock + self._tracker = RunTracker( + epoch=context.epoch, + session_generation=lambda: client.session_generation if client is not None else 0, + switching=context.switching, + ) + + def _selected_kind(self) -> str | None: + meta = self._view.aliases().get(self._view.canonical_kind(self._view.current_kind())) + if meta is None or meta.group or meta.kind not in _KINDS: + return None + return meta.kind + + def unavailable_reason(self) -> UnavailableReason | None: + """Why the view cannot open now, or None. + + Only for a missing connection, the wrong view or no selection: a + source the cluster refuses never disables it, the view says so. + """ + if self._client is None: + return _UNCONNECTED + if self._selected_kind() is None: + return _WRONG_VIEW + namespace, name = self._view.selected_ns_name(notify=False) + if not namespace or not name: + return _NO_SELECTION + return None + + def open_selected(self) -> None: + """Explain the selected ConfigMap or Secret in a worker.""" + reason = self.unavailable_reason() + if reason is not None: + self._ui.notify(reason.message, severity="warning", markup=False) + return + kind = self._selected_kind() + namespace, name = self._view.selected_ns_name() + if not self._context.reads_allowed() or kind is None or not namespace or not name: + return + target = ConfigTarget(kind, namespace, name, self._view.selected_uid(namespace, name)) + token = self._tracker.next_run() + self._ui.run_worker( + self._explain(target, token, self._context.epoch()), + exclusive=True, + group=CONFIG_PROPAGATION_GROUP, + exit_on_error=False, + ) + + async def _explain(self, target: ConfigTarget, token: RunToken, epoch: int) -> None: + client = self._client + if client is None: + return + snapshot = observe_discovery(await client.discovery_snapshot()) + report = await explain_propagation(client, snapshot, target, token=token, clock=self._clock) + if self._ui.screen_depth() > 1: # another dialog opened during the reads + return + screen = ConfigPropagationScreen(report, self._clock()) + self._tracker.apply_if_current( + token, + lambda: self._ui.push_screen(screen, lambda chosen: self._open_pod(chosen, epoch)), + ) + + def _open_pod(self, consumer: Consumer | None, epoch: int) -> None: + if consumer is None: + return + pod = consumer.pod + self._ui.run_worker( + self._navigate(pod.namespace, pod.name, epoch, pod.uid), + group=CONFIG_PROPAGATION_GROUP, + exit_on_error=False, + ) + + async def cancel_workers(self) -> None: + """Stop a run holding the old client, before a context switch swaps it.""" + await self._ui.cancel_workers(CONFIG_PROPAGATION_GROUP) +``` + +- [ ] **Step 4: Run the tests, and check the mutations** + +Expected: `11 passed`. Each change must fail a test: accept a ConfigMap kind from another group; ignore an open dialog; apply a result without the run token; track a fixed session generation; navigate in the current epoch rather than the run's. + +- [ ] **Step 5: Commit** + +```bash +git commit -m "k8s: name what a diagnostic needs of its client" +git commit -m "ui: run the propagation diagnostic for the selected object" +``` + +### Task 5: The command and the wiring + +**Files:** +- Modify: `src/korvid/ui/messages.py`, `ui/command.py`, `ui/command_router.py`, `ui/action_policy.py`, `ui/context_switch_coordinator.py`, `ui/app.py`, `ui/app_runtime.py`, `src/korvid/__main__.py` +- Test: `tests/ui/test_command_router.py`, `tests/ui/test_action_policy.py`, `tests/ui/test_context_switch_coordinator.py`, `tests/test_main_wiring.py`, `tests/ui/test_config_propagation_workflow.py` + +- [ ] **Step 1: Write the failing tests** + +- `test_command_router.py`: `BuiltinOperation.CONFIG_PROPAGATION` reaches a `FakePropagation.open_selected` once, and without an owner warns "Configuration propagation is unavailable in this session". +- `test_action_policy.py`: `compose_command_reasons(..., propagation=...)` answers `consumers` with the owner's reason, read live. +- `test_context_switch_coordinator.py`: `_SUCCESSFUL_ORDER` has `f"workers-cancelled:{CONFIG_PROPAGATION_GROUP}"` right after `"workspace-quiesce"`. +- `test_main_wiring.py`: `_wire_and_run` passes `evidence_client=kube` to `KorvidApp`. +- Create `tests/ui/test_config_propagation_workflow.py` with: + +```python +``` + +- [ ] **Step 2: Add the operation and the command** + +In `ui/messages.py`, `BuiltinOperation` gains `CONFIG_PROPAGATION = "consumers"`. In `ui/command.py`, before the `:q` descriptor: + +```python + CommandDescriptor( + aliases=("consumers", "config-consumers"), + help=( + ( + ":consumers|:config-consumers", + "ConfigMap or Secret consumers: Pods, mount modes, and what a change means", + ), + ), + operation=BuiltinOperation.CONFIG_PROPAGATION, + maximum_arguments=0, + palette=PaletteCommand( + "Explain configuration propagation", + "consumers", + ("propagation", "configmap", "secret", "mount", "subpath", "restart"), + ), + ), +``` + +The help text has no `h…e…l…m` subsequence: the palette also matches descriptions fuzzily, and a test pins that `helm` finds only the Helm rows. + +- [ ] **Step 3: Route it, and answer its availability** + +`ui/command_router.py` gains a `PropagationCommands` protocol (`open_selected()`), a `propagation` constructor argument, and `BuiltinOperation.CONFIG_PROPAGATION: self._open_propagation`, which warns when there is no owner. `compose_command_reasons` gains `propagation` and maps it to `"consumers"`. + +- [ ] **Step 4: Cancel the group on a context switch** + +In `ContextSwitchCoordinator._teardown`, right after `await self._workspace().quiesce_for_context_switch()`: + +```python + self._surface.cancel_worker_group(CONFIG_PROPAGATION_GROUP) +``` + +- [ ] **Step 5: Wire it** + +`KorvidApp.__init__` and `AppRuntimeInputs` gain `evidence_client: EvidenceClient | None = None`. In `_construct_app_runtime`: + +```python + 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 + ), + ) +``` + +and pass `propagation=config_propagation.unavailable_reason` to `compose_command_reasons`, `propagation=config_propagation` to `CommandRouter`, and `evidence_client=kube` to `KorvidApp` in `_wire_and_run`. The controller shows its unavailable reason with `markup=False`, as every owner does. + +- [ ] **Step 6: Run the UI suite and the size check** + +Run: `uv run --frozen pytest -p no:tach -n 8 tests/ui/ tests/test_main_wiring.py -q` and `python scripts/check_source_size.py` +Expected: all pass; `ui/app.py` is at its 1,500-line limit, not over it. + +- [ ] **Step 7: Commit** + +```bash +git commit -m "ui: open the propagation view with :consumers" +``` + +### Task 6: Verify it on every supported version + +**Files:** +- Modify: `tests/version_matrix/fixtures.yaml`, `tests/version_matrix/test_scenarios.py`, `.github/workflows/k8s-version-matrix.yml`, `docs/kubernetes-support.md` + +- [ ] **Step 1: Add the consumer Pod** + +After the `settings` ConfigMap in `fixtures.yaml`, a Pod `consumer` on `registry.k8s.io/pause:3.10` reads `settings` through `envFrom`, mounts it at `/etc/settings`, mounts its `password` key with `subPath`, and has a literal env value `matrix-pod-literal`. + +- [ ] **Step 2: Add the scenario** + +```python +async def test_a_config_maps_consumers_are_read_by_mode(connect: Connect) -> None: + kube = await connect(ADMIN_CONTEXT) + + report = await explain_propagation( + kube, await snapshot_of(kube), ConfigTarget("ConfigMap", NAMESPACE, "settings") + ) + + assert _state(report.object) == (EvidenceState.AVAILABLE, Basis.OBSERVED) + assert _state(report.pods) == (EvidenceState.AVAILABLE, Basis.OBSERVED) + assert sorted((c.pod.name, c.container, c.mode) for c in report.consumers) == [ + ("consumer", "app", ConsumptionMode.ENV_FROM), + ("consumer", "app", ConsumptionMode.SUBPATH), + ("consumer", "app", ConsumptionMode.VOLUME), + ] + assert report.consumers_conclusive(datetime.now(UTC)) + assert "matrix-configmap-payload" not in repr(report) + assert "matrix-pod-literal" not in repr(report) +``` + +- [ ] **Step 3: Cover the new modules in the workflow's path filters, and list the scenario in `docs/kubernetes-support.md`** + +- [ ] **Step 4: Commit** + +```bash +git commit -m "tests: verify ConfigMap consumers by mode on every supported version" +``` + +### Task 7: Document it + +- [ ] **Step 1:** `docs/tui.md` gains a "Configuration propagation" section with the modes table; `docs/keybindings.md` lists `:consumers`; `docs/release-notes/unreleased.md` announces it. +- [ ] **Step 2:** Run `uv run --frozen mkdocs build --strict` and `uv run --frozen pytest -p no:tach tests/test_docs_*.py -q`; both pass. +- [ ] **Step 3: Commit** + +```bash +git commit -m "docs: explain configuration propagation and :consumers" +``` + +### Task 8: Run the full gate + +- [ ] **Step 1:** Run `make check`. Expected: ruff, format, mypy, tach, the source-size check and the test suite pass, with coverage at least 80%. From 028f4331dffd0421900575fa44346db1d38eee14 Mon Sep 17 00:00:00 2001 From: hellices Date: Fri, 30 Oct 2026 03:17:45 +0900 Subject: [PATCH 10/21] ui: age the open propagation view, and name the forms it checks The view took a fixed now, so left open past its freshness limit it kept saying observed 2s ago and that no Pod references the object. It now takes a clock and re-renders its evidence lines and notes every few seconds without reading the cluster again: aged evidence is marked stale, and a stale Pod list no longer proves absence. "No Pod references it" now names the forms checked - envFrom, env valueFrom, configMap, secret and projected volumes, imagePullSecrets - and says CSI and other volume plugins are not, so a Secret used only through a CSI driver is not called unused. Part of #502. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../ui/widgets/config_propagation_screen.py | 37 ++++++++++++++----- tests/ui/test_config_propagation_screen.py | 36 ++++++++++++++++-- 2 files changed, 60 insertions(+), 13 deletions(-) diff --git a/src/korvid/ui/widgets/config_propagation_screen.py b/src/korvid/ui/widgets/config_propagation_screen.py index f895f28c6..7a4d9aec6 100644 --- a/src/korvid/ui/widgets/config_propagation_screen.py +++ b/src/korvid/ui/widgets/config_propagation_screen.py @@ -3,7 +3,9 @@ `ConfigPropagationScreen` shows one `PropagationReport`: a line per evidence source from the shared renderer, then the consumers grouped by how they consume the object, then what the evidence does not establish. It performs -no I/O. Enter on a consumer row dismisses with that `Consumer`, so the +no I/O, but re-renders the evidence lines and notes against its clock while +it is open, so evidence that ages past its policy is marked stale and stops +proving that no Pod references the object. Enter on a consumer row dismisses with that `Consumer`, so the caller can open its Pod; Escape dismisses with `None`. Every cell is a plain `Text`, so a cluster-provided name containing markup @@ -13,7 +15,7 @@ from __future__ import annotations import re -from collections.abc import Sequence +from collections.abc import Callable, Sequence from dataclasses import dataclass from datetime import datetime from typing import ClassVar @@ -159,8 +161,9 @@ def _completeness_note(report: PropagationReport, now: datetime) -> Text | None: if report.consumers: return None return Text( - f"No Pod in namespace {target.namespace} references this {target.kind} " - "through environment variables, volumes or image pull secrets." + f"No Pod in namespace {target.namespace} references this {target.kind} through " + "envFrom, env valueFrom, configMap, secret or projected volumes, or " + "imagePullSecrets. CSI and other volume plugins are not checked." ) pods = report.pods stale = pods.state is EvidenceState.AVAILABLE and not is_fresh( @@ -212,10 +215,17 @@ class ConfigPropagationScreen(ModalScreen[Consumer | None]): } """ - def __init__(self, report: PropagationReport, now: datetime) -> None: + def __init__( + self, + report: PropagationReport, + clock: Callable[[], datetime], + *, + refresh_seconds: float = 5.0, + ) -> None: super().__init__() self.report = report - self._now = now + self._clock = clock + self._refresh_seconds = refresh_seconds self._rows: dict[str, Consumer] = {} def compose(self) -> ComposeResult: @@ -226,13 +236,20 @@ def compose(self) -> ComposeResult: id="propagation-title", markup=False, ) - yield Static( - Text("\n").join(evidence_lines(self.report, self._now)), id="propagation-evidence" - ) + yield Static(id="propagation-evidence") yield DataTable[Text](id="propagation-table") - yield Static(Text("\n").join(report_notes(self.report, self._now)), id="propagation-notes") + yield Static(id="propagation-notes") + + def _render_text(self) -> None: + now = self._clock() + evidence = Text("\n").join(evidence_lines(self.report, now)) + self.query_one("#propagation-evidence", Static).update(evidence) + notes = Text("\n").join(report_notes(self.report, now)) + self.query_one("#propagation-notes", Static).update(notes) def on_mount(self) -> None: + self._render_text() + self.set_interval(self._refresh_seconds, self._render_text) table: DataTable[Text] = self.query_one(DataTable) table.cursor_type = "row" table.add_columns(*_COLUMNS) diff --git a/tests/ui/test_config_propagation_screen.py b/tests/ui/test_config_propagation_screen.py index 00526907b..818397a65 100644 --- a/tests/ui/test_config_propagation_screen.py +++ b/tests/ui/test_config_propagation_screen.py @@ -186,6 +186,9 @@ def test_no_consumer_is_said_only_when_the_pod_list_is_conclusive() -> None: assert any( n.startswith("No Pod in namespace team-a references this ConfigMap") for n in complete ) + (none,) = [n for n in complete if n.startswith("No Pod")] + assert "configMap, secret or projected volumes" in none + assert "CSI and other volume plugins are not checked" in none assert not any(n.startswith("No Pod") for n in truncated + denied) assert ( "Not established: whether every consumer is listed: stopped at the page limit" in truncated @@ -231,7 +234,7 @@ def on_mount(self) -> None: async def test_enter_on_a_consumer_row_returns_that_consumer() -> None: - app = _Host(ConfigPropagationScreen(_report(ENV, VOLUME), NOW)) + app = _Host(ConfigPropagationScreen(_report(ENV, VOLUME), lambda: NOW)) async with app.run_test() as pilot: await until(pilot, lambda: screen_ready(app, ConfigPropagationScreen), label="view open") table = app.screen.query_one(DataTable) @@ -243,7 +246,7 @@ async def test_enter_on_a_consumer_row_returns_that_consumer() -> None: async def test_enter_on_a_heading_does_nothing_and_escape_closes() -> None: - app = _Host(ConfigPropagationScreen(_report(ENV), NOW)) + app = _Host(ConfigPropagationScreen(_report(ENV), lambda: NOW)) async with app.run_test() as pilot: await until(pilot, lambda: screen_ready(app, ConfigPropagationScreen), label="view open") await pilot.press("up", "enter") @@ -264,8 +267,35 @@ async def test_the_title_shows_cluster_names_literally() -> None: (), Baseline(EvidenceState.AVAILABLE), ) - app = _Host(ConfigPropagationScreen(report, NOW)) + app = _Host(ConfigPropagationScreen(report, lambda: NOW)) async with app.run_test() as pilot: await until(pilot, lambda: screen_ready(app, ConfigPropagationScreen), label="view open") title = app.screen.query_one("#propagation-title", Static) assert "[bold]settings[/bold]" in str(title.render()) + + +def test_a_list_that_has_aged_past_its_policy_no_longer_proves_absence() -> None: + later = NOW + timedelta(minutes=5) + + notes = _plain(report_notes(_report(), later)) + + assert not any(n.startswith("No Pod") for n in notes) + assert "Not established: whether every consumer is listed: the Pod list is stale" in notes + + +async def test_an_open_view_ages_its_evidence_without_reading_again() -> None: + now = [NOW] + app = _Host(ConfigPropagationScreen(_report(), lambda: now[0], refresh_seconds=0.05)) + async with app.run_test() as pilot: + await until(pilot, lambda: screen_ready(app, ConfigPropagationScreen), label="view open") + evidence = app.screen.query_one("#propagation-evidence", Static) + notes = app.screen.query_one("#propagation-notes", Static) + assert "stale" not in str(evidence.render()) + assert "No Pod in namespace team-a" in str(notes.render()) + + now[0] = NOW + timedelta(minutes=5) + await until(pilot, lambda: "· stale" in str(evidence.render()), label="evidence aged") + + assert "observed 5m ago" in str(evidence.render()) + assert "No Pod in namespace team-a" not in str(notes.render()) + assert "the Pod list is stale" in str(notes.render()) From 55319e405257e70081e3d246ae266d23fd347905 Mon Sep 17 00:00:00 2001 From: hellices Date: Fri, 30 Oct 2026 03:17:45 +0900 Subject: [PATCH 11/21] ui: open a propagation result only where it was asked for A run's token stays current when the user changes view, scope or selection, so a slow run could open its report over a different workspace. The controller now records the view, scope and selected row a run starts from and opens the view only while they are unchanged, as the relationship view guards its pane and view. The test clients record every request, and the tests assert the exact targets: the object's metadata GET and the Pod LIST in the selected namespace, with the first continuation and the run's budgets. Part of #502. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../ui/config_propagation_controller.py | 21 +++++++++++--- .../ui/test_config_propagation_controller.py | 29 +++++++++++++++++-- 2 files changed, 43 insertions(+), 7 deletions(-) diff --git a/src/korvid/ui/config_propagation_controller.py b/src/korvid/ui/config_propagation_controller.py index f25033056..6082f911b 100644 --- a/src/korvid/ui/config_propagation_controller.py +++ b/src/korvid/ui/config_propagation_controller.py @@ -37,6 +37,8 @@ #: Opens a consumer's Pod: (namespace, name, epoch, expected UID). PodNavigator = Callable[[str, str, int, str | None], Awaitable[None]] +#: The view, scope and (namespace, name) selection a run was started from. +_Origin = tuple[str, str, tuple[str | None, str | None]] def _now() -> datetime: @@ -102,21 +104,32 @@ def open_selected(self) -> None: target = ConfigTarget(kind, namespace, name, self._view.selected_uid(namespace, name)) token = self._tracker.next_run() self._ui.run_worker( - self._explain(target, token, self._context.epoch()), + self._explain(target, token, self._context.epoch(), self._origin()), exclusive=True, group=CONFIG_PROPAGATION_GROUP, exit_on_error=False, ) - async def _explain(self, target: ConfigTarget, token: RunToken, epoch: int) -> None: + def _origin(self) -> _Origin: + """The view, scope and selected row a run explains.""" + return ( + self._view.current_kind(), + self._view.current_scope(), + self._view.selected_ns_name(notify=False), + ) + + async def _explain( + self, target: ConfigTarget, token: RunToken, epoch: int, origin: _Origin + ) -> None: client = self._client if client is None: return snapshot = observe_discovery(await client.discovery_snapshot()) report = await explain_propagation(client, snapshot, target, token=token, clock=self._clock) - if self._ui.screen_depth() > 1: # another dialog opened during the reads + # Another dialog opened, or the user moved on, during the reads. + if self._ui.screen_depth() > 1 or self._origin() != origin: return - screen = ConfigPropagationScreen(report, self._clock()) + screen = ConfigPropagationScreen(report, self._clock) self._tracker.apply_if_current( token, lambda: self._ui.push_screen(screen, lambda chosen: self._open_pod(chosen, epoch)), diff --git a/tests/ui/test_config_propagation_controller.py b/tests/ui/test_config_propagation_controller.py index 94cbc68b9..ad47bfe42 100644 --- a/tests/ui/test_config_propagation_controller.py +++ b/tests/ui/test_config_propagation_controller.py @@ -8,6 +8,7 @@ from korvid.core.config_consumers import Consumer, ConsumptionMode, PodIdentity from korvid.core.config_propagation import ConfigTarget +from korvid.core.evidence_reads import LIST_PAGE_SIZE, MAX_BYTES from korvid.k8s.discovery import ResourceMeta from korvid.k8s.discovery_snapshot import ( GroupVersionOutcome, @@ -41,6 +42,7 @@ class FakeView(ViewState): def __init__(self, kind: str = "configmaps", selected: tuple[str, str] | None = None) -> None: self.kind = kind + self.scope = "team-a" self.selected = selected self.alias_map = { "configmaps": CONFIG_MAPS, @@ -65,7 +67,7 @@ def selected_uid(self, namespace: str | None, name: str) -> str | None: return f"uid-{name}" def current_scope(self) -> str: - return "team-a" # pragma: no cover + return self.scope def resources(self, kind: str, scope: str) -> list[Any]: return [] # pragma: no cover @@ -106,6 +108,7 @@ class FakeClient(EvidenceClient): def __init__(self) -> None: self.generation = 3 self.gets: list[tuple[EvidenceTarget, str]] = [] + self.lists: list[tuple[EvidenceTarget, int, str, int]] = [] @property def session_generation(self) -> int: @@ -128,6 +131,7 @@ async def discovery_snapshot(self) -> RawDiscovery: async def list_evidence_page( self, target: EvidenceTarget, *, limit: int, continuation: str, max_bytes: int ) -> EvidencePage: + self.lists.append((target, limit, continuation, max_bytes)) return EvidencePage((SETTINGS_POD,)) async def get_evidence_object( @@ -205,8 +209,11 @@ async def test_opening_reads_the_selected_object_and_shows_its_consumers() -> No assert [(c.pod.name, c.mode) for c in screen.report.consumers] == [ ("api-1", ConsumptionMode.ENV_FROM) ] - assert [(target.resource, name) for target, name in harness.client.gets] == [ - ("configmaps", "settings") + assert harness.client.gets == [ + (EvidenceTarget("", "v1", "configmaps", "team-a", metadata_only=True), "settings") + ] + assert harness.client.lists == [ + (EvidenceTarget("", "v1", "pods", "team-a"), LIST_PAGE_SIZE, "", MAX_BYTES) ] @@ -288,6 +295,22 @@ async def test_a_dialog_opened_during_the_reads_keeps_the_view_closed() -> None: assert harness.ui.screens == [] +async def test_leaving_the_view_or_the_selection_keeps_the_result_closed() -> None: + moved = Harness() + rescoped = Harness() + reselected = Harness() + + for harness in (moved, rescoped, reselected): + harness.controller.open_selected() + moved.view.kind = "pods" + rescoped.view.scope = "team-b" + reselected.view.selected = ("team-a", "other") + for harness in (moved, rescoped, reselected): + await harness.run() + + assert (moved.ui.screens, rescoped.ui.screens, reselected.ui.screens) == ([], [], []) + + async def test_the_worker_group_is_cancelled_on_request() -> None: harness = Harness() From 6411e1f635f60e4fe8aa6c63fa90a0e7fce0b646 Mon Sep 17 00:00:00 2001 From: hellices Date: Fri, 30 Oct 2026 03:17:45 +0900 Subject: [PATCH 12/21] ui: report a failed propagation run instead of dropping it The propagation worker runs with exit_on_error=False, but the app reported failures only for the relationship and timeline groups, so a failed run showed nothing. worker_failures.py now names every reported group with its label, config-propagation included, and on_worker_state_changed looks the label up there - which also frees ten lines in ui/app.py. Part of #502. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/korvid/ui/app.py | 28 ++++------ src/korvid/ui/worker_failures.py | 26 ++++++++++ tests/ui/test_config_propagation_workflow.py | 54 +++++++++++++++++--- 3 files changed, 83 insertions(+), 25 deletions(-) create mode 100644 src/korvid/ui/worker_failures.py diff --git a/src/korvid/ui/app.py b/src/korvid/ui/app.py index 2380915f7..10d5f4093 100644 --- a/src/korvid/ui/app.py +++ b/src/korvid/ui/app.py @@ -98,10 +98,6 @@ UnknownCommand, ) from korvid.ui.navigation import NavigationStack -from korvid.ui.session_timeline_controller import ( - TIMELINE_EVENT_GROUP, - TIMELINE_NAVIGATION_GROUP, -) from korvid.ui.widgets.action_palette import ActionPaletteScreen from korvid.ui.widgets.agent_panel import AgentPanel from korvid.ui.widgets.command_bar import CommandBar @@ -117,9 +113,7 @@ from korvid.ui.widgets.resource_table import ResourceTable, validate_selected_view from korvid.ui.widgets.status_bar import StatusBar from korvid.ui.widgets.top_bar import KeyEntry, TopBar -from korvid.ui.workspace_controller import ( - RELATIONSHIP_GROUP, -) +from korvid.ui.worker_failures import REPORTED_WORKER_GROUPS from korvid.ui.workspace_state import PaneState, filtered_rows _DEFAULT_ALIASES: dict[str, ResourceMeta] = { @@ -908,22 +902,18 @@ def action_relationships(self) -> None: self._workspace_ctl.show_relationships() def on_worker_state_changed(self, event: Worker.StateChanged) -> None: - """Report a failed timeline or relationship worker instead of exiting. + """Report a failed worker of a `REPORTED_WORKER_GROUPS` group instead of exiting. - Scoped to this app's own `relationships`, `timeline-warning-events`, - and `timeline` (goto) workers, which are the only ones started with - `exit_on_error=False`: every other group keeps Textual's default - crash-on-error behaviour, and a cancelled worker never reaches - `WorkerState.ERROR`, so it is never reported. + Those are this app's own workers started with `exit_on_error=False`: + every other group keeps Textual's default crash-on-error behaviour, + and a cancelled worker never reaches `WorkerState.ERROR`, so it is + never reported. """ if event.worker.node is not self or event.state is not WorkerState.ERROR: return - if event.worker.group == TIMELINE_EVENT_GROUP: - self._notify_worker_error("Warning-event timeline feed failed", event.worker) - elif event.worker.group == RELATIONSHIP_GROUP: - self._notify_worker_error("Relationships failed", event.worker) - elif event.worker.group == TIMELINE_NAVIGATION_GROUP: - self._notify_worker_error("Timeline navigation failed", event.worker) + label = REPORTED_WORKER_GROUPS.get(event.worker.group) + if label is not None: + self._notify_worker_error(label, event.worker) def _notify_worker_error(self, label: str, worker: Worker[Any]) -> None: """One visible report for a worker that failed instead of crashing.""" diff --git a/src/korvid/ui/worker_failures.py b/src/korvid/ui/worker_failures.py new file mode 100644 index 000000000..8f0db2f65 --- /dev/null +++ b/src/korvid/ui/worker_failures.py @@ -0,0 +1,26 @@ +"""The worker groups whose failures the app reports instead of crashing. + +These workers run with `exit_on_error=False`, so a failure must reach the +user some other way: `KorvidApp.on_worker_state_changed` notifies with the +label kept here. Every other group keeps Textual's default crash-on-error +behaviour, and a cancelled worker never reaches `WorkerState.ERROR`. +""" + +from __future__ import annotations + +from collections.abc import Mapping +from types import MappingProxyType + +from korvid.ui.config_propagation_controller import CONFIG_PROPAGATION_GROUP +from korvid.ui.session_timeline_controller import TIMELINE_EVENT_GROUP, TIMELINE_NAVIGATION_GROUP +from korvid.ui.workspace_controller import RELATIONSHIP_GROUP + +#: Worker group -> what the notification says failed. +REPORTED_WORKER_GROUPS: Mapping[str, str] = MappingProxyType( + { + TIMELINE_EVENT_GROUP: "Warning-event timeline feed failed", + RELATIONSHIP_GROUP: "Relationships failed", + TIMELINE_NAVIGATION_GROUP: "Timeline navigation failed", + CONFIG_PROPAGATION_GROUP: "Configuration propagation failed", + } +) diff --git a/tests/ui/test_config_propagation_workflow.py b/tests/ui/test_config_propagation_workflow.py index d48397dd6..a742b52b1 100644 --- a/tests/ui/test_config_propagation_workflow.py +++ b/tests/ui/test_config_propagation_workflow.py @@ -42,7 +42,7 @@ class Client(EvidenceClient): """Serves configmaps, secrets and pods, and one Pod that reads `settings`.""" def __init__(self) -> None: - self.reads: list[str] = [] + self.reads: list[tuple[str, EvidenceTarget, str]] = [] @property def session_generation(self) -> int: @@ -65,14 +65,14 @@ async def discovery_snapshot(self) -> RawDiscovery: async def list_evidence_page( self, target: EvidenceTarget, *, limit: int, continuation: str, max_bytes: int ) -> EvidencePage: - self.reads.append(f"list {target.resource}") - return EvidencePage((SETTINGS_POD,)) + self.reads.append(("list", target, continuation)) + return EvidencePage((SETTINGS_POD,) if target.namespace == "default" else ()) async def get_evidence_object( self, target: EvidenceTarget, name: str, *, max_bytes: int ) -> dict[str, Any]: - self.reads.append(f"get {target.resource}/{name} metadata={target.metadata_only}") - return {"metadata": {"name": name, "namespace": "default", "uid": "uid-settings"}} + self.reads.append(("get", target, name)) + return {"metadata": {"name": name, "namespace": target.namespace, "uid": "uid-settings"}} async def _source(kind: str, scope: str) -> AsyncIterator[tuple[str, Summary]]: @@ -113,7 +113,14 @@ async def test_consumers_of_a_config_map_lead_to_their_pod() -> None: assert isinstance(screen, ConfigPropagationScreen) assert [c.pod.name for c in screen.report.consumers] == ["api-1"] assert "s3cr3t-literal" not in repr(screen.report) - assert client.reads == ["get configmaps/settings metadata=True", "list pods"] + assert sorted(client.reads, key=lambda read: read[0]) == [ + ( + "get", + EvidenceTarget("", "v1", "configmaps", "default", metadata_only=True), + "settings", + ), + ("list", EvidenceTarget("", "v1", "pods", "default"), ""), + ] await pilot.press("enter") await until(pilot, lambda: app.current_kind == "pods", label="Pod view") @@ -142,3 +149,38 @@ async def test_consumers_on_another_view_says_what_to_open() -> None: label="reason shown", ) assert not isinstance(app.screen, ConfigPropagationScreen) + + +class BrokenClient(Client): + async def discovery_snapshot(self) -> RawDiscovery: + raise RuntimeError("discovery exploded") + + +async def test_a_failed_run_is_reported_instead_of_vanishing() -> None: + store = ResourceStore() + app = build_test_app( + config=KorvidConfig(namespace="default"), + store=store, + watch_manager=WatchManager(store, _source), + aliases=build_alias_map([PODS_META, CONFIG_MAPS]), + agent_available=False, + evidence_client=BrokenClient(), + ) + async with app.run_test() as pilot: + await _command(pilot, "configmaps") + await until( + pilot, + lambda: app._view.selected_ns_name(notify=False) == ("default", "settings"), + label="ConfigMap selected", + ) + await _command(pilot, "consumers") + await until( + pilot, + lambda: any( + "Configuration propagation failed" in n.message + and "discovery exploded" in n.message + for n in app._notifications + ), + label="failure reported", + ) + assert app.is_running From 2bb52ad6bb88ec8d5d8baae83f23a9b63f3831be Mon Sep 17 00:00:00 2001 From: hellices Date: Fri, 30 Oct 2026 03:17:45 +0900 Subject: [PATCH 13/21] docs: say the open view ages and which forms are checked Part of #502. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../2026-10-30-config-propagation-view.md | 138 ++++++++++++++---- docs/tui.md | 7 +- 2 files changed, 117 insertions(+), 28 deletions(-) diff --git a/docs/superpowers/plans/2026-10-30-config-propagation-view.md b/docs/superpowers/plans/2026-10-30-config-propagation-view.md index 50955399e..569e60edd 100644 --- a/docs/superpowers/plans/2026-10-30-config-propagation-view.md +++ b/docs/superpowers/plans/2026-10-30-config-propagation-view.md @@ -16,7 +16,7 @@ - **Literal names:** every cluster-provided name is a plain `Text`, never parsed as markup. - **Conclusions from absence:** "no Pod references it" only when the Pod list is conclusive; otherwise "Not established" with the reason. - **Availability:** the entry is unavailable only without a connection, on the wrong view, or without a selection. -- **Late results:** dropped after a context switch, new credentials or a newer run; the context switch cancels the worker group. +- **Late results:** dropped after a context switch, new credentials or a newer run, or when the user has left the view, scope or selection the run started from; the context switch cancels the worker group, and a failed worker is reported, not dropped. - **No values, no writes:** the view shows references only; restarting is a suggestion. - **Size limits:** `scripts/check_source_size.py` is not edited; `__main__.py` and `ui/app.py` make room by moving code. - **Code rules:** no `assert` in `src/`; every `pytest.raises` takes `match=`; maximum complexity 10; Google-style docstrings. @@ -40,6 +40,7 @@ | `src/korvid/ui/evidence_text.py` | create | basis sentences, ages, source text, observation lines | | `src/korvid/ui/widgets/config_propagation_screen.py` | create | rows by mode, notes, the modal screen | | `src/korvid/ui/config_propagation_controller.py` | create | availability, the run, late results, Pod navigation | +| `src/korvid/ui/worker_failures.py` | create | the worker groups whose failures the app reports, `config-propagation` among them | | `src/korvid/ui/messages.py`, `ui/command.py`, `ui/command_router.py`, `ui/action_policy.py` | modify | `:consumers` and its palette availability | | `src/korvid/ui/context_switch_coordinator.py` | modify | cancel the worker group before the swap | | `src/korvid/ui/app.py`, `ui/app_runtime.py` | modify | the `evidence_client` input | @@ -411,7 +412,7 @@ git commit -m "ui: render evidence observations with one shared renderer" **Interfaces:** - Consumes: `PropagationReport`, `declarations` (#503); `observation_line`, `not_established`, `basis_sentence` (Task 2). -- Produces: `Heading`, `ConsumerRow`, `table_rows(consumers)`, `evidence_lines(report, now)`, `report_notes(report, now)`, `ConfigPropagationScreen(report, now)`, a `ModalScreen[Consumer | None]`. +- Produces: `Heading`, `ConsumerRow`, `table_rows(consumers)`, `evidence_lines(report, now)`, `report_notes(report, now)`, `ConfigPropagationScreen(report, clock, *, refresh_seconds=5.0)`, a `ModalScreen[Consumer | None]` that re-renders its evidence lines and notes against `clock` while open, so aged evidence is marked stale and stops proving absence. - [ ] **Step 1: Write the failing tests** @@ -606,6 +607,9 @@ def test_no_consumer_is_said_only_when_the_pod_list_is_conclusive() -> None: assert any( n.startswith("No Pod in namespace team-a references this ConfigMap") for n in complete ) + (none,) = [n for n in complete if n.startswith("No Pod")] + assert "configMap, secret or projected volumes" in none + assert "CSI and other volume plugins are not checked" in none assert not any(n.startswith("No Pod") for n in truncated + denied) assert ( "Not established: whether every consumer is listed: stopped at the page limit" in truncated @@ -651,7 +655,7 @@ class _Host(App[None]): async def test_enter_on_a_consumer_row_returns_that_consumer() -> None: - app = _Host(ConfigPropagationScreen(_report(ENV, VOLUME), NOW)) + app = _Host(ConfigPropagationScreen(_report(ENV, VOLUME), lambda: NOW)) async with app.run_test() as pilot: await until(pilot, lambda: screen_ready(app, ConfigPropagationScreen), label="view open") table = app.screen.query_one(DataTable) @@ -663,7 +667,7 @@ async def test_enter_on_a_consumer_row_returns_that_consumer() -> None: async def test_enter_on_a_heading_does_nothing_and_escape_closes() -> None: - app = _Host(ConfigPropagationScreen(_report(ENV), NOW)) + app = _Host(ConfigPropagationScreen(_report(ENV), lambda: NOW)) async with app.run_test() as pilot: await until(pilot, lambda: screen_ready(app, ConfigPropagationScreen), label="view open") await pilot.press("up", "enter") @@ -684,11 +688,38 @@ async def test_the_title_shows_cluster_names_literally() -> None: (), Baseline(EvidenceState.AVAILABLE), ) - app = _Host(ConfigPropagationScreen(report, NOW)) + app = _Host(ConfigPropagationScreen(report, lambda: NOW)) async with app.run_test() as pilot: await until(pilot, lambda: screen_ready(app, ConfigPropagationScreen), label="view open") title = app.screen.query_one("#propagation-title", Static) assert "[bold]settings[/bold]" in str(title.render()) + + +def test_a_list_that_has_aged_past_its_policy_no_longer_proves_absence() -> None: + later = NOW + timedelta(minutes=5) + + notes = _plain(report_notes(_report(), later)) + + assert not any(n.startswith("No Pod") for n in notes) + assert "Not established: whether every consumer is listed: the Pod list is stale" in notes + + +async def test_an_open_view_ages_its_evidence_without_reading_again() -> None: + now = [NOW] + app = _Host(ConfigPropagationScreen(_report(), lambda: now[0], refresh_seconds=0.05)) + async with app.run_test() as pilot: + await until(pilot, lambda: screen_ready(app, ConfigPropagationScreen), label="view open") + evidence = app.screen.query_one("#propagation-evidence", Static) + notes = app.screen.query_one("#propagation-notes", Static) + assert "stale" not in str(evidence.render()) + assert "No Pod in namespace team-a" in str(notes.render()) + + now[0] = NOW + timedelta(minutes=5) + await until(pilot, lambda: "· stale" in str(evidence.render()), label="evidence aged") + + assert "observed 5m ago" in str(evidence.render()) + assert "No Pod in namespace team-a" not in str(notes.render()) + assert "the Pod list is stale" in str(notes.render()) ``` - [ ] **Step 2: Run them to verify they fail** @@ -705,7 +736,9 @@ Create `src/korvid/ui/widgets/config_propagation_screen.py` with: `ConfigPropagationScreen` shows one `PropagationReport`: a line per evidence source from the shared renderer, then the consumers grouped by how they consume the object, then what the evidence does not establish. It performs -no I/O. Enter on a consumer row dismisses with that `Consumer`, so the +no I/O, but re-renders the evidence lines and notes against its clock while +it is open, so evidence that ages past its policy is marked stale and stops +proving that no Pod references the object. Enter on a consumer row dismisses with that `Consumer`, so the caller can open its Pod; Escape dismisses with `None`. Every cell is a plain `Text`, so a cluster-provided name containing markup @@ -715,7 +748,7 @@ is shown literally. from __future__ import annotations import re -from collections.abc import Sequence +from collections.abc import Callable, Sequence from dataclasses import dataclass from datetime import datetime from typing import ClassVar @@ -861,8 +894,9 @@ def _completeness_note(report: PropagationReport, now: datetime) -> Text | None: if report.consumers: return None return Text( - f"No Pod in namespace {target.namespace} references this {target.kind} " - "through environment variables, volumes or image pull secrets." + f"No Pod in namespace {target.namespace} references this {target.kind} through " + "envFrom, env valueFrom, configMap, secret or projected volumes, or " + "imagePullSecrets. CSI and other volume plugins are not checked." ) pods = report.pods stale = pods.state is EvidenceState.AVAILABLE and not is_fresh( @@ -914,10 +948,17 @@ class ConfigPropagationScreen(ModalScreen[Consumer | None]): } """ - def __init__(self, report: PropagationReport, now: datetime) -> None: + def __init__( + self, + report: PropagationReport, + clock: Callable[[], datetime], + *, + refresh_seconds: float = 5.0, + ) -> None: super().__init__() self.report = report - self._now = now + self._clock = clock + self._refresh_seconds = refresh_seconds self._rows: dict[str, Consumer] = {} def compose(self) -> ComposeResult: @@ -928,13 +969,20 @@ class ConfigPropagationScreen(ModalScreen[Consumer | None]): id="propagation-title", markup=False, ) - yield Static( - Text("\n").join(evidence_lines(self.report, self._now)), id="propagation-evidence" - ) + yield Static(id="propagation-evidence") yield DataTable[Text](id="propagation-table") - yield Static(Text("\n").join(report_notes(self.report, self._now)), id="propagation-notes") + yield Static(id="propagation-notes") + + def _render_text(self) -> None: + now = self._clock() + evidence = Text("\n").join(evidence_lines(self.report, now)) + self.query_one("#propagation-evidence", Static).update(evidence) + notes = Text("\n").join(report_notes(self.report, now)) + self.query_one("#propagation-notes", Static).update(notes) def on_mount(self) -> None: + self._render_text() + self.set_interval(self._refresh_seconds, self._render_text) table: DataTable[Text] = self.query_one(DataTable) table.cursor_type = "row" table.add_columns(*_COLUMNS) @@ -1022,6 +1070,7 @@ from typing import Any from korvid.core.config_consumers import Consumer, ConsumptionMode, PodIdentity from korvid.core.config_propagation import ConfigTarget +from korvid.core.evidence_reads import LIST_PAGE_SIZE, MAX_BYTES from korvid.k8s.discovery import ResourceMeta from korvid.k8s.discovery_snapshot import ( GroupVersionOutcome, @@ -1055,6 +1104,7 @@ SETTINGS_POD = { class FakeView(ViewState): def __init__(self, kind: str = "configmaps", selected: tuple[str, str] | None = None) -> None: self.kind = kind + self.scope = "team-a" self.selected = selected self.alias_map = { "configmaps": CONFIG_MAPS, @@ -1079,7 +1129,7 @@ class FakeView(ViewState): return f"uid-{name}" def current_scope(self) -> str: - return "team-a" # pragma: no cover + return self.scope def resources(self, kind: str, scope: str) -> list[Any]: return [] # pragma: no cover @@ -1120,6 +1170,7 @@ class FakeClient(EvidenceClient): def __init__(self) -> None: self.generation = 3 self.gets: list[tuple[EvidenceTarget, str]] = [] + self.lists: list[tuple[EvidenceTarget, int, str, int]] = [] @property def session_generation(self) -> int: @@ -1142,6 +1193,7 @@ class FakeClient(EvidenceClient): async def list_evidence_page( self, target: EvidenceTarget, *, limit: int, continuation: str, max_bytes: int ) -> EvidencePage: + self.lists.append((target, limit, continuation, max_bytes)) return EvidencePage((SETTINGS_POD,)) async def get_evidence_object( @@ -1219,8 +1271,11 @@ async def test_opening_reads_the_selected_object_and_shows_its_consumers() -> No assert [(c.pod.name, c.mode) for c in screen.report.consumers] == [ ("api-1", ConsumptionMode.ENV_FROM) ] - assert [(target.resource, name) for target, name in harness.client.gets] == [ - ("configmaps", "settings") + assert harness.client.gets == [ + (EvidenceTarget("", "v1", "configmaps", "team-a", metadata_only=True), "settings") + ] + assert harness.client.lists == [ + (EvidenceTarget("", "v1", "pods", "team-a"), LIST_PAGE_SIZE, "", MAX_BYTES) ] @@ -1302,6 +1357,22 @@ async def test_a_dialog_opened_during_the_reads_keeps_the_view_closed() -> None: assert harness.ui.screens == [] +async def test_leaving_the_view_or_the_selection_keeps_the_result_closed() -> None: + moved = Harness() + rescoped = Harness() + reselected = Harness() + + for harness in (moved, rescoped, reselected): + harness.controller.open_selected() + moved.view.kind = "pods" + rescoped.view.scope = "team-b" + reselected.view.selected = ("team-a", "other") + for harness in (moved, rescoped, reselected): + await harness.run() + + assert (moved.ui.screens, rescoped.ui.screens, reselected.ui.screens) == ([], [], []) + + async def test_the_worker_group_is_cancelled_on_request() -> None: harness = Harness() @@ -1354,6 +1425,8 @@ _NO_SELECTION = UnavailableReason(AvailabilityCode.NO_SELECTION, "Select a Confi #: Opens a consumer's Pod: (namespace, name, epoch, expected UID). PodNavigator = Callable[[str, str, int, str | None], Awaitable[None]] +#: The view, scope and (namespace, name) selection a run was started from. +_Origin = tuple[str, str, tuple[str | None, str | None]] def _now() -> datetime: @@ -1419,21 +1492,32 @@ class ConfigPropagationController: target = ConfigTarget(kind, namespace, name, self._view.selected_uid(namespace, name)) token = self._tracker.next_run() self._ui.run_worker( - self._explain(target, token, self._context.epoch()), + self._explain(target, token, self._context.epoch(), self._origin()), exclusive=True, group=CONFIG_PROPAGATION_GROUP, exit_on_error=False, ) - async def _explain(self, target: ConfigTarget, token: RunToken, epoch: int) -> None: + def _origin(self) -> _Origin: + """The view, scope and selected row a run explains.""" + return ( + self._view.current_kind(), + self._view.current_scope(), + self._view.selected_ns_name(notify=False), + ) + + async def _explain( + self, target: ConfigTarget, token: RunToken, epoch: int, origin: _Origin + ) -> None: client = self._client if client is None: return snapshot = observe_discovery(await client.discovery_snapshot()) report = await explain_propagation(client, snapshot, target, token=token, clock=self._clock) - if self._ui.screen_depth() > 1: # another dialog opened during the reads + # Another dialog opened, or the user moved on, during the reads. + if self._ui.screen_depth() > 1 or self._origin() != origin: return - screen = ConfigPropagationScreen(report, self._clock()) + screen = ConfigPropagationScreen(report, self._clock) self._tracker.apply_if_current( token, lambda: self._ui.push_screen(screen, lambda chosen: self._open_pod(chosen, epoch)), @@ -1537,12 +1621,16 @@ In `ContextSwitchCoordinator._teardown`, right after `await self._workspace().qu and pass `propagation=config_propagation.unavailable_reason` to `compose_command_reasons`, `propagation=config_propagation` to `CommandRouter`, and `evidence_client=kube` to `KorvidApp` in `_wire_and_run`. The controller shows its unavailable reason with `markup=False`, as every owner does. -- [ ] **Step 6: Run the UI suite and the size check** +- [ ] **Step 6: Report a failed run** + +`ui/worker_failures.py` maps each `exit_on_error=False` worker group to its failure label, `config-propagation` included, and `KorvidApp.on_worker_state_changed` looks the label up there instead of an `if`/`elif` chain. A workflow test makes discovery raise and expects "Configuration propagation failed" with the error. This also frees ten lines in `ui/app.py`. + +- [ ] **Step 7: Run the UI suite and the size check** Run: `uv run --frozen pytest -p no:tach -n 8 tests/ui/ tests/test_main_wiring.py -q` and `python scripts/check_source_size.py` -Expected: all pass; `ui/app.py` is at its 1,500-line limit, not over it. +Expected: all pass, and `python scripts/check_source_size.py` passes. -- [ ] **Step 7: Commit** +- [ ] **Step 8: Commit** ```bash git commit -m "ui: open the propagation view with :consumers" diff --git a/docs/tui.md b/docs/tui.md index 41faf4816..381baaab4 100644 --- a/docs/tui.md +++ b/docs/tui.md @@ -131,9 +131,10 @@ container in its namespace that uses it, grouped by how: The view reads the object's metadata and the namespace's Pods when it opens, never the object's data or a Pod's environment values. Each source gets a -line saying what was read, its state and how old it is. "No Pod references -it" appears only when every Pod could be listed; a denied or truncated list -says why the set of consumers is not established. Whether the object is +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. From 0abfb8f435b9436df55bef28a3fcbb4ac7fd80fc Mon Sep 17 00:00:00 2001 From: hellices Date: Fri, 30 Oct 2026 03:50:48 +0900 Subject: [PATCH 14/21] ui: stop calling an aged absence a deletion A 404 that named the object proves it was gone when it was read, not that it still is. Once the metadata read ages past its policy, the open view says the object did not exist when it was read and may have been created since, instead of that it no longer exists. The controller test now records the metadata GET's byte budget and asserts the default, as it already did for the Pod list's maximum. Part of #502. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/korvid/ui/widgets/config_propagation_screen.py | 8 ++++++-- tests/ui/test_config_propagation_controller.py | 12 ++++++++---- tests/ui/test_config_propagation_screen.py | 13 +++++++++++++ 3 files changed, 27 insertions(+), 6 deletions(-) diff --git a/src/korvid/ui/widgets/config_propagation_screen.py b/src/korvid/ui/widgets/config_propagation_screen.py index 7a4d9aec6..aa3078dce 100644 --- a/src/korvid/ui/widgets/config_propagation_screen.py +++ b/src/korvid/ui/widgets/config_propagation_screen.py @@ -139,9 +139,13 @@ def evidence_lines(report: PropagationReport, now: datetime) -> list[Text]: ] -def _object_notes(report: PropagationReport) -> list[Text]: +def _object_notes(report: PropagationReport, now: datetime) -> list[Text]: kind = report.target.kind if report.object_absent: + if not is_fresh(report.object, declarations(report.target)[0].policy, now): + return [ + Text(f"The {kind} did not exist when it was read; it may have been created since.") + ] tail = " The Pods below still reference it." if report.consumers else "" return [Text(f"The {kind} no longer exists.{tail}")] if report.recreated: @@ -175,7 +179,7 @@ def _completeness_note(report: PropagationReport, now: datetime) -> Text | None: def report_notes(report: PropagationReport, now: datetime) -> list[Text]: """What the evidence says about the object, and what it does not establish.""" - notes = _object_notes(report) + notes = _object_notes(report, now) if any(consumer.category is ContainerCategory.INIT for consumer in report.consumers): notes.append(Text(_INIT_NOTE)) completeness = _completeness_note(report, now) diff --git a/tests/ui/test_config_propagation_controller.py b/tests/ui/test_config_propagation_controller.py index ad47bfe42..d1d0ecbb2 100644 --- a/tests/ui/test_config_propagation_controller.py +++ b/tests/ui/test_config_propagation_controller.py @@ -8,7 +8,7 @@ from korvid.core.config_consumers import Consumer, ConsumptionMode, PodIdentity from korvid.core.config_propagation import ConfigTarget -from korvid.core.evidence_reads import LIST_PAGE_SIZE, MAX_BYTES +from korvid.core.evidence_reads import DEFAULT_MAX_BYTES, LIST_PAGE_SIZE, MAX_BYTES from korvid.k8s.discovery import ResourceMeta from korvid.k8s.discovery_snapshot import ( GroupVersionOutcome, @@ -107,7 +107,7 @@ def crossed(self, epoch: int) -> bool: class FakeClient(EvidenceClient): def __init__(self) -> None: self.generation = 3 - self.gets: list[tuple[EvidenceTarget, str]] = [] + self.gets: list[tuple[EvidenceTarget, str, int]] = [] self.lists: list[tuple[EvidenceTarget, int, str, int]] = [] @property @@ -137,7 +137,7 @@ async def list_evidence_page( async def get_evidence_object( self, target: EvidenceTarget, name: str, *, max_bytes: int ) -> dict[str, Any]: - self.gets.append((target, name)) + self.gets.append((target, name, max_bytes)) return {"metadata": {"name": name, "namespace": "team-a", "uid": f"uid-{name}"}} @@ -210,7 +210,11 @@ async def test_opening_reads_the_selected_object_and_shows_its_consumers() -> No ("api-1", ConsumptionMode.ENV_FROM) ] assert harness.client.gets == [ - (EvidenceTarget("", "v1", "configmaps", "team-a", metadata_only=True), "settings") + ( + EvidenceTarget("", "v1", "configmaps", "team-a", metadata_only=True), + "settings", + DEFAULT_MAX_BYTES, + ) ] assert harness.client.lists == [ (EvidenceTarget("", "v1", "pods", "team-a"), LIST_PAGE_SIZE, "", MAX_BYTES) diff --git a/tests/ui/test_config_propagation_screen.py b/tests/ui/test_config_propagation_screen.py index 818397a65..c8f6585d1 100644 --- a/tests/ui/test_config_propagation_screen.py +++ b/tests/ui/test_config_propagation_screen.py @@ -215,6 +215,19 @@ def test_a_gone_or_recreated_object_is_said() -> None: assert not any("was recreated" in n for n in _plain(report_notes(_report(VOLUME), NOW))) +def test_an_absence_that_has_aged_is_no_longer_called_a_deletion() -> None: + report = _report(ENV, object_basis=Basis.OBJECT_ABSENT) + + fresh = _plain(report_notes(report, NOW)) + aged = _plain(report_notes(report, NOW + timedelta(minutes=5))) + + assert any("no longer exists" in n for n in fresh) + assert not any("no longer exists" in n for n in aged) + assert any( + "did not exist when it was read" in n and "may have been created since" in n for n in aged + ) + + def test_an_init_container_row_explains_when_it_runs() -> None: with_init = _plain(report_notes(_report(SUBPATH), NOW)) without = _plain(report_notes(_report(ENV), NOW)) From c828a4979018788e7b690e2d62f5881259b2ed70 Mon Sep 17 00:00:00 2001 From: hellices Date: Fri, 30 Oct 2026 03:50:48 +0900 Subject: [PATCH 15/21] docs: record that keybinding the view waits for #510 Part of #502. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../2026-10-30-config-propagation-view.md | 34 +++++++++++++++---- 1 file changed, 28 insertions(+), 6 deletions(-) diff --git a/docs/superpowers/plans/2026-10-30-config-propagation-view.md b/docs/superpowers/plans/2026-10-30-config-propagation-view.md index 569e60edd..6d58065b4 100644 --- a/docs/superpowers/plans/2026-10-30-config-propagation-view.md +++ b/docs/superpowers/plans/2026-10-30-config-propagation-view.md @@ -27,6 +27,7 @@ - **The command is `:consumers`, not `:propagation`.** `:propagation` would make `prop` ambiguous with `:proposals` in the command bar's first-word completion, which a test pins. The palette entry is still **Explain configuration propagation**, and `propagation` is one of its search terms. - **The context switch cancels the group by name.** The design names a `cancel_config_propagation_workers()` hook; the coordinator already cancels `hint-events` by name with `cancel_worker_group`, so it does the same here and needs no new constructor argument. The controller's `cancel_workers()` remains for other callers. - **`EvidenceClient` lives in `k8s`.** The controller needs evidence reads, discovery and the session generation from one client; naming that in `k8s/evidence_requests.py` lets `KubeClient` declare it and the UI depend on the boundary instead of `KubeClient`. +- **The view is not yet bindable in the keybinding editor.** Design decision 5 keeps it off every default key and makes it bindable in `:keys`, but the editor only remaps `APP_BINDINGS` entries, each of which has a default key. Supporting an action that ships unbound changes the keymap contract, so it is its own change, #510. Until then the view is reached with `:consumers` and the palette. - **The observability builders move to `composition_support.py`** to make room in `__main__.py`, which was three lines under its limit. ## File structure @@ -636,6 +637,19 @@ def test_a_gone_or_recreated_object_is_said() -> None: assert not any("was recreated" in n for n in _plain(report_notes(_report(VOLUME), NOW))) +def test_an_absence_that_has_aged_is_no_longer_called_a_deletion() -> None: + report = _report(ENV, object_basis=Basis.OBJECT_ABSENT) + + fresh = _plain(report_notes(report, NOW)) + aged = _plain(report_notes(report, NOW + timedelta(minutes=5))) + + assert any("no longer exists" in n for n in fresh) + assert not any("no longer exists" in n for n in aged) + assert any( + "did not exist when it was read" in n and "may have been created since" in n for n in aged + ) + + def test_an_init_container_row_explains_when_it_runs() -> None: with_init = _plain(report_notes(_report(SUBPATH), NOW)) without = _plain(report_notes(_report(ENV), NOW)) @@ -872,9 +886,13 @@ def evidence_lines(report: PropagationReport, now: datetime) -> list[Text]: ] -def _object_notes(report: PropagationReport) -> list[Text]: +def _object_notes(report: PropagationReport, now: datetime) -> list[Text]: kind = report.target.kind if report.object_absent: + if not is_fresh(report.object, declarations(report.target)[0].policy, now): + return [ + Text(f"The {kind} did not exist when it was read; it may have been created since.") + ] tail = " The Pods below still reference it." if report.consumers else "" return [Text(f"The {kind} no longer exists.{tail}")] if report.recreated: @@ -908,7 +926,7 @@ def _completeness_note(report: PropagationReport, now: datetime) -> Text | None: def report_notes(report: PropagationReport, now: datetime) -> list[Text]: """What the evidence says about the object, and what it does not establish.""" - notes = _object_notes(report) + notes = _object_notes(report, now) if any(consumer.category is ContainerCategory.INIT for consumer in report.consumers): notes.append(Text(_INIT_NOTE)) completeness = _completeness_note(report, now) @@ -1070,7 +1088,7 @@ from typing import Any from korvid.core.config_consumers import Consumer, ConsumptionMode, PodIdentity from korvid.core.config_propagation import ConfigTarget -from korvid.core.evidence_reads import LIST_PAGE_SIZE, MAX_BYTES +from korvid.core.evidence_reads import DEFAULT_MAX_BYTES, LIST_PAGE_SIZE, MAX_BYTES from korvid.k8s.discovery import ResourceMeta from korvid.k8s.discovery_snapshot import ( GroupVersionOutcome, @@ -1169,7 +1187,7 @@ class FakeContext(ContextGuard): class FakeClient(EvidenceClient): def __init__(self) -> None: self.generation = 3 - self.gets: list[tuple[EvidenceTarget, str]] = [] + self.gets: list[tuple[EvidenceTarget, str, int]] = [] self.lists: list[tuple[EvidenceTarget, int, str, int]] = [] @property @@ -1199,7 +1217,7 @@ class FakeClient(EvidenceClient): async def get_evidence_object( self, target: EvidenceTarget, name: str, *, max_bytes: int ) -> dict[str, Any]: - self.gets.append((target, name)) + self.gets.append((target, name, max_bytes)) return {"metadata": {"name": name, "namespace": "team-a", "uid": f"uid-{name}"}} @@ -1272,7 +1290,11 @@ async def test_opening_reads_the_selected_object_and_shows_its_consumers() -> No ("api-1", ConsumptionMode.ENV_FROM) ] assert harness.client.gets == [ - (EvidenceTarget("", "v1", "configmaps", "team-a", metadata_only=True), "settings") + ( + EvidenceTarget("", "v1", "configmaps", "team-a", metadata_only=True), + "settings", + DEFAULT_MAX_BYTES, + ) ] assert harness.client.lists == [ (EvidenceTarget("", "v1", "pods", "team-a"), LIST_PAGE_SIZE, "", MAX_BYTES) From 932e32a8466e3191341609f794dfca2dd2770b0c Mon Sep 17 00:00:00 2001 From: hellices Date: Fri, 30 Oct 2026 04:07:42 +0900 Subject: [PATCH 16/21] docs: say what reaches korvid and what the view keeps The TUI guide said the view never reads a Pod's environment values, but Pods arrive whole, literal env[].value fields included; it now says that, and that the view keeps and shows only references. The controller docstring describes the dialog check as it works - a dialog open when the reads finish - as the relationship view does. The EvidenceClient test also pins that it keeps EvidenceReader's two reads, and the plan's expected test counts match the files. Part of #502. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../plans/2026-10-30-config-propagation-view.md | 13 +++++++------ docs/tui.md | 4 +++- src/korvid/ui/config_propagation_controller.py | 9 +++++---- tests/k8s/test_evidence_requests.py | 2 ++ 4 files changed, 17 insertions(+), 11 deletions(-) diff --git a/docs/superpowers/plans/2026-10-30-config-propagation-view.md b/docs/superpowers/plans/2026-10-30-config-propagation-view.md index 6d58065b4..97147f463 100644 --- a/docs/superpowers/plans/2026-10-30-config-propagation-view.md +++ b/docs/superpowers/plans/2026-10-30-config-propagation-view.md @@ -1030,7 +1030,7 @@ class ConfigPropagationScreen(ModalScreen[Consumer | None]): - [ ] **Step 4: Run the tests, and check the mutations** -Expected: `10 passed`. Each change must fail a test: show empty sections; treat any Pod list as conclusive; dismiss on a heading; always add the init note; always call the object recreated; render the title as markup. +Expected: `13 passed`. Each change must fail a test: show empty sections; treat any Pod list as conclusive; dismiss on a heading; always add the init note; always call the object recreated; render the title as markup. - [ ] **Step 5: Commit** @@ -1413,9 +1413,10 @@ Create `src/korvid/ui/config_propagation_controller.py` with: One exclusive worker reads the object's metadata and its namespace's Pods through `explain_propagation`, under a run token. The view opens only while that token is current: a context switch, new credentials or a newer run -drops the result, as `RunTracker` decides, and so does a dialog that opened -meanwhile. The context-switch coordinator cancels the worker group before -it swaps the client. +drops the result, as `RunTracker` decides. So does another dialog that is +open when the reads finish, or a change of view, scope or selection since +they started, as the relationship view does. The context-switch coordinator +cancels the worker group before it swaps the client. """ from __future__ import annotations @@ -1536,7 +1537,7 @@ class ConfigPropagationController: return snapshot = observe_discovery(await client.discovery_snapshot()) report = await explain_propagation(client, snapshot, target, token=token, clock=self._clock) - # Another dialog opened, or the user moved on, during the reads. + # Another dialog is open, or the user has moved on, as the reads finish. if self._ui.screen_depth() > 1 or self._origin() != origin: return screen = ConfigPropagationScreen(report, self._clock) @@ -1562,7 +1563,7 @@ class ConfigPropagationController: - [ ] **Step 4: Run the tests, and check the mutations** -Expected: `11 passed`. Each change must fail a test: accept a ConfigMap kind from another group; ignore an open dialog; apply a result without the run token; track a fixed session generation; navigate in the current epoch rather than the run's. +Expected: `12 passed`. Each change must fail a test: accept a ConfigMap kind from another group; ignore an open dialog; apply a result without the run token; track a fixed session generation; navigate in the current epoch rather than the run's. - [ ] **Step 5: Commit** diff --git a/docs/tui.md b/docs/tui.md index 381baaab4..afd67c48b 100644 --- a/docs/tui.md +++ b/docs/tui.md @@ -130,7 +130,9 @@ container in its namespace that uses it, grouped by how: | An image pull secret | Used only when an image is pulled | The view reads the object's metadata and the namespace's Pods when it opens, -never the object's data or a Pod's environment values. Each source gets a +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 diff --git a/src/korvid/ui/config_propagation_controller.py b/src/korvid/ui/config_propagation_controller.py index 6082f911b..fa30c5a46 100644 --- a/src/korvid/ui/config_propagation_controller.py +++ b/src/korvid/ui/config_propagation_controller.py @@ -3,9 +3,10 @@ One exclusive worker reads the object's metadata and its namespace's Pods through `explain_propagation`, under a run token. The view opens only while that token is current: a context switch, new credentials or a newer run -drops the result, as `RunTracker` decides, and so does a dialog that opened -meanwhile. The context-switch coordinator cancels the worker group before -it swaps the client. +drops the result, as `RunTracker` decides. So does another dialog that is +open when the reads finish, or a change of view, scope or selection since +they started, as the relationship view does. The context-switch coordinator +cancels the worker group before it swaps the client. """ from __future__ import annotations @@ -126,7 +127,7 @@ async def _explain( return snapshot = observe_discovery(await client.discovery_snapshot()) report = await explain_propagation(client, snapshot, target, token=token, clock=self._clock) - # Another dialog opened, or the user moved on, during the reads. + # Another dialog is open, or the user has moved on, as the reads finish. if self._ui.screen_depth() > 1 or self._origin() != origin: return screen = ConfigPropagationScreen(report, self._clock) diff --git a/tests/k8s/test_evidence_requests.py b/tests/k8s/test_evidence_requests.py index 3e4fb1eca..8926a43fa 100644 --- a/tests/k8s/test_evidence_requests.py +++ b/tests/k8s/test_evidence_requests.py @@ -173,4 +173,6 @@ async def test_an_object_failure_keeps_the_status_for_the_classifier() -> None: def test_the_kubernetes_client_is_an_evidence_client() -> None: """A diagnostic needs reads, discovery and the session generation from one client.""" assert issubclass(KubeClient, EvidenceClient) + assert issubclass(EvidenceClient, EvidenceReader) + assert {"list_evidence_page", "get_evidence_object"} <= EvidenceClient.__abstractmethods__ assert {"session_generation", "discovery_snapshot"} <= EvidenceClient.__abstractmethods__ From 5292316a499bd96aff2ad34fb1c38169b6f646bc Mon Sep 17 00:00:00 2001 From: hellices Date: Fri, 30 Oct 2026 04:27:35 +0900 Subject: [PATCH 17/21] ui: tell an ephemeral consumer that a restart will not help The environment and subPath headings suggest a rollout restart, but an ephemeral container cannot be restarted, and a rollout creates Pods without it. When a consumer is an ephemeral container, the view now says so and that a new ephemeral container is the way to read a change, as it already explains init containers. The TUI guide says so too. The support page no longer says the version-matrix scenario never reads the Pod's literal values: the Pod list reads them, and the scenario checks they do not reach the report. The plan's Task 5 paths are full. Part of #502. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/kubernetes-support.md | 5 +-- .../2026-10-30-config-propagation-view.md | 32 +++++++++++++++++-- docs/tui.md | 4 +++ .../ui/widgets/config_propagation_screen.py | 9 +++++- tests/ui/test_config_propagation_screen.py | 19 +++++++++++ 5 files changed, 63 insertions(+), 6 deletions(-) diff --git a/docs/kubernetes-support.md b/docs/kubernetes-support.md index 952cecf32..0e3fa7730 100644 --- a/docs/kubernetes-support.md +++ b/docs/kubernetes-support.md @@ -68,8 +68,9 @@ read-only scenarios against each: 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 or the Pod's literal values. + 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 diff --git a/docs/superpowers/plans/2026-10-30-config-propagation-view.md b/docs/superpowers/plans/2026-10-30-config-propagation-view.md index 97147f463..352911b43 100644 --- a/docs/superpowers/plans/2026-10-30-config-propagation-view.md +++ b/docs/superpowers/plans/2026-10-30-config-propagation-view.md @@ -650,6 +650,25 @@ def test_an_absence_that_has_aged_is_no_longer_called_a_deletion() -> None: ) +def test_an_ephemeral_container_row_explains_a_restart_will_not_help() -> None: + debugger = Consumer( + POD, + ConsumptionMode.ENV_FROM, + "spec.ephemeralContainers[0].envFrom[0].configMapRef", + False, + "debugger", + ContainerCategory.EPHEMERAL, + ) + + with_ephemeral = _plain(report_notes(_report(debugger), NOW)) + without = _plain(report_notes(_report(ENV), NOW)) + + (note,) = [n for n in with_ephemeral if n.startswith("An ephemeral container")] + assert "cannot be restarted" in note + assert "add a new ephemeral container" in note + assert not any(n.startswith("An ephemeral container") for n in without) + + def test_an_init_container_row_explains_when_it_runs() -> None: with_init = _plain(report_notes(_report(SUBPATH), NOW)) without = _plain(report_notes(_report(ENV), NOW)) @@ -810,6 +829,10 @@ _INIT_NOTE = ( "An init container runs to completion before the other containers start; once it " "has finished, a change reaches it only when the Pod restarts." ) +_EPHEMERAL_NOTE = ( + "An ephemeral container cannot be restarted, and a rollout restart creates Pods " + "without it: to read a change, add a new ephemeral container." +) @dataclass(frozen=True, slots=True) @@ -927,8 +950,11 @@ def _completeness_note(report: PropagationReport, now: datetime) -> Text | None: def report_notes(report: PropagationReport, now: datetime) -> list[Text]: """What the evidence says about the object, and what it does not establish.""" notes = _object_notes(report, now) - if any(consumer.category is ContainerCategory.INIT for consumer in report.consumers): + categories = {consumer.category for consumer in report.consumers} + if ContainerCategory.INIT in categories: notes.append(Text(_INIT_NOTE)) + if ContainerCategory.EPHEMERAL in categories: + notes.append(Text(_EPHEMERAL_NOTE)) completeness = _completeness_note(report, now) if completeness is not None: notes.append(completeness) @@ -1030,7 +1056,7 @@ class ConfigPropagationScreen(ModalScreen[Consumer | None]): - [ ] **Step 4: Run the tests, and check the mutations** -Expected: `13 passed`. Each change must fail a test: show empty sections; treat any Pod list as conclusive; dismiss on a heading; always add the init note; always call the object recreated; render the title as markup. +Expected: `14 passed`. Each change must fail a test: show empty sections; treat any Pod list as conclusive; dismiss on a heading; always add the init note; always call the object recreated; render the title as markup. - [ ] **Step 5: Commit** @@ -1575,7 +1601,7 @@ git commit -m "ui: run the propagation diagnostic for the selected object" ### Task 5: The command and the wiring **Files:** -- Modify: `src/korvid/ui/messages.py`, `ui/command.py`, `ui/command_router.py`, `ui/action_policy.py`, `ui/context_switch_coordinator.py`, `ui/app.py`, `ui/app_runtime.py`, `src/korvid/__main__.py` +- Modify: `src/korvid/ui/messages.py`, `src/korvid/ui/command.py`, `src/korvid/ui/command_router.py`, `src/korvid/ui/action_policy.py`, `src/korvid/ui/context_switch_coordinator.py`, `src/korvid/ui/app.py`, `src/korvid/ui/app_runtime.py`, `src/korvid/__main__.py` - Test: `tests/ui/test_command_router.py`, `tests/ui/test_action_policy.py`, `tests/ui/test_context_switch_coordinator.py`, `tests/test_main_wiring.py`, `tests/ui/test_config_propagation_workflow.py` - [ ] **Step 1: Write the failing tests** diff --git a/docs/tui.md b/docs/tui.md index afd67c48b..0a4daf8f5 100644 --- a/docs/tui.md +++ b/docs/tui.md @@ -129,6 +129,10 @@ container in its namespace that uses it, grouped by how: | A volume no container mounts | Nothing reads it | | An image pull secret | Used only when an image is pulled | +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. + 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 diff --git a/src/korvid/ui/widgets/config_propagation_screen.py b/src/korvid/ui/widgets/config_propagation_screen.py index aa3078dce..0432db48a 100644 --- a/src/korvid/ui/widgets/config_propagation_screen.py +++ b/src/korvid/ui/widgets/config_propagation_screen.py @@ -63,6 +63,10 @@ "An init container runs to completion before the other containers start; once it " "has finished, a change reaches it only when the Pod restarts." ) +_EPHEMERAL_NOTE = ( + "An ephemeral container cannot be restarted, and a rollout restart creates Pods " + "without it: to read a change, add a new ephemeral container." +) @dataclass(frozen=True, slots=True) @@ -180,8 +184,11 @@ def _completeness_note(report: PropagationReport, now: datetime) -> Text | None: def report_notes(report: PropagationReport, now: datetime) -> list[Text]: """What the evidence says about the object, and what it does not establish.""" notes = _object_notes(report, now) - if any(consumer.category is ContainerCategory.INIT for consumer in report.consumers): + categories = {consumer.category for consumer in report.consumers} + if ContainerCategory.INIT in categories: notes.append(Text(_INIT_NOTE)) + if ContainerCategory.EPHEMERAL in categories: + notes.append(Text(_EPHEMERAL_NOTE)) completeness = _completeness_note(report, now) if completeness is not None: notes.append(completeness) diff --git a/tests/ui/test_config_propagation_screen.py b/tests/ui/test_config_propagation_screen.py index c8f6585d1..89120cc11 100644 --- a/tests/ui/test_config_propagation_screen.py +++ b/tests/ui/test_config_propagation_screen.py @@ -228,6 +228,25 @@ def test_an_absence_that_has_aged_is_no_longer_called_a_deletion() -> None: ) +def test_an_ephemeral_container_row_explains_a_restart_will_not_help() -> None: + debugger = Consumer( + POD, + ConsumptionMode.ENV_FROM, + "spec.ephemeralContainers[0].envFrom[0].configMapRef", + False, + "debugger", + ContainerCategory.EPHEMERAL, + ) + + with_ephemeral = _plain(report_notes(_report(debugger), NOW)) + without = _plain(report_notes(_report(ENV), NOW)) + + (note,) = [n for n in with_ephemeral if n.startswith("An ephemeral container")] + assert "cannot be restarted" in note + assert "add a new ephemeral container" in note + assert not any(n.startswith("An ephemeral container") for n in without) + + def test_an_init_container_row_explains_when_it_runs() -> None: with_init = _plain(report_notes(_report(SUBPATH), NOW)) without = _plain(report_notes(_report(ENV), NOW)) From 36ee923299214380fde3963bd69fa2dc35469e1c Mon Sep 17 00:00:00 2001 From: hellices Date: Fri, 30 Oct 2026 05:35:18 +0900 Subject: [PATCH 18/21] ui: show the ephemeral note only where a new container is needed A running ephemeral container that mounts the object as a plain volume does see the kubelet's update, so the note now appears only for an ephemeral consumer through environment variables or a subPath mount. The controller test asserts the consumer's whole Pod identity and navigates with the consumer the report produced. Part of #502. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../2026-10-30-config-propagation-view.md | 40 ++++++++++++++----- docs/tui.md | 3 +- .../ui/widgets/config_propagation_screen.py | 15 +++++-- .../ui/test_config_propagation_controller.py | 12 +++--- tests/ui/test_config_propagation_screen.py | 13 +++++- 5 files changed, 60 insertions(+), 23 deletions(-) diff --git a/docs/superpowers/plans/2026-10-30-config-propagation-view.md b/docs/superpowers/plans/2026-10-30-config-propagation-view.md index 352911b43..07533dab3 100644 --- a/docs/superpowers/plans/2026-10-30-config-propagation-view.md +++ b/docs/superpowers/plans/2026-10-30-config-propagation-view.md @@ -660,8 +660,19 @@ def test_an_ephemeral_container_row_explains_a_restart_will_not_help() -> None: ContainerCategory.EPHEMERAL, ) + mounted = Consumer( + POD, + ConsumptionMode.VOLUME, + "spec.volumes[0].configMap", + False, + "debugger", + ContainerCategory.EPHEMERAL, + volume="config", + mount_path="/etc/app", + ) + with_ephemeral = _plain(report_notes(_report(debugger), NOW)) - without = _plain(report_notes(_report(ENV), NOW)) + without = _plain(report_notes(_report(ENV), NOW)) + _plain(report_notes(_report(mounted), NOW)) (note,) = [n for n in with_ephemeral if n.startswith("An ephemeral container")] assert "cannot be restarted" in note @@ -831,7 +842,12 @@ _INIT_NOTE = ( ) _EPHEMERAL_NOTE = ( "An ephemeral container cannot be restarted, and a rollout restart creates Pods " - "without it: to read a change, add a new ephemeral container." + "without it: to read a change to its environment or subPath mount, add a new " + "ephemeral container." +) +#: The modes an ephemeral container cannot pick a change up through. +_READ_AT_START = frozenset( + {ConsumptionMode.ENV_FROM, ConsumptionMode.ENV_KEY, ConsumptionMode.SUBPATH} ) @@ -950,10 +966,12 @@ def _completeness_note(report: PropagationReport, now: datetime) -> Text | None: def report_notes(report: PropagationReport, now: datetime) -> list[Text]: """What the evidence says about the object, and what it does not establish.""" notes = _object_notes(report, now) - categories = {consumer.category for consumer in report.consumers} - if ContainerCategory.INIT in categories: + if any(consumer.category is ContainerCategory.INIT for consumer in report.consumers): notes.append(Text(_INIT_NOTE)) - if ContainerCategory.EPHEMERAL in categories: + if any( + consumer.category is ContainerCategory.EPHEMERAL and consumer.mode in _READ_AT_START + for consumer in report.consumers + ): notes.append(Text(_EPHEMERAL_NOTE)) completeness = _completeness_note(report, now) if completeness is not None: @@ -1112,7 +1130,7 @@ from collections.abc import Awaitable, Mapping from datetime import UTC, datetime from typing import Any -from korvid.core.config_consumers import Consumer, ConsumptionMode, PodIdentity +from korvid.core.config_consumers import ConsumptionMode, PodIdentity from korvid.core.config_propagation import ConfigTarget from korvid.core.evidence_reads import DEFAULT_MAX_BYTES, LIST_PAGE_SIZE, MAX_BYTES from korvid.k8s.discovery import ResourceMeta @@ -1312,8 +1330,8 @@ async def test_opening_reads_the_selected_object_and_shows_its_consumers() -> No (screen,) = harness.ui.screens assert isinstance(screen, ConfigPropagationScreen) assert screen.report.target == ConfigTarget("ConfigMap", "team-a", "settings", "uid-settings") - assert [(c.pod.name, c.mode) for c in screen.report.consumers] == [ - ("api-1", ConsumptionMode.ENV_FROM) + assert [(c.pod, c.mode) for c in screen.report.consumers] == [ + (PodIdentity("team-a", "api-1", "uid-api-1"), ConsumptionMode.ENV_FROM) ] assert harness.client.gets == [ ( @@ -1381,9 +1399,9 @@ async def test_choosing_a_consumer_opens_its_pod_in_the_run_epoch() -> None: harness = Harness() harness.controller.open_selected() await harness.run() - consumer = Consumer( - PodIdentity("team-a", "api-1", "uid-api-1"), ConsumptionMode.ENV_FROM, "spec.x" - ) + (screen,) = harness.ui.screens + assert isinstance(screen, ConfigPropagationScreen) + (consumer,) = screen.report.consumers assert harness.ui.screen_callback is not None harness.ui.screen_callback(None) diff --git a/docs/tui.md b/docs/tui.md index 0a4daf8f5..ba4e703e8 100644 --- a/docs/tui.md +++ b/docs/tui.md @@ -131,7 +131,8 @@ container in its namespace that uses it, grouped by how: 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. +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` diff --git a/src/korvid/ui/widgets/config_propagation_screen.py b/src/korvid/ui/widgets/config_propagation_screen.py index 0432db48a..481280b61 100644 --- a/src/korvid/ui/widgets/config_propagation_screen.py +++ b/src/korvid/ui/widgets/config_propagation_screen.py @@ -65,7 +65,12 @@ ) _EPHEMERAL_NOTE = ( "An ephemeral container cannot be restarted, and a rollout restart creates Pods " - "without it: to read a change, add a new ephemeral container." + "without it: to read a change to its environment or subPath mount, add a new " + "ephemeral container." +) +#: The modes an ephemeral container cannot pick a change up through. +_READ_AT_START = frozenset( + {ConsumptionMode.ENV_FROM, ConsumptionMode.ENV_KEY, ConsumptionMode.SUBPATH} ) @@ -184,10 +189,12 @@ def _completeness_note(report: PropagationReport, now: datetime) -> Text | None: def report_notes(report: PropagationReport, now: datetime) -> list[Text]: """What the evidence says about the object, and what it does not establish.""" notes = _object_notes(report, now) - categories = {consumer.category for consumer in report.consumers} - if ContainerCategory.INIT in categories: + if any(consumer.category is ContainerCategory.INIT for consumer in report.consumers): notes.append(Text(_INIT_NOTE)) - if ContainerCategory.EPHEMERAL in categories: + if any( + consumer.category is ContainerCategory.EPHEMERAL and consumer.mode in _READ_AT_START + for consumer in report.consumers + ): notes.append(Text(_EPHEMERAL_NOTE)) completeness = _completeness_note(report, now) if completeness is not None: diff --git a/tests/ui/test_config_propagation_controller.py b/tests/ui/test_config_propagation_controller.py index d1d0ecbb2..52d17c566 100644 --- a/tests/ui/test_config_propagation_controller.py +++ b/tests/ui/test_config_propagation_controller.py @@ -6,7 +6,7 @@ from datetime import UTC, datetime from typing import Any -from korvid.core.config_consumers import Consumer, ConsumptionMode, PodIdentity +from korvid.core.config_consumers import ConsumptionMode, PodIdentity from korvid.core.config_propagation import ConfigTarget from korvid.core.evidence_reads import DEFAULT_MAX_BYTES, LIST_PAGE_SIZE, MAX_BYTES from korvid.k8s.discovery import ResourceMeta @@ -206,8 +206,8 @@ async def test_opening_reads_the_selected_object_and_shows_its_consumers() -> No (screen,) = harness.ui.screens assert isinstance(screen, ConfigPropagationScreen) assert screen.report.target == ConfigTarget("ConfigMap", "team-a", "settings", "uid-settings") - assert [(c.pod.name, c.mode) for c in screen.report.consumers] == [ - ("api-1", ConsumptionMode.ENV_FROM) + assert [(c.pod, c.mode) for c in screen.report.consumers] == [ + (PodIdentity("team-a", "api-1", "uid-api-1"), ConsumptionMode.ENV_FROM) ] assert harness.client.gets == [ ( @@ -275,9 +275,9 @@ async def test_choosing_a_consumer_opens_its_pod_in_the_run_epoch() -> None: harness = Harness() harness.controller.open_selected() await harness.run() - consumer = Consumer( - PodIdentity("team-a", "api-1", "uid-api-1"), ConsumptionMode.ENV_FROM, "spec.x" - ) + (screen,) = harness.ui.screens + assert isinstance(screen, ConfigPropagationScreen) + (consumer,) = screen.report.consumers assert harness.ui.screen_callback is not None harness.ui.screen_callback(None) diff --git a/tests/ui/test_config_propagation_screen.py b/tests/ui/test_config_propagation_screen.py index 89120cc11..b552ed096 100644 --- a/tests/ui/test_config_propagation_screen.py +++ b/tests/ui/test_config_propagation_screen.py @@ -238,8 +238,19 @@ def test_an_ephemeral_container_row_explains_a_restart_will_not_help() -> None: ContainerCategory.EPHEMERAL, ) + mounted = Consumer( + POD, + ConsumptionMode.VOLUME, + "spec.volumes[0].configMap", + False, + "debugger", + ContainerCategory.EPHEMERAL, + volume="config", + mount_path="/etc/app", + ) + with_ephemeral = _plain(report_notes(_report(debugger), NOW)) - without = _plain(report_notes(_report(ENV), NOW)) + without = _plain(report_notes(_report(ENV), NOW)) + _plain(report_notes(_report(mounted), NOW)) (note,) = [n for n in with_ephemeral if n.startswith("An ephemeral container")] assert "cannot be restarted" in note From 86ca83e0bd3960116fbabb057c9d5f4a5528609c Mon Sep 17 00:00:00 2001 From: hellices Date: Fri, 30 Oct 2026 06:17:19 +0900 Subject: [PATCH 19/21] tests: pin each age unit at its exact boundary Part of #502. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../superpowers/plans/2026-10-30-config-propagation-view.md | 6 +++++- tests/ui/test_evidence_text.py | 4 ++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/docs/superpowers/plans/2026-10-30-config-propagation-view.md b/docs/superpowers/plans/2026-10-30-config-propagation-view.md index 07533dab3..babfa8fe0 100644 --- a/docs/superpowers/plans/2026-10-30-config-propagation-view.md +++ b/docs/superpowers/plans/2026-10-30-config-propagation-view.md @@ -229,6 +229,10 @@ def test_an_observation_never_made_is_not_called_stale() -> None: ("seconds", "text"), [ (0.4, "observed just now"), + (1, "observed 1s ago"), + (60, "observed 1m ago"), + (3600, "observed 1h ago"), + (86400, "observed 1d ago"), (59, "observed 59s ago"), (61, "observed 1m ago"), (2 * 3600 + 5, "observed 2h ago"), @@ -397,7 +401,7 @@ def not_established(what: str) -> Text: - [ ] **Step 4: Run the tests, and check the mutations** -Expected: `22 passed`. Each change must fail a test: mark stale without an observation; never mark stale; parse the line as markup; drop the group from a grouped version; use `>` for unit boundaries. +Expected: `26 passed`. Each change must fail a test: mark stale without an observation; never mark stale; parse the line as markup; drop the group from a grouped version; use `>` for unit boundaries. - [ ] **Step 5: Commit** diff --git a/tests/ui/test_evidence_text.py b/tests/ui/test_evidence_text.py index efde5b9ad..e5ebc7911 100644 --- a/tests/ui/test_evidence_text.py +++ b/tests/ui/test_evidence_text.py @@ -131,6 +131,10 @@ def test_an_observation_never_made_is_not_called_stale() -> None: ("seconds", "text"), [ (0.4, "observed just now"), + (1, "observed 1s ago"), + (60, "observed 1m ago"), + (3600, "observed 1h ago"), + (86400, "observed 1d ago"), (59, "observed 59s ago"), (61, "observed 1m ago"), (2 * 3600 + 5, "observed 2h ago"), From 67cc1ec47297ad3aed0073ef5a93e5ea64e46018 Mon Sep 17 00:00:00 2001 From: hellices Date: Fri, 30 Oct 2026 06:36:25 +0900 Subject: [PATCH 20/21] docs: fill in the plan's workflow test block The step listed the workflow test inside a bullet, which the plan sync does not match, so its code block stayed empty. It now holds the test file, including the failed-run case Step 6 refers to. Part of #502. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../2026-10-30-config-propagation-view.md | 189 +++++++++++++++++- 1 file changed, 188 insertions(+), 1 deletion(-) diff --git a/docs/superpowers/plans/2026-10-30-config-propagation-view.md b/docs/superpowers/plans/2026-10-30-config-propagation-view.md index babfa8fe0..af1d739b7 100644 --- a/docs/superpowers/plans/2026-10-30-config-propagation-view.md +++ b/docs/superpowers/plans/2026-10-30-config-propagation-view.md @@ -1632,9 +1632,196 @@ git commit -m "ui: run the propagation diagnostic for the selected object" - `test_action_policy.py`: `compose_command_reasons(..., propagation=...)` answers `consumers` with the owner's reason, read live. - `test_context_switch_coordinator.py`: `_SUCCESSFUL_ORDER` has `f"workers-cancelled:{CONFIG_PROPAGATION_GROUP}"` right after `"workspace-quiesce"`. - `test_main_wiring.py`: `_wire_and_run` passes `evidence_client=kube` to `KorvidApp`. -- Create `tests/ui/test_config_propagation_workflow.py` with: + +Create `tests/ui/test_config_propagation_workflow.py` with: ```python +"""`:consumers` end to end: a ConfigMap row, its consumers, and their Pod (#410).""" + +from __future__ import annotations + +import asyncio +from collections.abc import AsyncIterator +from datetime import UTC, datetime +from typing import Any + +from korvid.core.config import KorvidConfig +from korvid.core.store import ResourceStore, Summary +from korvid.core.watch import WatchManager +from korvid.k8s.discovery import PODS_META, ResourceMeta, build_alias_map +from korvid.k8s.discovery_snapshot import ( + GroupVersionOutcome, + RawDiscovery, + RootOutcome, + ServedResource, +) +from korvid.k8s.evidence_requests import EvidenceClient, EvidencePage, EvidenceTarget +from korvid.k8s.models import GenericSummary, PodSummary +from korvid.ui.widgets.config_propagation_screen import ConfigPropagationScreen +from tests.app_factory import build_test_app +from tests.ui.waits import screen_ready, until + +CONFIG_MAPS = ResourceMeta("ConfigMap", "configmaps", "", "v1", True) +SETTINGS_POD = { + "metadata": {"name": "api-1", "namespace": "default", "uid": "uid-api-1"}, + "spec": { + "containers": [ + { + "name": "app", + "envFrom": [{"configMapRef": {"name": "settings"}}], + "env": [{"name": "TOKEN", "value": "s3cr3t-literal"}], + } + ] + }, +} + + +class Client(EvidenceClient): + """Serves configmaps, secrets and pods, and one Pod that reads `settings`.""" + + def __init__(self) -> None: + self.reads: list[tuple[str, EvidenceTarget, str]] = [] + + @property + def session_generation(self) -> int: + return 1 + + async def discovery_snapshot(self) -> RawDiscovery: + served = tuple( + ServedResource(name, frozenset({"get", "list"})) + for name in ("configmaps", "secrets", "pods") + ) + return RawDiscovery( + 1, + datetime.now(UTC), + 0.0, + RootOutcome("/api", (("", "v1"),)), + RootOutcome("/apis", ()), + (GroupVersionOutcome("", "v1", served),), + ) + + async def list_evidence_page( + self, target: EvidenceTarget, *, limit: int, continuation: str, max_bytes: int + ) -> EvidencePage: + self.reads.append(("list", target, continuation)) + return EvidencePage((SETTINGS_POD,) if target.namespace == "default" else ()) + + async def get_evidence_object( + self, target: EvidenceTarget, name: str, *, max_bytes: int + ) -> dict[str, Any]: + self.reads.append(("get", target, name)) + return {"metadata": {"name": name, "namespace": target.namespace, "uid": "uid-settings"}} + + +async def _source(kind: str, scope: str) -> AsyncIterator[tuple[str, Summary]]: + if kind == "configmaps": + yield ("ADDED", GenericSummary("settings", "default", "ConfigMap", "", uid="uid-settings")) + if kind == "pods": + pod = PodSummary("api-1", "default", "Running", "1/1", 0, None, uid="uid-api-1") + yield ("ADDED", pod) + await asyncio.Event().wait() + + +async def _command(pilot: Any, text: str) -> None: + await pilot.press("colon", *text, "enter") + + +async def test_consumers_of_a_config_map_lead_to_their_pod() -> None: + client = Client() + store = ResourceStore() + app = build_test_app( + config=KorvidConfig(namespace="default"), + store=store, + watch_manager=WatchManager(store, _source), + aliases=build_alias_map([PODS_META, CONFIG_MAPS]), + agent_available=False, + evidence_client=client, + ) + async with app.run_test() as pilot: + await _command(pilot, "configmaps") + await until( + pilot, + lambda: app._view.selected_ns_name(notify=False) == ("default", "settings"), + label="ConfigMap selected", + ) + + await _command(pilot, "consumers") + await until(pilot, lambda: screen_ready(app, ConfigPropagationScreen), label="view open") + screen = app.screen + assert isinstance(screen, ConfigPropagationScreen) + assert [c.pod.name for c in screen.report.consumers] == ["api-1"] + assert "s3cr3t-literal" not in repr(screen.report) + assert sorted(client.reads, key=lambda read: read[0]) == [ + ( + "get", + EvidenceTarget("", "v1", "configmaps", "default", metadata_only=True), + "settings", + ), + ("list", EvidenceTarget("", "v1", "pods", "default"), ""), + ] + + await pilot.press("enter") + await until(pilot, lambda: app.current_kind == "pods", label="Pod view") + await until( + pilot, + lambda: app._view.selected_ns_name(notify=False) == ("default", "api-1"), + label="consumer Pod selected", + ) + + +async def test_consumers_on_another_view_says_what_to_open() -> None: + store = ResourceStore() + app = build_test_app( + config=KorvidConfig(namespace="default"), + store=store, + watch_manager=WatchManager(store, _source), + aliases=build_alias_map([PODS_META, CONFIG_MAPS]), + agent_available=False, + evidence_client=Client(), + ) + async with app.run_test() as pilot: + await _command(pilot, "consumers") + await until( + pilot, + lambda: any("Open ConfigMaps or Secrets" in n.message for n in app._notifications), + label="reason shown", + ) + assert not isinstance(app.screen, ConfigPropagationScreen) + + +class BrokenClient(Client): + async def discovery_snapshot(self) -> RawDiscovery: + raise RuntimeError("discovery exploded") + + +async def test_a_failed_run_is_reported_instead_of_vanishing() -> None: + store = ResourceStore() + app = build_test_app( + config=KorvidConfig(namespace="default"), + store=store, + watch_manager=WatchManager(store, _source), + aliases=build_alias_map([PODS_META, CONFIG_MAPS]), + agent_available=False, + evidence_client=BrokenClient(), + ) + async with app.run_test() as pilot: + await _command(pilot, "configmaps") + await until( + pilot, + lambda: app._view.selected_ns_name(notify=False) == ("default", "settings"), + label="ConfigMap selected", + ) + await _command(pilot, "consumers") + await until( + pilot, + lambda: any( + "Configuration propagation failed" in n.message + and "discovery exploded" in n.message + for n in app._notifications + ), + label="failure reported", + ) + assert app.is_running ``` - [ ] **Step 2: Add the operation and the command** From 97bf42e1243f5ed3d00012907e6efd284d42f96f Mon Sep 17 00:00:00 2001 From: hellices Date: Fri, 30 Oct 2026 06:52:45 +0900 Subject: [PATCH 21/21] tests: KubeClient must be a concrete EvidenceClient Part of #502. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- tests/k8s/test_evidence_requests.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/k8s/test_evidence_requests.py b/tests/k8s/test_evidence_requests.py index 8926a43fa..483bbeb55 100644 --- a/tests/k8s/test_evidence_requests.py +++ b/tests/k8s/test_evidence_requests.py @@ -173,6 +173,7 @@ async def test_an_object_failure_keeps_the_status_for_the_classifier() -> None: def test_the_kubernetes_client_is_an_evidence_client() -> None: """A diagnostic needs reads, discovery and the session generation from one client.""" assert issubclass(KubeClient, EvidenceClient) + assert not KubeClient.__abstractmethods__ assert issubclass(EvidenceClient, EvidenceReader) assert {"list_evidence_page", "get_evidence_object"} <= EvidenceClient.__abstractmethods__ assert {"session_generation", "discovery_snapshot"} <= EvidenceClient.__abstractmethods__