Skip to content

fix(operator): distinguish SecretMissing from KeyMissing in TLS Secret resolution - #59

Merged
nerdalert merged 7 commits into
praxis-proxy:mainfrom
jordigilh:fix/grid-58-secret-key-missing-vs-secret-missing
Aug 16, 2026
Merged

fix(operator): distinguish SecretMissing from KeyMissing in TLS Secret resolution#59
nerdalert merged 7 commits into
praxis-proxy:mainfrom
jordigilh:fix/grid-58-secret-key-missing-vs-secret-missing

Conversation

@jordigilh

Copy link
Copy Markdown
Contributor

What

Fixes #58.

secret::read_secret_bytes collapsed two distinct cases into Ok(None): the referenced Secret not existing at all, and the Secret existing but lacking the requested key. endpoint_tls::read_secret_bytes_for_tls (shared by InferenceProvider's metrics/health-check TLS resolution, and verify_tls_accessible's reconcile-time check) could therefore only ever report KeyMissing when a key was present with an empty value — never when it was absent entirely — contradicting TlsFailureReason::KeyMissing's own doc comment and misleading operators diagnosing a live misconfiguration ("Secret doesn't exist" vs. "Secret exists, wrong/missing key name").

How

  • Introduced SecretKeyLookup (Found(Vec<u8>) / SecretMissing / KeyMissing) and changed read_secret_bytes to return it directly instead of collapsing to Option<Vec<u8>>.
  • read_secret_bytes_for_tls now matches on the three-way result instead of guessing.
  • Removed the near-duplicate of this same distinction that verify_tls_accessible's private TlsSecretCheck/read_tls_secret_for_verify had already implemented independently against its own kube::Api call — it now delegates to the same (fixed) read_secret_bytes, so there's a single source of truth instead of two copies that could drift.
  • grid_site.rs's gateway-probe path doesn't need the distinction (it maps everything to TrustMaterialMissing regardless), so it uses SecretKeyLookup::into_bytes() to keep its existing Option<Vec<u8>>-based control flow unchanged.

Testing

Added unit tests directly against a mocked kube::Client (tower::service_fn, matching the existing pattern from mcp_probe.rs):

  • secret.rs: read_secret_bytes — key found, secret absent, secret with no data section, key absent from an existing secret's data (the bug), key present but empty.
  • endpoint_tls.rs: resolve_tls_config and verify_tls_accessible — secret absent → SecretMissing, key absent from an existing secret → KeyMissing (the regression this PR fixes), no TLS configured → None.

cargo test -p operator --lib: 979/979 passing. make lint (workspace clippy -D warnings, nightly cargo fmt --check, cargo machete): clean.

Scope note

This is independent of the still-open #56 (AgentToolProvider) — #56 added a test in mcp_probe.rs (read_tls_material_key_absent_from_data_currently_misreported_as_secret_missing) that intentionally locked in this bug pending this fix, referencing this issue. I'll update that test to assert the corrected behavior when #56 rebases onto main post-merge, per the plan already noted in that test's doc comment.

…t resolution

`secret::read_secret_bytes` collapsed two distinct cases into `Ok(None)`:
the referenced Secret not existing at all, and the Secret existing but
lacking the requested key. `endpoint_tls::read_secret_bytes_for_tls`
(shared by InferenceProvider's metrics/health-check TLS resolution and
the reconcile-time verify_tls_accessible check) could therefore only
ever report KeyMissing when a key was present with an empty value,
never when it was absent entirely -- contradicting its own documented
behavior and misleading operators diagnosing a live misconfiguration
("Secret doesn't exist" vs. "Secret exists, wrong/missing key name").

Introduce SecretKeyLookup, a three-way Found/SecretMissing/KeyMissing
result, and have read_secret_bytes return it directly instead of
collapsing to Option<Vec<u8>>. This also removes a near-duplicate of
the same distinction that verify_tls_accessible's private
TlsSecretCheck/read_tls_secret_for_verify had already implemented
independently against its own kube::Api call -- it now delegates to
the same fixed read_secret_bytes.

grid_site.rs's gateway-probe path doesn't need the distinction, so it
uses SecretKeyLookup::into_bytes() to keep its existing
Option<Vec<u8>>-based control flow unchanged.

Adds direct unit tests for read_secret_bytes (secret.rs) and for
resolve_tls_config/verify_tls_accessible (endpoint_tls.rs) against a
mocked kube::Client, covering: key found, secret absent, secret with no
data section, key absent from an existing secret's data (the bug), and
key present but empty.

Fixes praxis-proxy#58

Signed-off-by: Jordi Gil <jgil@redhat.com>
…issing

Adds the integration and E2E tiers that were missing for the
SecretMissing-vs-KeyMissing status.reason distinction fixed in this PR:

- Integration: resolve_phase_and_sites (the function reconcile() calls) is
  now exercised end-to-end against a mocked kube::Client, proving the
  distinction survives through the actual (phase, status.reason) pair
  written to the CR, not just the lower-level resolve_tls_config/
  read_secret_bytes helpers.
- E2E: a new InferenceProvider fixture with a CA Secret that exists but is
  missing the expected key runs against a live kind cluster and polls
  status.reason for "HealthCheckTlsKeyMissing" (verified locally: PASS).

This gap pre-existed for all four TlsFailureReason variants, not just
this fix; the E2E addition here only covers the specific regression this
PR guards against, since it needs no live probe endpoint (TLS resolution
fails before any health probe is attempted).

Signed-off-by: Jordi Gil <jgil@redhat.com>
Follow-up to the previous commit: the cleanup_includes_all_owned_providers
documentation test enforces that every InferenceProvider fixture name is
registered for idempotent cleanup between runs. Missed adding the new
TEST_PROVIDER_TLS_KEY_MISSING constant when it was introduced.

