ipa-diagnose 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. ipa_diagnose/__init__.py +10 -0
  2. ipa_diagnose/__main__.py +6 -0
  3. ipa_diagnose/ai/__init__.py +19 -0
  4. ipa_diagnose/ai/anthropic_provider.py +67 -0
  5. ipa_diagnose/ai/bedrock_provider.py +100 -0
  6. ipa_diagnose/ai/openai_provider.py +76 -0
  7. ipa_diagnose/ai/prompt.py +98 -0
  8. ipa_diagnose/ai/provider.py +101 -0
  9. ipa_diagnose/cli.py +190 -0
  10. ipa_diagnose/config.py +69 -0
  11. ipa_diagnose/engine/__init__.py +29 -0
  12. ipa_diagnose/engine/correlate.py +167 -0
  13. ipa_diagnose/engine/model.py +191 -0
  14. ipa_diagnose/engine/packs/__init__.py +3 -0
  15. ipa_diagnose/engine/packs/base.py +67 -0
  16. ipa_diagnose/engine/packs/certificates.py +731 -0
  17. ipa_diagnose/engine/packs/directory_server.py +718 -0
  18. ipa_diagnose/engine/packs/dns.py +700 -0
  19. ipa_diagnose/engine/packs/kerberos.py +517 -0
  20. ipa_diagnose/engine/packs/replication.py +683 -0
  21. ipa_diagnose/engine/registry.py +34 -0
  22. ipa_diagnose/engine/run.py +16 -0
  23. ipa_diagnose/evidence/__init__.py +17 -0
  24. ipa_diagnose/evidence/collect.py +145 -0
  25. ipa_diagnose/evidence/collectors/__init__.py +3 -0
  26. ipa_diagnose/evidence/collectors/base.py +54 -0
  27. ipa_diagnose/evidence/collectors/certmonger.py +208 -0
  28. ipa_diagnose/evidence/collectors/dns_lookup.py +221 -0
  29. ipa_diagnose/evidence/collectors/filesystem.py +194 -0
  30. ipa_diagnose/evidence/collectors/journal_dirsrv.py +201 -0
  31. ipa_diagnose/evidence/collectors/journal_krb5kdc.py +168 -0
  32. ipa_diagnose/evidence/collectors/journal_named.py +189 -0
  33. ipa_diagnose/evidence/collectors/journal_pki.py +160 -0
  34. ipa_diagnose/evidence/collectors/kerberos_client.py +307 -0
  35. ipa_diagnose/evidence/collectors/ldap_query.py +304 -0
  36. ipa_diagnose/evidence/collectors/registry.py +63 -0
  37. ipa_diagnose/evidence/collectors/replication_agreements.py +286 -0
  38. ipa_diagnose/evidence/healthcheck.py +120 -0
  39. ipa_diagnose/evidence/model.py +139 -0
  40. ipa_diagnose/privacy/__init__.py +10 -0
  41. ipa_diagnose/privacy/minimize.py +162 -0
  42. ipa_diagnose/privacy/preview.py +36 -0
  43. ipa_diagnose/privacy/redact.py +119 -0
  44. ipa_diagnose/render/__init__.py +4 -0
  45. ipa_diagnose/render/console.py +233 -0
  46. ipa_diagnose/render/json_output.py +69 -0
  47. ipa_diagnose/verify.py +165 -0
  48. ipa_diagnose-0.1.0.dist-info/METADATA +295 -0
  49. ipa_diagnose-0.1.0.dist-info/RECORD +53 -0
  50. ipa_diagnose-0.1.0.dist-info/WHEEL +5 -0
  51. ipa_diagnose-0.1.0.dist-info/entry_points.txt +2 -0
  52. ipa_diagnose-0.1.0.dist-info/licenses/LICENSE +190 -0
  53. ipa_diagnose-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,10 @@
