Skip to content

Commit d2e3b50

Browse files
authored
Merge pull request #3 from schubergphilis/feature/surface-direnv-credential-errors
feat: surface direnv stderr when .envrc credential lookup fails
2 parents f893fd9 + 2d824c9 commit d2e3b50

3 files changed

Lines changed: 51 additions & 31 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
[![Changelog](https://img.shields.io/badge/changelog-Keep%20a%20Changelog%201.1.0-orange)](https://keepachangelog.com/en/1.1.0/)
1818
[![Documentation: Diátaxis](https://img.shields.io/badge/docs-Di%C3%A1taxis-009485?logo=readthedocs&logoColor=white)](https://diataxis.fr/)
1919
[![Build](https://img.shields.io/badge/build-unknown-lightgrey)](https://github.com/features/actions)
20-
[![Coverage](https://img.shields.io/badge/coverage-60%25-orange)](https://coverage.readthedocs.io/)
20+
[![Coverage](https://img.shields.io/badge/coverage-61%25-orange)](https://coverage.readthedocs.io/)
2121
[![pyscn quality](https://img.shields.io/badge/pyscn-not%20rated-lightgrey)](https://pyscn.ludo-tech.org)
2222

2323
CLI to set overrides idempotently for multiple SLO's

src/datadog_slo_overrides_cli/datadog_slo_overrides_cli.py

Lines changed: 32 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -528,55 +528,66 @@ def _run_direnv_export(directory: Path) -> subprocess.CompletedProcess[str] | No
528528
)
529529

530530

531-
def load_direnv_env(directory: Path) -> dict[str, str]:
532-
"""Return the environment variables an optional ``.envrc`` produces via direnv.
531+
def load_direnv_env(directory: Path) -> tuple[dict[str, str], str]:
532+
"""Return the variables an optional ``.envrc`` produces via direnv, plus direnv's stderr.
533533
534-
Returns ``{}`` silently when direnv isn't installed or there's no ``.envrc``.
535-
When direnv refuses an unapproved ``.envrc``, prints an actionable hint and
536-
returns ``{}`` rather than crashing.
534+
Returns ``({}, '')`` silently when direnv isn't installed or there's no
535+
``.envrc``. When direnv refuses an unapproved ``.envrc``, prints an actionable
536+
hint and returns ``({}, <stderr>)`` rather than crashing. The stderr is
537+
returned so callers can surface *why* the ``.envrc`` produced no usable values
538+
(e.g. a Vault command inside it failing), since direnv runs the file itself.
537539
538540
Args:
539541
directory: Directory whose ``.envrc`` should be evaluated.
540542
541543
Returns:
542-
A mapping of exported variable names to string values.
544+
A ``(exported vars, direnv stderr)`` pair.
543545
"""
544546
result = _run_direnv_export(directory)
545547
if result is None:
546-
return {}
548+
return {}, ''
547549
if result.returncode != 0 or _envrc_is_blocked(result.stderr):
548550
typer.echo(
549551
f'direnv could not load {directory}/.envrc (not approved?). Run: direnv allow {directory}',
550552
err=True,
551553
)
552-
return {}
554+
return {}, result.stderr
553555
stdout = result.stdout.strip()
554556
if not stdout:
555-
return {}
557+
return {}, result.stderr
556558
try:
557559
data = json.loads(stdout)
558560
except json.JSONDecodeError:
559-
return {}
560-
return {key: value for key, value in data.items() if isinstance(value, str)}
561+
return {}, result.stderr
562+
return {key: value for key, value in data.items() if isinstance(value, str)}, result.stderr
561563

562564

563-
def _warn_if_envrc_lacks_keys(directory: Path, direnv_env: dict[str, str], missing: list[str]) -> None:
564-
"""Warn when an evaluated ``.envrc`` didn't export the credential keys still needed.
565+
def _warn_if_envrc_lacks_keys(directory: Path, direnv_env: dict[str, str], missing: list[str], stderr: str) -> None:
566+
"""Warn when an evaluated ``.envrc`` didn't provide the credential keys still needed.
565567
566568
``direnv_env`` is non-empty only when direnv actually evaluated an approved
567569
``.envrc`` (it always includes direnv's own bookkeeping vars), which lets us
568-
tell "loaded but missing the keys" apart from "blocked" or "no .envrc".
570+
tell "loaded but missing the keys" apart from "blocked" or "no .envrc". When
571+
the file ran but the keys are unset/empty, direnv's stderr usually explains
572+
why (e.g. a failed Vault lookup), so it is echoed back as the reason.
569573
570574
Args:
571575
directory: Directory whose ``.envrc`` was evaluated.
572576
direnv_env: Variables direnv exported (empty if it didn't run/was blocked).
573577
missing: Required credential variables still unset after the merge.
578+
stderr: direnv's stderr from evaluating the ``.envrc``.
574579
"""
575-
if direnv_env and missing:
576-
typer.echo(
577-
f'{directory}/.envrc was loaded via direnv but does not export: {", ".join(missing)}',
578-
err=True,
579-
)
580+
if not (direnv_env and missing):
581+
return
582+
typer.echo(
583+
f'{directory}/.envrc was loaded via direnv but did not provide: {", ".join(missing)}',
584+
err=True,
585+
)
586+
diagnostic = stderr.strip()
587+
if diagnostic:
588+
typer.echo('direnv reported:', err=True)
589+
for line in diagnostic.splitlines():
590+
typer.echo(f' {line}', err=True)
580591

581592

582593
def resolve_credentials(api_key: str | None, app_key: str | None, directory: Path) -> tuple[str | None, str | None]:
@@ -599,11 +610,11 @@ def resolve_credentials(api_key: str | None, app_key: str | None, directory: Pat
599610
"""
600611
if api_key and app_key:
601612
return api_key, app_key
602-
direnv_env = load_direnv_env(directory)
613+
direnv_env, stderr = load_direnv_env(directory)
603614
api_key = api_key or direnv_env.get(API_KEY_ENV)
604615
app_key = app_key or direnv_env.get(APP_KEY_ENV)
605616
missing = [name for name, value in ((API_KEY_ENV, api_key), (APP_KEY_ENV, app_key)) if not value]
606-
_warn_if_envrc_lacks_keys(directory, direnv_env, missing)
617+
_warn_if_envrc_lacks_keys(directory, direnv_env, missing, stderr)
607618
return api_key, app_key
608619

609620

tests/test_datadog_slo_overrides_cli.py

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -197,7 +197,7 @@ def test_load_direnv_env_success(tmp_path: Path, monkeypatch: pytest.MonkeyPatch
197197
payload = json.dumps({'DD_API_KEY': 'key-123', 'DD_APP_KEY': 'app-456', 'DIRENV_DIFF': 'x'})
198198
monkeypatch.setattr(cli.subprocess, 'run', lambda *_args, **_kwargs: _completed(stdout=payload))
199199

200-
loaded = load_direnv_env(tmp_path)
200+
loaded, _stderr = load_direnv_env(tmp_path)
201201
assert loaded['DD_API_KEY'] == 'key-123'
202202
assert loaded['DD_APP_KEY'] == 'app-456'
203203

@@ -211,7 +211,8 @@ def test_load_direnv_env_unallowed(
211211
blocked = _completed(stderr=f'direnv: error {tmp_path}/.envrc is blocked. Run `direnv allow`.', returncode=1)
212212
monkeypatch.setattr(cli.subprocess, 'run', lambda *_args, **_kwargs: blocked)
213213

214-
assert load_direnv_env(tmp_path) == {}
214+
env, _stderr = load_direnv_env(tmp_path)
215+
assert env == {}
215216
assert 'direnv allow' in capsys.readouterr().err
216217

217218

@@ -225,7 +226,8 @@ def _fail(*_args: object, **_kwargs: object) -> object:
225226
raise AssertionError(msg)
226227

227228
monkeypatch.setattr(cli.subprocess, 'run', _fail)
228-
assert load_direnv_env(tmp_path) == {}
229+
env, _stderr = load_direnv_env(tmp_path)
230+
assert env == {}
229231

230232

231233
def test_resolve_credentials_prefers_explicit_values(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
@@ -244,20 +246,27 @@ def test_resolve_credentials_fills_missing_from_direnv_without_override(
244246
monkeypatch: pytest.MonkeyPatch,
245247
) -> None:
246248
"""Missing keys are filled from direnv; an explicit value is never overridden."""
247-
monkeypatch.setattr(cli, 'load_direnv_env', lambda _d: {'DD_API_KEY': 'direnv-key', 'DD_APP_KEY': 'direnv-app'})
249+
monkeypatch.setattr(
250+
cli, 'load_direnv_env', lambda _d: ({'DD_API_KEY': 'direnv-key', 'DD_APP_KEY': 'direnv-app'}, '')
251+
)
248252
assert resolve_credentials('flag-key', None, tmp_path) == ('flag-key', 'direnv-app')
249253

250254

251-
def test_resolve_credentials_warns_when_envrc_lacks_keys(
255+
def test_resolve_credentials_surfaces_direnv_reason_when_keys_missing(
252256
tmp_path: Path,
253257
monkeypatch: pytest.MonkeyPatch,
254258
capsys: pytest.CaptureFixture[str],
255259
) -> None:
256-
"""An evaluated .envrc that exports no Datadog keys yields a hint naming the missing ones."""
257-
# Non-empty dict (direnv ran) but without DD_API_KEY/DD_APP_KEY.
258-
monkeypatch.setattr(cli, 'load_direnv_env', lambda _d: {'DIRENV_DIFF': 'x'})
260+
"""An evaluated .envrc that yields no usable keys names them and echoes direnv's stderr."""
261+
# direnv ran (non-empty dict) but the Vault lookups failed, so the keys are absent
262+
# and the reason is on stderr.
263+
vault_error = 'Error making API request.\nCode: 403. Errors:\n* permission denied'
264+
monkeypatch.setattr(cli, 'load_direnv_env', lambda _d: ({'DIRENV_DIFF': 'x'}, vault_error))
259265
assert resolve_credentials(None, None, tmp_path) == (None, None)
260266
err = capsys.readouterr().err
261267
assert 'DD_API_KEY' in err
262268
assert 'DD_APP_KEY' in err
263-
assert 'does not export' in err
269+
assert 'did not provide' in err
270+
assert 'direnv reported:' in err
271+
assert '403' in err
272+
assert 'permission denied' in err

0 commit comments

Comments
 (0)