Signed-off-by: Jordi Gil <jgil@redhat.com>
@jordigilh

Copy link
Copy Markdown
Contributor Author

Pushed a follow-up to close the pyramid-invariant gap flagged in review discussion: unit tests alone weren't enough to claim behavioral assurance for the SecretMissing-vs-KeyMissing fix, since nothing exercised it through reconcile()/resolve_phase_and_sites() or against a live cluster.

Added:

  • Integration tier (operator/src/controller/inference_provider.rs): two new tests drive resolve_phase_and_sites — the function reconcile() actually calls — through a mocked kube::Client, asserting the real (ProviderPhase::Degraded, status.reason) pair for both the KeyMissing and SecretMissing cases.
  • E2E tier (xtask/src/env/operator.rs + mod.rs): a new InferenceProvider fixture (op-e2e-tls-key-missing) with a CA Secret that exists but lacks the expected key, wired into verify-operator-reconcile. Verified locally against a live kind cluster:
    [OK] op-e2e-tls-key-missing phase=Degraded reason="HealthCheckTlsKeyMissing"
    

Full test suite (981 tests), clippy, fmt, and machete all clean.

Side finding (not in scope here): while validating the E2E fixture I hit a pre-existing, deterministic scoring-order failure in verify-operator-reconcile (api_provider candidate ranking ahead of the local one) — reproduced identically with this PR's changes reverted, so it predates and is unrelated to this fix. Filed as #60 rather than fixing it here.

@praxis-bot praxis-bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR Review

Summary: Fixes grid#58 by introducing SecretKeyLookup to distinguish Secret-not-found from key-not-found in TLS Secret resolution, consolidating the duplicated TlsSecretCheck into the shared read_secret_bytes path, and adding thorough regression tests at unit, integration, and e2e tiers.

Overall assessment: Well-executed bug fix. The three-way enum is the right abstraction, the into_bytes() escape hatch keeps the grid_site path clean, and the deduplication of TlsSecretCheck vs SecretKeyLookup is a meaningful consolidation. Test coverage is excellent -- the bug case is exercised at every layer (direct unit, mocked kube client, mocked reconcile, and live e2e). Two medium-severity convention items below.

Severity Count
Critical 0
Large 0
Medium 2

Comment thread operator/src/resources/endpoint_tls.rs Outdated
Comment thread operator/src/resources/secret.rs Outdated
`[`secret::read_secret_bytes`]` referenced an out-of-scope `secret`
path at the doc-comment's location, failing the rustdoc CI check
(-D rustdoc::broken-intra-doc-links). Use the same fully-qualified
link style already used one line below for SecretKeyLookup.

Signed-off-by: Jordi Gil <jgil@redhat.com>
…ents

Addresses grid#59 review feedback (praxis-bot):

- mock_kube_client_with_secrets was duplicated verbatim (~40 lines)
  between secret.rs::tests and endpoint_tls.rs::tests, with
  secret_with_key also duplicated in spirit. Extracted both into a new
  resources::test_doubles module (cfg(test)-gated at the mod
  declaration) so the two copies cannot drift independently.
- Removed doc comments on
  read_secret_bytes_key_absent_from_existing_secret_returns_key_missing
  (secret.rs) and
  resolve_tls_config_ca_key_absent_from_existing_secret_yields_key_missing
  (endpoint_tls.rs) per the project convention (CLAUDE.md, Test
  Organization: function name is the documentation) -- both were
  redundant with the grid#58 regression context already carried by
  each test's assertion message.

Signed-off-by: Jordi Gil <jgil@redhat.com>
nerdalert pushed a commit that referenced this pull request Aug 14, 2026
…eads/main (#62)

* fix(ci): resolve triggered-integration-test base SHA live from refs/heads/main

The "Verify PR merge checkout" step in triggered-integration-test.yaml
compared the PR merge ref's base parent against `base_sha`, which was
read from `gh api pulls/<n>` -> `.base.sha`. That REST field is only
refreshed by GitHub on PR synchronize events (e.g. a push to the head
branch) and can lag main's actual tip by hours once a PR sits idle
while main advances. Meanwhile `refs/pull/<n>/merge` is kept
continuously current against main, so the two values can disagree
even with no race at all, failing the check with
"Checked-out merge does not combine the expected base and PR head"
(observed on run 31811278059 testing PR #59: cached base_sha pointed
at a main commit that was already superseded by 282a337 seven hours
earlier).

Resolve `base_sha` live via `git ls-remote refs/heads/main` in the
same "Resolve source" step that resolves the merge ref, instead of
trusting the cached PR resource field, so both values are re-derived
consistently with each other.

Signed-off-by: Jordi Gil <jgil@redhat.com>

* fix(ci): resolve merge and base SHAs from one ls-remote call

Addresses grid#62 review feedback (praxis-bot): the merge-ref and
refs/heads/main resolutions were two sequential `git ls-remote` calls,
leaving a window for main to advance between them -- the same class of
staleness bug this PR fixes, just with a much narrower race window.
Combine both into a single `git ls-remote` invocation so both SHAs are
resolved from the same server-side view, making them atomically
consistent with each other instead of merely "re-derived the same way."

Signed-off-by: Jordi Gil <jgil@redhat.com>

---------

Signed-off-by: Jordi Gil <jgil@redhat.com>
@nerdalert
nerdalert merged commit 878b85b into praxis-proxy:main Aug 16, 2026
16 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

bug: read_secret_bytes_for_tls misreports 'key absent from Secret.data' as SecretMissing, not KeyMissing

3 participants