1
+ """ipa-diagnose: evidence-grounded diagnostic and correlation engine for FreeIPA / Red Hat IdM."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from importlib.metadata import PackageNotFoundError, version
6
+
7
+ try:
8
+ __version__ = version("ipa-diagnose")
9
+ except PackageNotFoundError: # pragma: no cover - only hit for an unpackaged checkout
10
+ __version__ = "0.0.0+unknown"
@@ -0,0 +1,6 @@
1
+ import sys
2
+
3
+ from ipa_diagnose.cli import main
4
+
5
+ if __name__ == "__main__":
6
+ sys.exit(main())
@@ -0,0 +1,19 @@
1
+ from ipa_diagnose.ai.provider import (
2
+ AIProvider,
3
+ ProviderAuthError,
4
+ ProviderConnectionError,
5
+ ProviderError,
6
+ ProviderRateLimitError,
7
+ ProviderRefusalError,
8
+ ProviderTimeoutError,
9
+ )
10
+
11
+ __all__ = [
12
+ "AIProvider",
13
+ "ProviderAuthError",
14
+ "ProviderConnectionError",
15
+ "ProviderError",
16
+ "ProviderRateLimitError",
17
+ "ProviderRefusalError",
18
+ "ProviderTimeoutError",
19
+ ]
@@ -0,0 +1,67 @@
1
+ """Anthropic adapter. Requires the `anthropic` extra (`pip install ipa-diagnose[anthropic]`)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Optional
6
+
7
+ from ipa_diagnose.ai.provider import (
8
+ AIProvider,
9
+ AIRequest,
10
+ AIResponse,
11
+ ProviderAuthError,
12
+ ProviderConnectionError,
13
+ ProviderError,
14
+ ProviderRateLimitError,
15
+ ProviderRefusalError,
16
+ ProviderTimeoutError,
17
+ ProviderUnavailableError,
18
+ )
19
+
20
+
21
+ class AnthropicProvider(AIProvider):
22
+ provider_name = "anthropic"
23
+
24
+ def __init__(self, *, api_key: Optional[str], model: str, timeout: float):
25
+ self._api_key = api_key
26
+ self._model = model
27
+ self._timeout = timeout
28
+
29
+ def is_configured(self) -> bool:
30
+ return bool(self._api_key)
31
+
32
+ def generate(self, request: AIRequest) -> AIResponse:
33
+ if not self._api_key:
34
+ raise ProviderAuthError("ANTHROPIC_API_KEY is not set")
35
+ try:
36
+ import anthropic
37
+ except ImportError as e:
38
+ raise ProviderError(
39
+ "the 'anthropic' package is not installed (pip install 'ipa-diagnose[anthropic]')"
40
+ ) from e
41
+
42
+ client = anthropic.Anthropic(api_key=self._api_key, timeout=min(request.timeout, self._timeout))
43
+ try:
44
+ message = client.messages.create(
45
+ model=self._model,
46
+ max_tokens=request.max_tokens,
47
+ system=request.system_prompt,
48
+ messages=[{"role": "user", "content": request.user_prompt}],
49
+ )
50
+ except anthropic.AuthenticationError as e:
51
+ raise ProviderAuthError(str(e)) from e
52
+ except anthropic.RateLimitError as e:
53
+ raise ProviderRateLimitError(str(e)) from e
54
+ except anthropic.APITimeoutError as e:
55
+ raise ProviderTimeoutError(str(e)) from e
56
+ except anthropic.APIConnectionError as e:
57
+ raise ProviderConnectionError(str(e)) from e
58
+ except anthropic.APIStatusError as e:
59
+ raise ProviderUnavailableError(str(e)) from e
60
+
61
+ if getattr(message, "stop_reason", None) == "refusal":
62
+ raise ProviderRefusalError("Claude declined to produce a response")
63
+ blocks = [b.text for b in message.content if getattr(b, "type", None) == "text"]
64
+ text = "\n".join(blocks).strip()
65
+ if not text:
66
+ raise ProviderRefusalError("Claude returned an empty response")
67
+ return AIResponse(text=text, provider_name=self.provider_name, model=self._model)
@@ -0,0 +1,100 @@
1
+ """AWS Bedrock adapter (Converse API). Requires the `bedrock` extra
2
+ (`pip install ipa-diagnose[bedrock]`, which installs boto3).
3
+
4
+ Uses boto3's standard credential chain (env vars, ~/.aws, EC2/ECS instance
5
+ role, assumed role) rather than requiring explicit keys - the realistic
6
+ default for this tool's audience is an IAM role already attached to the
7
+ host. See docs/ai-configuration.md for the cross-region inference-profile
8
+ gotcha this adapter's default model id already works around.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from ipa_diagnose.ai.provider import (
14
+ AIProvider,
15
+ AIRequest,
16
+ AIResponse,
17
+ ProviderAuthError,
18
+ ProviderConnectionError,
19
+ ProviderError,
20
+ ProviderRateLimitError,
21
+ ProviderRefusalError,
22
+ ProviderTimeoutError,
23
+ ProviderUnavailableError,
24
+ )
25
+
26
+ _RETRYABLE_BUT_STILL_UNAVAILABLE = {"ServiceUnavailableException", "InternalServerError", "ModelTimeoutException"}
27
+
28
+
29
+ class BedrockProvider(AIProvider):
30
+ provider_name = "bedrock"
31
+
32
+ def __init__(self, *, model_id: str, region: str, timeout: float):
33
+ self._model_id = model_id
34
+ self._region = region
35
+ self._timeout = timeout
36
+ self._client = None
37
+
38
+ def is_configured(self) -> bool:
39
+ try:
40
+ import boto3
41
+ except ImportError:
42
+ return False
43
+ try:
44
+ session = boto3.Session()
45
+ return session.get_credentials() is not None
46
+ except Exception:
47
+ return False
48
+
49
+ def _get_client(self):
50
+ if self._client is None:
51
+ import boto3
52
+ from botocore.config import Config
53
+
54
+ self._client = boto3.client(
55
+ "bedrock-runtime",
56
+ region_name=self._region,
57
+ config=Config(connect_timeout=self._timeout, read_timeout=self._timeout, retries={"max_attempts": 2}),
58
+ )
59
+ return self._client
60
+
61
+ def generate(self, request: AIRequest) -> AIResponse:
62
+ try:
63
+ import botocore
64
+ except ImportError as e:
65
+ raise ProviderError(
66
+ "the 'boto3' package is not installed (pip install 'ipa-diagnose[bedrock]')"
67
+ ) from e
68
+
69
+ client = self._get_client()
70
+ try:
71
+ response = client.converse(
72
+ modelId=self._model_id,
73
+ system=[{"text": request.system_prompt}],
74
+ messages=[{"role": "user", "content": [{"text": request.user_prompt}]}],
75
+ inferenceConfig={"maxTokens": request.max_tokens},
76
+ )
77
+ except botocore.exceptions.ClientError as e:
78
+ code = e.response.get("Error", {}).get("Code", "")
79
+ if code in ("AccessDeniedException", "UnrecognizedClientException"):
80
+ raise ProviderAuthError(str(e)) from e
81
+ if code == "ThrottlingException":
82
+ raise ProviderRateLimitError(str(e)) from e
83
+ if code in _RETRYABLE_BUT_STILL_UNAVAILABLE:
84
+ raise ProviderUnavailableError(str(e)) from e
85
+ if code == "ValidationException":
86
+ raise ProviderError(f"Bedrock rejected the request: {e}") from e
87
+ raise ProviderUnavailableError(str(e)) from e
88
+ except botocore.exceptions.ConnectTimeoutError as e:
89
+ raise ProviderTimeoutError(str(e)) from e
90
+ except botocore.exceptions.EndpointConnectionError as e:
91
+ raise ProviderConnectionError(str(e)) from e
92
+
93
+ stop_reason = response.get("stopReason")
94
+ if stop_reason == "content_filtered":
95
+ raise ProviderRefusalError("Bedrock content filter declined to produce a response")
96
+ content = response.get("output", {}).get("message", {}).get("content", [])
97
+ text = "\n".join(block.get("text", "") for block in content if "text" in block).strip()
98
+ if not text:
99
+ raise ProviderRefusalError("Bedrock returned an empty response")
100
+ return AIResponse(text=text, provider_name=self.provider_name, model=self._model_id)
@@ -0,0 +1,76 @@
1
+ """OpenAI adapter. Requires the `openai` extra (`pip install ipa-diagnose[openai]`).
2
+
3
+ Import of the `openai` package is deferred into __init__ so this module can
4
+ be imported (e.g. by config.py's lazy factory) without the dependency being
5
+ installed - only actually *using* the openai provider requires it.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Optional
11
+
12
+ from ipa_diagnose.ai.provider import (
13
+ AIProvider,
14
+ AIRequest,
15
+ AIResponse,
16
+ ProviderAuthError,
17
+ ProviderConnectionError,
18
+ ProviderError,
19
+ ProviderRateLimitError,
20
+ ProviderRefusalError,
21
+ ProviderTimeoutError,
22
+ ProviderUnavailableError,
23
+ )
24
+
25
+
26
+ class OpenAIProvider(AIProvider):
27
+ provider_name = "openai"
28
+
29
+ def __init__(self, *, api_key: Optional[str], model: str, timeout: float):
30
+ self._api_key = api_key
31
+ self._model = model
32
+ self._timeout = timeout
33
+
34
+ def is_configured(self) -> bool:
35
+ return bool(self._api_key)
36
+
37
+ def generate(self, request: AIRequest) -> AIResponse:
38
+ if not self._api_key:
39
+ raise ProviderAuthError("OPENAI_API_KEY is not set")
40
+ try:
41
+ import openai
42
+ except ImportError as e:
43
+ raise ProviderError(
44
+ "the 'openai' package is not installed (pip install 'ipa-diagnose[openai]')"
45
+ ) from e
46
+
47
+ client = openai.OpenAI(api_key=self._api_key, timeout=min(request.timeout, self._timeout))
48
+ try:
49
+ completion = client.chat.completions.create(
50
+ model=self._model,
51
+ max_tokens=request.max_tokens,
52
+ messages=[
53
+ {"role": "developer", "content": request.system_prompt},
54
+ {"role": "user", "content": request.user_prompt},
55
+ ],
56
+ )
57
+ except openai.AuthenticationError as e:
58
+ raise ProviderAuthError(str(e)) from e
59
+ except openai.RateLimitError as e:
60
+ raise ProviderRateLimitError(str(e)) from e
61
+ except openai.APITimeoutError as e:
62
+ raise ProviderTimeoutError(str(e)) from e
63
+ except openai.APIConnectionError as e:
64
+ raise ProviderConnectionError(str(e)) from e
65
+ except openai.InternalServerError as e:
66
+ raise ProviderUnavailableError(str(e)) from e
67
+ except openai.APIStatusError as e:
68
+ raise ProviderUnavailableError(str(e)) from e
69
+
70
+ choice = completion.choices[0] if completion.choices else None
71
+ if choice is None or getattr(choice, "finish_reason", None) == "content_filter":
72
+ raise ProviderRefusalError("OpenAI declined to produce a response (content filter)")
73
+ text = (choice.message.content or "").strip()
74
+ if not text:
75
+ raise ProviderRefusalError("OpenAI returned an empty response")
76
+ return AIResponse(text=text, provider_name=self.provider_name, model=self._model)
@@ -0,0 +1,98 @@
1
+ """Ties the privacy pipeline + AIProvider together, with a defense-in-depth
2
+ sanitizer on the way back out.
3
+
4
+ Structural safety guarantee (the important one): the rendered Actions section
5
+ in the CLI ALWAYS comes from `Diagnosis.actions` (deterministic Action
6
+ objects), never from AI output. An AI explanation is prose displayed
7
+ alongside that section, never a replacement for it. `sanitize_explanation`
8
+ below is a second, best-effort layer on top of that structural guarantee -
9
+ it catches an AI response that ignored its instructions and tried to invent
10
+ a new command anyway, and rejects the whole explanation rather than trying
11
+ to strip just the bad line (a partially-sanitized AI paragraph is more
12
+ misleading than falling back to the deterministic `why` text).
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import re
18
+ from typing import Optional
19
+
20
+ from ipa_diagnose.ai.provider import AIProvider, AIRequest, ProviderError
21
+ from ipa_diagnose.engine.model import Diagnosis
22
+ from ipa_diagnose.evidence.model import EvidenceBundle
23
+ from ipa_diagnose.privacy.minimize import build_ai_payload
24
+
25
+ # Deliberately NOT anchored to line-start (an earlier version was - a
26
+ # security review found that let prose-embedded suggestions like "you could
27
+ # try: getcert resubmit ..." slip through undetected) and deliberately a
28
+ # broad binary/keyword list rather than an exhaustive one, since the goal is
29
+ # "reject anything command-shaped we don't recognize as approved," not
30
+ # "recognize every dangerous command by name."
31
+ _COMMAND_LIKE = re.compile(
32
+ # Zero-width lookbehind for the boundary (start-of-string, whitespace, or
33
+ # a backtick) rather than a consuming group - otherwise match.group(0)
34
+ # includes the boundary character and would never equal an approved
35
+ # command string during comparison.
36
+ r"(?<![^\s`])(?:\$\s*)?(?:sudo\s+)?(?:/usr/(?:s?bin)/)?"
37
+ r"(ipa[\w-]*|getcert|kinit|klist|kvno|dsconf|dsctl|ldapmodify|ldapsearch|"
38
+ r"systemctl|service|reboot|shutdown|halt|poweroff|init\s+0|"
39
+ r"rm\b|mkfs[\w.]*|dd\b|userdel|groupdel|iptables|firewall-cmd|"
40
+ r"dnf|yum|rpm\b|certutil|db2index[\w.]*|chmod|chown|kill(?:all)?|"
41
+ r"curl|wget|python[\w.]*|perl|bash|sh\b|nc\b|ncat)\b",
42
+ re.IGNORECASE,
43
+ )
44
+ _CODE_SPAN = re.compile(r"`([^`\n]{1,200})`")
45
+ _MAX_EXPLANATION_CHARS = 4000
46
+
47
+
48
+ def _looks_approved(candidate: str, approved_commands: set) -> bool:
49
+ candidate = candidate.strip()
50
+ return any(candidate in cmd or cmd in candidate for cmd in approved_commands)
51
+
52
+
53
+ def sanitize_explanation(text: str, diagnosis: Diagnosis) -> Optional[str]:
54
+ """Returns the explanation if it looks like prose only, else None (caller
55
+ must fall back to the deterministic `why` text).
56
+
57
+ Two independent checks, either of which rejects the whole response:
58
+ 1. Any command-shaped token (see _COMMAND_LIKE) appearing anywhere in
59
+ the text, not just at a line's start.
60
+ 2. Any markdown-style inline code span (`` `...` ``) whose content isn't
61
+ one of the diagnosis's own approved commands - this catches the
62
+ common AI phrasing "run `<command>`" regardless of whether the
63
+ command inside the backticks matches a known binary name.
64
+ """
65
+
66
+ if not text or not text.strip():
67
+ return None
68
+ if len(text) > _MAX_EXPLANATION_CHARS:
69
+ return None
70
+
71
+ approved_commands = {a.command for a in diagnosis.actions if a.command}
72
+
73
+ for span_match in _CODE_SPAN.finditer(text):
74
+ if not _looks_approved(span_match.group(1), approved_commands):
75
+ return None
76
+
77
+ for match in _COMMAND_LIKE.finditer(text):
78
+ if not _looks_approved(match.group(0), approved_commands):
79
+ return None
80
+
81
+ return text.strip()
82
+
83
+
84
+ def explain_diagnosis(diagnosis: Diagnosis, bundle: EvidenceBundle, provider: AIProvider) -> Optional[str]:
85
+ """Returns an AI explanation, or None on any failure/refusal/unsafe output -
86
+ callers must always have a local-explanation fallback ready (Diagnosis.why),
87
+ ipa-diagnose must remain fully useful with this returning None."""
88
+
89
+ if not provider.is_configured():
90
+ return None
91
+ payload = build_ai_payload(bundle, diagnosis)
92
+ try:
93
+ response = provider.generate(
94
+ AIRequest(system_prompt=payload.system_prompt, user_prompt=payload.user_prompt)
95
+ )
96
+ except ProviderError:
97
+ return None
98
+ return sanitize_explanation(response.text, diagnosis)
@@ -0,0 +1,101 @@
1
+ """AI provider abstraction.
2
+
3
+ Exactly one job: turn an already-computed, deterministic Diagnosis into more
4
+ readable prose. A provider implementation NEVER sees raw evidence beyond
5
+ what the privacy/redaction pipeline approved (see privacy/preview.py, which
6
+ renders the exact same payload this module sends - "ai-preview" and the real
7
+ call must never diverge). A provider MUST NOT be asked to produce root
8
+ causes, evidence, confidence, or commands - prompt.py's contract enforces
9
+ this by construction (it only ever asks for an explanation of given facts),
10
+ and render code must ignore/discard anything a provider response contains
11
+ that looks like a new claim (see ai/prompt.py `sanitize_explanation`).
12
+
13
+ Every concrete provider (OpenAI, Anthropic, Bedrock) is a thin ~20-40 line
14
+ adapter: build client, make one call, map that SDK's specific exceptions
15
+ into the ProviderError hierarchy below so the rest of the codebase never
16
+ branches on provider identity. See docs/ai-configuration.md for the research
17
+ backing the model/error-handling choices per provider.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import abc
23
+ import dataclasses
24
+
25
+
26
+ class ProviderError(Exception):
27
+ """Base class for all AI provider failures. Any of these must leave
28
+ ipa-diagnose fully functional in local-explanation mode - never fatal."""
29
+
30
+
31
+ class ProviderAuthError(ProviderError):
32
+ """Invalid/missing API key or credentials."""
33
+
34
+
35
+ class ProviderRateLimitError(ProviderError):
36
+ """Rate limited; retrying immediately will not help."""
37
+
38
+
39
+ class ProviderTimeoutError(ProviderError):
40
+ """Request exceeded the configured timeout."""
41
+
42
+
43
+ class ProviderConnectionError(ProviderError):
44
+ """Network/connectivity failure reaching the provider."""
45
+
46
+
47
+ class ProviderRefusalError(ProviderError):
48
+ """Provider returned a content-policy refusal or empty/invalid response."""
49
+
50
+
51
+ class ProviderUnavailableError(ProviderError):
52
+ """Provider-side server error (5xx) or service outage."""
53
+
54
+
55
+ @dataclasses.dataclass(frozen=True)
56
+ class AIRequest:
57
+ system_prompt: str
58
+ user_prompt: str
59
+ max_tokens: int = 700
60
+ timeout: float = 12.0
61
+
62
+
63
+ @dataclasses.dataclass(frozen=True)
64
+ class AIResponse:
65
+ text: str
66
+ provider_name: str
67
+ model: str
68
+
69
+
70
+ class AIProvider(abc.ABC):
71
+ """Implemented by OpenAIProvider, AnthropicProvider, BedrockProvider."""
72
+
73
+ provider_name: str
74
+
75
+ @abc.abstractmethod
76
+ def generate(self, request: AIRequest) -> AIResponse:
77
+ """Returns a response, or raises a ProviderError subclass. Must never
78
+ raise anything else - implementations should catch their SDK's own
79
+ exception types and re-raise as ProviderError subclasses."""
80
+ raise NotImplementedError
81
+
82
+ @abc.abstractmethod
83
+ def is_configured(self) -> bool:
84
+ """True if required credentials/config are present, without making a
85
+ network call. Used to fail fast with a clear message before ever
86
+ reaching the provider."""
87
+ raise NotImplementedError
88
+
89
+
90
+ class NoAIProvider(AIProvider):
91
+ """The default. Explicitly refuses to run rather than silently no-op,
92
+ so callers always go through the same code path (try provider, catch
93
+ ProviderError, fall back to local explanation)."""
94
+
95
+ provider_name = "none"
96
+
97
+ def generate(self, request: AIRequest) -> AIResponse:
98
+ raise ProviderError("AI explanation is disabled (--no-ai or no provider configured)")
99
+
100
+ def is_configured(self) -> bool:
101
+ return False
ipa_diagnose/cli.py ADDED
@@ -0,0 +1,190 @@
1
+ """ipa-diagnose command-line entry point."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import os
8
+ import sys
9
+ from typing import Dict, Optional
10
+
11
+ from rich.console import Console
12
+
13
+ from ipa_diagnose import __version__
14
+ from ipa_diagnose.ai.prompt import explain_diagnosis
15
+ from ipa_diagnose.config import AIConfig, build_provider
16
+ from ipa_diagnose.engine.model import DiagnosisReport, PriorityBucket
17
+ from ipa_diagnose.engine.run import run_diagnosis
18
+ from ipa_diagnose.evidence.collect import collect_evidence
19
+ from ipa_diagnose.evidence.model import EvidenceBundle
20
+ from ipa_diagnose.privacy.minimize import build_ai_payload
21
+ from ipa_diagnose.privacy.preview import render_preview
22
+ from ipa_diagnose.render.console import render_report, render_verify
23
+ from ipa_diagnose.render.json_output import report_to_dict
24
+ from ipa_diagnose.verify import compare, default_state_path, load_previous_report, save_report
25
+
26
+ _EXPLAINABLE_PRIORITIES = {PriorityBucket.PRIMARY, PriorityBucket.SECONDARY_INDEPENDENT}
27
+
28
+
29
+ def _add_common_args(parser: argparse.ArgumentParser) -> None:
30
+ parser.add_argument("--details", action="store_true", help="show full evidence, confidence, and all actions")
31
+ parser.add_argument("--json", action="store_true", help="print the report as JSON instead of formatted text")
32
+ parser.add_argument("--no-ai", action="store_true", help="never contact an AI provider, even if configured")
33
+ parser.add_argument(
34
+ "--ai-provider", choices=["openai", "anthropic", "bedrock"], default=None,
35
+ help="override IPA_DIAGNOSE_AI_PROVIDER for this run",
36
+ )
37
+ parser.add_argument(
38
+ "--replay", metavar="FIXTURE_DIR", default=None,
39
+ help="read evidence from a fixture directory instead of the live host (development/testing)",
40
+ )
41
+
42
+
43
+ def build_parser() -> argparse.ArgumentParser:
44
+ # A single parser with one optional positional `command`, rather than
45
+ # argparse subparsers: subparsers each get their own copy of the common
46
+ # flags' defaults, which silently clobbers a flag given *before* the
47
+ # subcommand name (e.g. `ipa-diagnose --replay DIR diagnose` would reset
48
+ # --replay back to None). One shared namespace avoids that entirely.
49
+ parser = argparse.ArgumentParser(prog="ipa-diagnose", description="FreeIPA / Red Hat IdM diagnostic tool")
50
+ parser.add_argument("--version", action="version", version=f"ipa-diagnose {__version__}")
51
+ parser.add_argument(
52
+ "command",
53
+ nargs="?",
54
+ choices=["diagnose", "verify", "ai-preview"],
55
+ default="diagnose",
56
+ help="diagnose (default): run diagnosis. verify: check if previously diagnosed problems cleared. "
57
+ "ai-preview: show exactly what would be sent to the AI provider.",
58
+ )
59
+ _add_common_args(parser)
60
+ return parser
61
+
62
+
63
+ def _ai_config_from_args(args: argparse.Namespace) -> AIConfig:
64
+ return AIConfig.from_env_and_args(no_ai=args.no_ai, provider_arg=args.ai_provider)
65
+
66
+
67
+ def _collect_and_diagnose(args: argparse.Namespace) -> tuple[EvidenceBundle, DiagnosisReport]:
68
+ bundle = collect_evidence(replay_dir=args.replay)
69
+ report = run_diagnosis(bundle)
70
+ return bundle, report
71
+
72
+
73
+ def _maybe_explain(
74
+ report: DiagnosisReport, bundle: EvidenceBundle, args: argparse.Namespace, console: Console
75
+ ) -> Dict[str, str]:
76
+ explanations: Dict[str, str] = {}
77
+ config = _ai_config_from_args(args)
78
+ if config.provider == "none":
79
+ return explanations
80
+ provider = build_provider(config)
81
+ if not provider.is_configured():
82
+ console.print(
83
+ f"[dim](AI provider '{config.provider}' is not configured - showing local explanation)[/dim]"
84
+ )
85
+ return explanations
86
+ for d in report.diagnoses:
87
+ if d.priority not in _EXPLAINABLE_PRIORITIES:
88
+ continue
89
+ text = explain_diagnosis(d, bundle, provider)
90
+ if text:
91
+ explanations[d.diagnosis_id] = text
92
+ else:
93
+ console.print(
94
+ f"[dim](AI explanation unavailable for '{d.title}' - showing local explanation)[/dim]"
95
+ )
96
+ return explanations
97
+
98
+
99
+ def cmd_diagnose(args: argparse.Namespace, console: Console) -> int:
100
+ bundle, report = _collect_and_diagnose(args)
101
+ explanations = {} if args.json else _maybe_explain(report, bundle, args, console)
102
+
103
+ if args.json:
104
+ print(json.dumps(report_to_dict(report, explanations), indent=2))
105
+ else:
106
+ render_report(report, console, details=args.details, ai_explanations=explanations)
107
+
108
+ save_report(default_state_path(), report)
109
+ return _exit_code_for(report)
110
+
111
+
112
+ def cmd_verify(args: argparse.Namespace, console: Console) -> int:
113
+ bundle, report = _collect_and_diagnose(args)
114
+ previous = load_previous_report(default_state_path())
115
+ result = compare(previous, report)
116
+
117
+ if args.json:
118
+ print(
119
+ json.dumps(
120
+ {
121
+ "previous_generated_at": result.previous_generated_at,
122
+ "items": [
123
+ {"diagnosis_id": i.diagnosis_id, "title": i.title, "outcome": i.outcome.value, "detail": i.detail}
124
+ for i in result.items
125
+ ],
126
+ "new_conditions": [d.title for d in result.new_conditions],
127
+ "current_report": report_to_dict(report),
128
+ },
129
+ indent=2,
130
+ )
131
+ )
132
+ else:
133
+ render_verify(result, console)
134
+
135
+ save_report(default_state_path(), report)
136
+ return 0
137
+
138
+
139
+ def cmd_ai_preview(args: argparse.Namespace, console: Console) -> int:
140
+ bundle, report = _collect_and_diagnose(args)
141
+ explainable = [d for d in report.diagnoses if d.priority in _EXPLAINABLE_PRIORITIES]
142
+ if not explainable:
143
+ console.print("[green]No primary or independent problems to explain right now.[/green]")
144
+ return 0
145
+ for d in explainable:
146
+ payload = build_ai_payload(bundle, d)
147
+ render_preview(payload, console)
148
+ console.print()
149
+ return 0
150
+
151
+
152
+ def _exit_code_for(report: DiagnosisReport) -> int:
153
+ from ipa_diagnose.engine.model import OverallStatus
154
+
155
+ return {
156
+ OverallStatus.HEALTHY: 0,
157
+ OverallStatus.DEGRADED: 1,
158
+ OverallStatus.CRITICAL: 2,
159
+ OverallStatus.UNKNOWN: 3,
160
+ }[report.overall_status]
161
+
162
+
163
+ def main(argv: Optional[list] = None) -> int:
164
+ parser = build_parser()
165
+ args = parser.parse_args(argv)
166
+ # highlight=False: rich's default ReprHighlighter auto-colors substrings
167
+ # that look like paths/numbers/quoted strings inside plain evidence text
168
+ # (e.g. a log message), which reads as noise, not signal, in this UI -
169
+ # all intentional styling here is explicit markup, not auto-detected.
170
+ console = Console(highlight=False)
171
+
172
+ command = args.command or "diagnose"
173
+ if os.name != "nt" and command in ("diagnose", "verify") and args.replay is None and os.geteuid() != 0:
174
+ console.print(
175
+ "[yellow]Warning: not running as root - live evidence collection (ipa-healthcheck, "
176
+ "journalctl, certmonger) will likely fail or be incomplete.[/yellow]\n"
177
+ )
178
+
179
+ try:
180
+ if command == "verify":
181
+ return cmd_verify(args, console)
182
+ if command == "ai-preview":
183
+ return cmd_ai_preview(args, console)
184
+ return cmd_diagnose(args, console)
185
+ except KeyboardInterrupt:
186
+ return 130
187
+
188
+
189
+ if __name__ == "__main__":
190
+ sys.exit(main())