devopsiq 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.
- agent/__init__.py +5 -0
- agent/agent.py +232 -0
- agent/investigation.py +339 -0
- agent/prompts.py +125 -0
- agent/store.py +147 -0
- devopsiq-0.1.0.dist-info/METADATA +662 -0
- devopsiq-0.1.0.dist-info/RECORD +30 -0
- devopsiq-0.1.0.dist-info/WHEEL +5 -0
- devopsiq-0.1.0.dist-info/entry_points.txt +2 -0
- devopsiq-0.1.0.dist-info/licenses/LICENSE +21 -0
- devopsiq-0.1.0.dist-info/top_level.txt +3 -0
- main.py +310 -0
- tools/__init__.py +6 -0
- tools/ansible.py +110 -0
- tools/argocd.py +91 -0
- tools/base.py +112 -0
- tools/cloud.py +101 -0
- tools/docker.py +280 -0
- tools/git_ci.py +257 -0
- tools/helm.py +168 -0
- tools/investigation.py +357 -0
- tools/istio.py +43 -0
- tools/kubernetes.py +464 -0
- tools/monitoring.py +162 -0
- tools/newrelic.py +167 -0
- tools/preflight.py +59 -0
- tools/registry.py +49 -0
- tools/system.py +162 -0
- tools/terraform.py +90 -0
- tools/trivy.py +83 -0
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
main.py,sha256=ElfwUD8_Cfg8Te9izRE9znkL81-BhOuKQXR4_DbJd30,11005
|
|
2
|
+
agent/__init__.py,sha256=uWlx2x4nHSQULNQQOSY9wqlLqzew7jfBJ4960Idkt7A,169
|
|
3
|
+
agent/agent.py,sha256=RX_I5Ddn709umA0uXYm8BtdEi5boPwa2fjSOVxEqtKI,9299
|
|
4
|
+
agent/investigation.py,sha256=M3xZNHik94bAKVa3outHci4pzrZE9N5s8z7SL7gtjnk,12807
|
|
5
|
+
agent/prompts.py,sha256=KsGYBgXCasRDnWJGKXFRtazhE8_LyezY9Z590eiK6t4,7301
|
|
6
|
+
agent/store.py,sha256=H6gGWtPR6EMhUBxMf9iPM6smbOigXY488qeo350Demw,5546
|
|
7
|
+
devopsiq-0.1.0.dist-info/licenses/LICENSE,sha256=gHfBJB-YkdlM5hAqg_hdC_j64O3l3G8WyvrwRU1nzys,1068
|
|
8
|
+
tools/__init__.py,sha256=nhGjvOeA4il34iatfdGy-OO2wgcoMSV3mfCDysTJGHg,214
|
|
9
|
+
tools/ansible.py,sha256=jTUdf-nHF1z9Iuz5NG3DAwODGEnB3-EWqt15etYP4Bc,3886
|
|
10
|
+
tools/argocd.py,sha256=pW9r0E5KtyUAtOBmRNBrDn9wv_k7LOLd8HJ32cIngVs,2917
|
|
11
|
+
tools/base.py,sha256=nWBUmtITDDTWAeiSgsJyyqqK9MIT765miM-l8VPFtjs,3919
|
|
12
|
+
tools/cloud.py,sha256=GNzY1RIEyecyhuuLxADDI73CxWCY9J0Be_TRXPP-6Hg,3709
|
|
13
|
+
tools/docker.py,sha256=Hl668TWTz8qENc5KR0K3-E7GWJlBi9lg5lwZ45sHm9U,9322
|
|
14
|
+
tools/git_ci.py,sha256=u48R7wK9QN8-j4AIabn87bw1OzmTYT6BnNBOuCcNYhU,8306
|
|
15
|
+
tools/helm.py,sha256=gPDnTdGSB9Ic_6GCx7P7roRpvQprrkkF87Lj3y7tDHs,5395
|
|
16
|
+
tools/investigation.py,sha256=zdaY20kGc_6fh47mVk_AdPey8WKNlzmkBTWGfKQZers,12013
|
|
17
|
+
tools/istio.py,sha256=UYWLXkSuaqjSekchm6s5gbmNL6tpMftmoQdHKIC2OSQ,1517
|
|
18
|
+
tools/kubernetes.py,sha256=zFSHMmFL7pZqQwd4ZQeHy3Zlo0eTgg9Ba_zJ29FEy1Y,15689
|
|
19
|
+
tools/monitoring.py,sha256=OPiRyZYlyKkpzXbNkYJuqiD73Df5PmrWG2b-K_-208E,5720
|
|
20
|
+
tools/newrelic.py,sha256=qCXUm7yiS-hbNQ72TavWeL1Y9SNYq__rvhHXCjvafSQ,5721
|
|
21
|
+
tools/preflight.py,sha256=qhfwur8bbMskvr4IZE8rMx86WLuOILyTO-XVTTlIsfY,2079
|
|
22
|
+
tools/registry.py,sha256=MVYnUYRuGZqJ0Hrod2nFnYmI2YBFfjSMclv7jU87JGg,1798
|
|
23
|
+
tools/system.py,sha256=XF6TO08BYC5p818ZAI5LtfqyhaFRu2RArpKksD47S3c,5252
|
|
24
|
+
tools/terraform.py,sha256=kn52U4xiVB7ZbVQtrHCgVwb4SEanUFmgeOZmvsgJC0Q,3244
|
|
25
|
+
tools/trivy.py,sha256=znSzYqey78oWDDHKj0xO4NA_6xUQzq6Je2pu0_lso58,3002
|
|
26
|
+
devopsiq-0.1.0.dist-info/METADATA,sha256=L9HbVeWkRPlEJaxwaqRKR92f3kioFKQ5vI76r2EpboY,38422
|
|
27
|
+
devopsiq-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
28
|
+
devopsiq-0.1.0.dist-info/entry_points.txt,sha256=71bCZyu1h4f7oK5rvqaW_Ah6uQRceRaGlWphcXNaDZw,39
|
|
29
|
+
devopsiq-0.1.0.dist-info/top_level.txt,sha256=V_H1jV8TvkW5zy_1ZYtspiHgwqRs8shfUc-mMz5Abp0,17
|
|
30
|
+
devopsiq-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 DevOpsAbhii
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
main.py
ADDED
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
"""The DevOps investigation agent.
|
|
2
|
+
|
|
3
|
+
Two modes of use:
|
|
4
|
+
|
|
5
|
+
REPL (default):
|
|
6
|
+
python main.py
|
|
7
|
+
|
|
8
|
+
One-shot (for cron / CI / scripts):
|
|
9
|
+
python main.py "why is api-5d6f crash-looping?"
|
|
10
|
+
python main.py --json "why is api-5d6f crash-looping?" # structured report
|
|
11
|
+
python main.py --resume --json "any update?" # continue prior run
|
|
12
|
+
python main.py --store-dir DIR --out report.json "..." # pipeline paths
|
|
13
|
+
|
|
14
|
+
One-shot mode sends the problem once, prints the agent's report, and exits
|
|
15
|
+
with a status code (0 = completed, 1 = setup/API error). With --json the
|
|
16
|
+
output is the tracked investigation as JSON (see agent/investigation.py:
|
|
17
|
+
render_report_json) instead of the markdown report, so a pipeline can act
|
|
18
|
+
on the verdict. --out additionally writes that JSON to an exact path.
|
|
19
|
+
Phase 7: the investigation record is auto-saved on every change
|
|
20
|
+
(~/.devops-ai-agent/investigations/ by default) and survives CLI exits; the
|
|
21
|
+
REPL resumes the newest in-progress record at startup. Slash commands work
|
|
22
|
+
in both modes (/investigate, /investigations, /report, ...). Read-only:
|
|
23
|
+
nothing here ever mutates a real system.
|
|
24
|
+
|
|
25
|
+
Type `exit` (or `quit`, or Ctrl-D / Ctrl-C) to leave the REPL.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
import json
|
|
29
|
+
import sys
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
from typing import NamedTuple
|
|
32
|
+
|
|
33
|
+
from dotenv import load_dotenv
|
|
34
|
+
from openai import (
|
|
35
|
+
APIError,
|
|
36
|
+
APIConnectionError,
|
|
37
|
+
APITimeoutError,
|
|
38
|
+
APIStatusError,
|
|
39
|
+
AuthenticationError,
|
|
40
|
+
RateLimitError,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
from agent.agent import DevOpsAgent
|
|
44
|
+
|
|
45
|
+
EXIT_WORDS = {"exit", "quit"}
|
|
46
|
+
|
|
47
|
+
# Slash commands that operate on the investigation record, plus short aliases.
|
|
48
|
+
COMMAND_ALIASES = {
|
|
49
|
+
"/investigate": "/investigate",
|
|
50
|
+
"/inv": "/investigate",
|
|
51
|
+
"/investigation": "/investigation",
|
|
52
|
+
"/status": "/investigation",
|
|
53
|
+
"/investigations": "/investigations",
|
|
54
|
+
"/history": "/investigations",
|
|
55
|
+
"/report": "/report",
|
|
56
|
+
"/endinvestigation": "/endinvestigation",
|
|
57
|
+
"/end": "/endinvestigation",
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def handle_command(text: str, agent: DevOpsAgent) -> str | None:
|
|
62
|
+
"""Handle a slash command; return the text to print, or None to continue.
|
|
63
|
+
|
|
64
|
+
Command lines are detected by main() and routed here without ever
|
|
65
|
+
reaching the model. All commands are read-only with respect to real
|
|
66
|
+
systems: they only inspect or clear the agent's in-memory record (and,
|
|
67
|
+
for /investigations, list the saved copies on disk).
|
|
68
|
+
"""
|
|
69
|
+
cmd, _, rest = text.partition(" ")
|
|
70
|
+
canonical = COMMAND_ALIASES.get(cmd.lower())
|
|
71
|
+
|
|
72
|
+
if canonical == "/investigate":
|
|
73
|
+
problem = rest.strip()
|
|
74
|
+
if not problem:
|
|
75
|
+
return "Usage: /investigate <one-line problem statement>"
|
|
76
|
+
return agent.begin_investigation(problem)
|
|
77
|
+
if canonical == "/investigation":
|
|
78
|
+
return agent.investigation_status_text() or "(no active investigation)"
|
|
79
|
+
if canonical == "/investigations":
|
|
80
|
+
return agent.list_investigations() or "(no saved investigations yet)"
|
|
81
|
+
if canonical == "/report":
|
|
82
|
+
return agent.investigation_report_text() or "(no investigation recorded)"
|
|
83
|
+
if canonical == "/endinvestigation":
|
|
84
|
+
return agent.end_investigation()
|
|
85
|
+
return (f"unknown command: {cmd}. Try /investigate <problem>, "
|
|
86
|
+
"/investigation, /investigations, /report, /endinvestigation")
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class OneShotArgs(NamedTuple):
|
|
90
|
+
"""Parsed one-shot CLI arguments (task=None means "run the REPL")."""
|
|
91
|
+
|
|
92
|
+
task: str | None
|
|
93
|
+
as_json: bool
|
|
94
|
+
store_dir: str | None # --store-dir PATH (persistence override)
|
|
95
|
+
resume: bool # --resume (continue the newest in-progress record)
|
|
96
|
+
out: str | None # --out PATH (also write the JSON report there)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
_VALUE_FLAGS = ("--store-dir", "--out")
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _flag_value(argv: list[str], flag: str) -> str | None:
|
|
103
|
+
"""Value of `flag <value>` in argv, or None when absent/missing."""
|
|
104
|
+
for i, arg in enumerate(argv):
|
|
105
|
+
if arg == flag and i + 1 < len(argv):
|
|
106
|
+
return argv[i + 1]
|
|
107
|
+
return None
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def parse_args(argv: list[str]) -> OneShotArgs:
|
|
111
|
+
"""Split one-shot CLI args.
|
|
112
|
+
|
|
113
|
+
task=None means "run the REPL". A bare task is any positional text;
|
|
114
|
+
--json switches the one-shot output from markdown to the structured JSON
|
|
115
|
+
report; --resume continues the newest in-progress record from the store;
|
|
116
|
+
--store-dir PATH overrides the persistence directory for this run; --out
|
|
117
|
+
PATH additionally writes the JSON report to an exact path. Example:
|
|
118
|
+
`python main.py --json --out r.json "why is it down?"` ->
|
|
119
|
+
("why is it down?", True, None, False, "r.json").
|
|
120
|
+
"""
|
|
121
|
+
as_json = "--json" in argv
|
|
122
|
+
do_resume = "--resume" in argv
|
|
123
|
+
store_dir = _flag_value(argv, "--store-dir")
|
|
124
|
+
out = _flag_value(argv, "--out")
|
|
125
|
+
positionals: list[str] = []
|
|
126
|
+
skip_next = False
|
|
127
|
+
for arg in argv:
|
|
128
|
+
if skip_next:
|
|
129
|
+
skip_next = False
|
|
130
|
+
continue
|
|
131
|
+
if arg in _VALUE_FLAGS:
|
|
132
|
+
skip_next = True # its value was captured by _flag_value
|
|
133
|
+
continue
|
|
134
|
+
if arg in ("--json", "--resume"):
|
|
135
|
+
continue
|
|
136
|
+
positionals.append(arg)
|
|
137
|
+
text = " ".join(positionals).strip()
|
|
138
|
+
if not positionals or not text:
|
|
139
|
+
return OneShotArgs(None, as_json, store_dir, do_resume, out)
|
|
140
|
+
return OneShotArgs(text, as_json, store_dir, do_resume, out)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def run_one_shot(
|
|
144
|
+
task: str,
|
|
145
|
+
as_json: bool = False,
|
|
146
|
+
agent: DevOpsAgent | None = None,
|
|
147
|
+
store_dir: str | None = None,
|
|
148
|
+
resume: bool = False,
|
|
149
|
+
out: str | None = None,
|
|
150
|
+
) -> int:
|
|
151
|
+
"""Non-interactive single run: send `task`, print the result, exit cleanly.
|
|
152
|
+
|
|
153
|
+
Exit code 1 on setup/API errors. With as_json (or out), the printed /
|
|
154
|
+
written output is the tracked investigation as JSON; if the model never
|
|
155
|
+
opened an investigation this is a hard failure (exit 1) — a caller asked
|
|
156
|
+
for a report and there is none to give. The record is auto-saved on every
|
|
157
|
+
mutation regardless; store_dir points persistence somewhere else for this
|
|
158
|
+
run, resume continues the newest in-progress record before asking, and
|
|
159
|
+
out additionally writes the JSON report to an exact path. `agent` lets
|
|
160
|
+
embedders reuse a configured agent (also the test seam); default builds a
|
|
161
|
+
fresh one from the environment.
|
|
162
|
+
"""
|
|
163
|
+
if agent is None:
|
|
164
|
+
try:
|
|
165
|
+
agent = DevOpsAgent()
|
|
166
|
+
except ValueError as exc:
|
|
167
|
+
print(f"[setup] {exc}", file=sys.stderr)
|
|
168
|
+
return 1
|
|
169
|
+
|
|
170
|
+
if store_dir is not None:
|
|
171
|
+
agent.use_store_dir(store_dir)
|
|
172
|
+
if resume:
|
|
173
|
+
agent.resume_investigation()
|
|
174
|
+
|
|
175
|
+
if task.startswith("/"):
|
|
176
|
+
print(handle_command(task, agent))
|
|
177
|
+
return 0
|
|
178
|
+
|
|
179
|
+
try:
|
|
180
|
+
reply = agent.ask(task)
|
|
181
|
+
except ValueError as exc:
|
|
182
|
+
print(f"[input] {exc}", file=sys.stderr)
|
|
183
|
+
return 1
|
|
184
|
+
except Exception as exc: # noqa: BLE001 — same top-level safety net as the REPL
|
|
185
|
+
print(f"[error] {describe_error(exc)}", file=sys.stderr)
|
|
186
|
+
return 1
|
|
187
|
+
|
|
188
|
+
if as_json or out is not None:
|
|
189
|
+
report = agent.investigation_report_json()
|
|
190
|
+
if report is None:
|
|
191
|
+
print(
|
|
192
|
+
"[error] --json/--out requested but the run recorded no "
|
|
193
|
+
"investigation",
|
|
194
|
+
file=sys.stderr,
|
|
195
|
+
)
|
|
196
|
+
return 1
|
|
197
|
+
rendered = json.dumps(report, indent=2, ensure_ascii=False)
|
|
198
|
+
if out is not None:
|
|
199
|
+
try:
|
|
200
|
+
Path(out).write_text(rendered + "\n", encoding="utf-8")
|
|
201
|
+
except OSError as exc:
|
|
202
|
+
print(f"[error] could not write --out {out}: {exc}",
|
|
203
|
+
file=sys.stderr)
|
|
204
|
+
return 1
|
|
205
|
+
if as_json:
|
|
206
|
+
print(rendered)
|
|
207
|
+
return 0
|
|
208
|
+
print(reply)
|
|
209
|
+
return 0
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def describe_error(exc: Exception) -> str:
|
|
213
|
+
"""Map common SDK/API failures to short, safe messages (never leaks keys)."""
|
|
214
|
+
if isinstance(exc, AuthenticationError):
|
|
215
|
+
return (
|
|
216
|
+
"The API key was rejected (HTTP 401). Check that OPENROUTER_API_KEY "
|
|
217
|
+
"in .env is correct and still valid."
|
|
218
|
+
)
|
|
219
|
+
if isinstance(exc, RateLimitError):
|
|
220
|
+
return "Rate limit hit — OpenRouter is throttling requests. Wait and retry."
|
|
221
|
+
if isinstance(exc, APITimeoutError):
|
|
222
|
+
return "The request to OpenRouter timed out. Check your network and retry."
|
|
223
|
+
if isinstance(exc, APIConnectionError):
|
|
224
|
+
return (
|
|
225
|
+
"Could not reach the OpenRouter API. Check your internet connection "
|
|
226
|
+
"and that the base URL is correct."
|
|
227
|
+
)
|
|
228
|
+
if isinstance(exc, APIStatusError):
|
|
229
|
+
return f"The OpenRouter API returned an error (HTTP {exc.status_code})."
|
|
230
|
+
if isinstance(exc, APIError):
|
|
231
|
+
return "The API returned an unexpected error."
|
|
232
|
+
return f"Unexpected error: {type(exc).__name__}"
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def main() -> int:
|
|
236
|
+
# Load .env (never overrides variables already set in the shell).
|
|
237
|
+
load_dotenv()
|
|
238
|
+
|
|
239
|
+
# One-shot mode: `python main.py [flags] <problem or /command>`.
|
|
240
|
+
args = parse_args(sys.argv[1:])
|
|
241
|
+
if args.task is not None:
|
|
242
|
+
return run_one_shot(
|
|
243
|
+
args.task,
|
|
244
|
+
as_json=args.as_json,
|
|
245
|
+
store_dir=args.store_dir,
|
|
246
|
+
resume=args.resume,
|
|
247
|
+
out=args.out,
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
try:
|
|
251
|
+
agent = DevOpsAgent()
|
|
252
|
+
except ValueError as exc:
|
|
253
|
+
print(f"[setup] {exc}", file=sys.stderr)
|
|
254
|
+
return 1
|
|
255
|
+
|
|
256
|
+
tool_names = ", ".join(sorted(tool.name for tool in agent.tools)) or "none"
|
|
257
|
+
|
|
258
|
+
print("DevOps AI Agent (Phase 9 — 58 read-only tools, persistent investigations)")
|
|
259
|
+
print(f"Model: {agent.model}")
|
|
260
|
+
print(f"Backend: {agent.base_url}")
|
|
261
|
+
print(f"Store: {agent.store_dir or '(persistence off)'}")
|
|
262
|
+
print(f"Tools: {tool_names}")
|
|
263
|
+
print("Commands: /investigate <problem>, /investigation, /investigations, "
|
|
264
|
+
"/report, /endinvestigation")
|
|
265
|
+
print("One-shot: python main.py [--json] [--resume] [--out report.json] "
|
|
266
|
+
"[--store-dir DIR] \"<problem>\"")
|
|
267
|
+
print("Type 'exit' to quit.")
|
|
268
|
+
print()
|
|
269
|
+
|
|
270
|
+
# Phase 7: pick up the newest in-progress record so a previous session's
|
|
271
|
+
# work is not lost (fresh investigations start with /investigate as usual).
|
|
272
|
+
resumed = agent.resume_investigation()
|
|
273
|
+
if resumed:
|
|
274
|
+
print(resumed)
|
|
275
|
+
print()
|
|
276
|
+
|
|
277
|
+
while True:
|
|
278
|
+
try:
|
|
279
|
+
raw = input("You: ")
|
|
280
|
+
except (EOFError, KeyboardInterrupt):
|
|
281
|
+
print("\nBye.")
|
|
282
|
+
return 0
|
|
283
|
+
|
|
284
|
+
text = raw.strip()
|
|
285
|
+
if not text:
|
|
286
|
+
print("(Enter a message, or type 'exit' to quit.)")
|
|
287
|
+
continue
|
|
288
|
+
if text.lower() in EXIT_WORDS:
|
|
289
|
+
print("Bye.")
|
|
290
|
+
return 0
|
|
291
|
+
if text.startswith("/"):
|
|
292
|
+
print(handle_command(text, agent))
|
|
293
|
+
print()
|
|
294
|
+
continue
|
|
295
|
+
|
|
296
|
+
try:
|
|
297
|
+
reply = agent.ask(text)
|
|
298
|
+
except ValueError as exc:
|
|
299
|
+
print(f"[input] {exc}")
|
|
300
|
+
continue
|
|
301
|
+
except Exception as exc: # noqa: BLE001 — top-level safety net
|
|
302
|
+
print(f"[error] {describe_error(exc)}")
|
|
303
|
+
continue
|
|
304
|
+
|
|
305
|
+
print(f"Agent: {reply}")
|
|
306
|
+
print()
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
if __name__ == "__main__":
|
|
310
|
+
sys.exit(main())
|
tools/__init__.py
ADDED
tools/ansible.py
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""Read-only Ansible tools (Phase 8).
|
|
2
|
+
|
|
3
|
+
Two inventory/playbook inspection tools: `ansible-inventory --list` (see the
|
|
4
|
+
hosts and groups an inventory resolves to) and `ansible-playbook --list-tasks
|
|
5
|
+
--list-hosts` (see what a playbook WOULD do, without doing it). Both are pure
|
|
6
|
+
listing modes — they connect to no managed host and change nothing.
|
|
7
|
+
|
|
8
|
+
Safety model:
|
|
9
|
+
- Only fixed argv templates with the --list / --list-tasks / --list-hosts
|
|
10
|
+
modes exist. There is no playbook-execution path: no run, no syntax-check
|
|
11
|
+
with extra vars, no ad-hoc `ansible` module invocations.
|
|
12
|
+
- Arguments are path-shaped, so they are validated as RELATIVE paths: no
|
|
13
|
+
leading '/', no leading '-', no '..' segment (no traversal), no '/'. (a
|
|
14
|
+
slash could smuggle a second path element; inventory/playbook names are
|
|
15
|
+
meant to resolve against the working directory or ansible.cfg).
|
|
16
|
+
- ansible / ansible-inventory must be installed; if absent, the exact error
|
|
17
|
+
is returned — nothing is invented.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
import re
|
|
21
|
+
|
|
22
|
+
from tools.base import Tool, ToolError, read_command_output
|
|
23
|
+
from tools.registry import register
|
|
24
|
+
|
|
25
|
+
_TIMEOUT_S = 15
|
|
26
|
+
|
|
27
|
+
# Relative, dash-free, traversal-free path fragment the CLIs receive.
|
|
28
|
+
_SAFE_PATH_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9._+\-]{0,200}")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _checked_path(value, kind: str) -> str:
|
|
32
|
+
if (
|
|
33
|
+
not isinstance(value, str)
|
|
34
|
+
or not _SAFE_PATH_RE.fullmatch(value)
|
|
35
|
+
or ".." in value
|
|
36
|
+
):
|
|
37
|
+
raise ToolError(
|
|
38
|
+
f"invalid {kind} {value!r}: expected a relative path fragment "
|
|
39
|
+
"(no '/', no leading '-', no '..') resolving in the working "
|
|
40
|
+
"directory or ansible.cfg"
|
|
41
|
+
)
|
|
42
|
+
return value
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _ansible_inventory(args: dict) -> str:
|
|
46
|
+
inventory = args.get("inventory")
|
|
47
|
+
argv = ["ansible-inventory"]
|
|
48
|
+
if inventory is not None:
|
|
49
|
+
argv += ["--inventory", _checked_path(inventory, "inventory")]
|
|
50
|
+
argv += ["--list"]
|
|
51
|
+
return read_command_output(tuple(argv), timeout=_TIMEOUT_S)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _ansible_playbook_tasks(args: dict) -> str:
|
|
55
|
+
playbook = _checked_path(args.get("playbook"), "playbook")
|
|
56
|
+
return read_command_output(
|
|
57
|
+
("ansible-playbook", "--list-tasks", "--list-hosts", playbook),
|
|
58
|
+
timeout=_TIMEOUT_S,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
ANSIBLE_INVENTORY = Tool(
|
|
63
|
+
name="ansible_inventory",
|
|
64
|
+
description=(
|
|
65
|
+
"Dump the resolved Ansible inventory as JSON (ansible-inventory "
|
|
66
|
+
"--list): groups, hosts, hostvars. Use to answer 'which hosts does "
|
|
67
|
+
"this inventory actually target' before/during an incident, or to "
|
|
68
|
+
"find the right host for a hypothesis. Without 'inventory' it uses "
|
|
69
|
+
"the ansible.cfg / default inventory of the working directory. "
|
|
70
|
+
"Read-only."
|
|
71
|
+
),
|
|
72
|
+
parameters={
|
|
73
|
+
"type": "object",
|
|
74
|
+
"properties": {
|
|
75
|
+
"inventory": {
|
|
76
|
+
"type": "string",
|
|
77
|
+
"description": "Optional inventory source (file or group "
|
|
78
|
+
"name relative to the working directory).",
|
|
79
|
+
}
|
|
80
|
+
},
|
|
81
|
+
"additionalProperties": False,
|
|
82
|
+
},
|
|
83
|
+
executor=_ansible_inventory,
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
ANSIBLE_PLAYBOOK_TASKS = Tool(
|
|
87
|
+
name="ansible_playbook_tasks",
|
|
88
|
+
description=(
|
|
89
|
+
"List the tasks and target hosts of a playbook (ansible-playbook "
|
|
90
|
+
"--list-tasks --list-hosts) WITHOUT running it. Use to see what a "
|
|
91
|
+
"deploy playbook would change, or to map a failing host back to the "
|
|
92
|
+
"task that touches it. Read-only; connects to no host."
|
|
93
|
+
),
|
|
94
|
+
parameters={
|
|
95
|
+
"type": "object",
|
|
96
|
+
"properties": {
|
|
97
|
+
"playbook": {
|
|
98
|
+
"type": "string",
|
|
99
|
+
"description": "Playbook file, relative to the working "
|
|
100
|
+
"directory (e.g. site.yml).",
|
|
101
|
+
}
|
|
102
|
+
},
|
|
103
|
+
"required": ["playbook"],
|
|
104
|
+
"additionalProperties": False,
|
|
105
|
+
},
|
|
106
|
+
executor=_ansible_playbook_tasks,
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
register(ANSIBLE_INVENTORY)
|
|
110
|
+
register(ANSIBLE_PLAYBOOK_TASKS)
|
tools/argocd.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Read-only Argo CD tools (Phase 9).
|
|
2
|
+
|
|
3
|
+
Two tools over the argocd CLI: list Applications (argocd app list) and one
|
|
4
|
+
Application's full status (argocd app get). GitOps health questions — "is
|
|
5
|
+
the cluster in sync with git", "why is my app degraded/out-of-sync" — start
|
|
6
|
+
here.
|
|
7
|
+
|
|
8
|
+
Safety model:
|
|
9
|
+
- Only fixed argv templates with the list/get verbs exist. There is no app
|
|
10
|
+
create, sync, rollback, terminate-op, delete, or repo path — argocd's
|
|
11
|
+
mutating side is unreachable by construction.
|
|
12
|
+
- Application names are validated (DNS-style, like Kubernetes objects)
|
|
13
|
+
before reaching argocd — blocks flag injection.
|
|
14
|
+
- The CLI must be installed AND logged in (`argocd login`); otherwise the
|
|
15
|
+
exact CLI error is returned — nothing is invented.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import re
|
|
19
|
+
|
|
20
|
+
from tools.base import Tool, ToolError, read_command_output
|
|
21
|
+
from tools.registry import register
|
|
22
|
+
|
|
23
|
+
_NAME_RE = re.compile(r"[a-z0-9](?:[-a-z0-9.]{0,251}[a-z0-9])?")
|
|
24
|
+
_TIMEOUT_S = 15
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _check_name(value, kind: str) -> str:
|
|
28
|
+
if (
|
|
29
|
+
not isinstance(value, str)
|
|
30
|
+
or len(value) > 253
|
|
31
|
+
or not _NAME_RE.fullmatch(value)
|
|
32
|
+
):
|
|
33
|
+
raise ToolError(
|
|
34
|
+
f"invalid Argo CD {kind} name {value!r}: expected lowercase "
|
|
35
|
+
"letters, digits, '-' or '.', max 253 characters"
|
|
36
|
+
)
|
|
37
|
+
return value
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _argocd_apps(args: dict) -> str:
|
|
41
|
+
return read_command_output(
|
|
42
|
+
("argocd", "app", "list", "--output", "json"),
|
|
43
|
+
timeout=_TIMEOUT_S,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _argocd_app_status(args: dict) -> str:
|
|
48
|
+
app = _check_name(args.get("app"), "application")
|
|
49
|
+
return read_command_output(
|
|
50
|
+
("argocd", "app", "get", app),
|
|
51
|
+
timeout=_TIMEOUT_S,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
ARGOCD_APPS = Tool(
|
|
56
|
+
name="argocd_apps",
|
|
57
|
+
description=(
|
|
58
|
+
"List Argo CD Applications as JSON (argocd app list): name, sync "
|
|
59
|
+
"status (Synced/OutOfSync), health (Healthy/Degraded/Progressing), "
|
|
60
|
+
"target/destination, git revision. Use for GitOps questions — 'is "
|
|
61
|
+
"the cluster in sync with git', 'which apps are degraded'. "
|
|
62
|
+
"Requires the argocd CLI installed and logged in. Read-only."
|
|
63
|
+
),
|
|
64
|
+
parameters={"type": "object", "properties": {}, "additionalProperties": False},
|
|
65
|
+
executor=_argocd_apps,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
ARGOCD_APP_STATUS = Tool(
|
|
69
|
+
name="argocd_app_status",
|
|
70
|
+
description=(
|
|
71
|
+
"One Argo CD Application's detail (argocd app get): sync and health "
|
|
72
|
+
"status per resource, the git revision deployed, conditions, and "
|
|
73
|
+
"recent operations. Use with argocd_apps to explain 'why is this "
|
|
74
|
+
"app OutOfSync/Degraded'. Read-only."
|
|
75
|
+
),
|
|
76
|
+
parameters={
|
|
77
|
+
"type": "object",
|
|
78
|
+
"properties": {
|
|
79
|
+
"app": {
|
|
80
|
+
"type": "string",
|
|
81
|
+
"description": "Application name.",
|
|
82
|
+
}
|
|
83
|
+
},
|
|
84
|
+
"required": ["app"],
|
|
85
|
+
"additionalProperties": False,
|
|
86
|
+
},
|
|
87
|
+
executor=_argocd_app_status,
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
register(ARGOCD_APPS)
|
|
91
|
+
register(ARGOCD_APP_STATUS)
|