aiops-wrap 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.
- aiops_wrap/__init__.py +6 -0
- aiops_wrap/__main__.py +4 -0
- aiops_wrap/cli.py +127 -0
- aiops_wrap/config.py +195 -0
- aiops_wrap/join.py +101 -0
- aiops_wrap/py.typed +0 -0
- aiops_wrap/wrap.py +138 -0
- aiops_wrap-0.1.0.dist-info/METADATA +164 -0
- aiops_wrap-0.1.0.dist-info/RECORD +13 -0
- aiops_wrap-0.1.0.dist-info/WHEEL +5 -0
- aiops_wrap-0.1.0.dist-info/entry_points.txt +2 -0
- aiops_wrap-0.1.0.dist-info/licenses/LICENSE +21 -0
- aiops_wrap-0.1.0.dist-info/top_level.txt +1 -0
aiops_wrap/__init__.py
ADDED
aiops_wrap/__main__.py
ADDED
aiops_wrap/cli.py
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""`aiops` command-line entry point: `aiops join` and `aiops wrap -- <command>`."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import sys
|
|
7
|
+
from collections.abc import Sequence
|
|
8
|
+
|
|
9
|
+
from aiops_wrap import __version__
|
|
10
|
+
from aiops_wrap.config import DEFAULT_BASE_URL, load_settings, save_global_setting
|
|
11
|
+
from aiops_wrap.join import JoinError, join
|
|
12
|
+
from aiops_wrap.wrap import run_wrapped
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
16
|
+
parser = argparse.ArgumentParser(
|
|
17
|
+
prog="aiops",
|
|
18
|
+
description="Instrument any scripted agent with zero code changes.",
|
|
19
|
+
)
|
|
20
|
+
parser.add_argument("--version", action="version", version=f"aiops-wrap {__version__}")
|
|
21
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
22
|
+
|
|
23
|
+
join_parser = subparsers.add_parser(
|
|
24
|
+
"join", help="Self-register this agent with AiOps Enabler and store credentials."
|
|
25
|
+
)
|
|
26
|
+
join_parser.add_argument(
|
|
27
|
+
"--email", required=True, help="Operator email (never shown publicly)."
|
|
28
|
+
)
|
|
29
|
+
join_parser.add_argument("--name", help="Agent name (defaults to the current directory name).")
|
|
30
|
+
join_parser.add_argument(
|
|
31
|
+
"--category",
|
|
32
|
+
default="other",
|
|
33
|
+
choices=["incident-response", "alert-triage", "remediation", "observability", "other"],
|
|
34
|
+
)
|
|
35
|
+
join_parser.add_argument("--description")
|
|
36
|
+
join_parser.add_argument("--repo-url")
|
|
37
|
+
join_parser.add_argument("--base-url", default=DEFAULT_BASE_URL)
|
|
38
|
+
|
|
39
|
+
wrap_parser = subparsers.add_parser("wrap", help="Run a command and report it as a task event.")
|
|
40
|
+
wrap_parser.add_argument(
|
|
41
|
+
"wrapped_command",
|
|
42
|
+
nargs=argparse.REMAINDER,
|
|
43
|
+
help="The command to run, e.g. `aiops wrap -- python my_agent.py`.",
|
|
44
|
+
)
|
|
45
|
+
wrap_parser.add_argument(
|
|
46
|
+
"-q", "--quiet", action="store_true", help="Suppress reporting warnings."
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
configure_parser = subparsers.add_parser(
|
|
50
|
+
"configure", help="Set a persistent, non-secret config value in ~/.aiops/config.json."
|
|
51
|
+
)
|
|
52
|
+
configure_parser.add_argument(
|
|
53
|
+
"key", choices=["category", "heartbeat_interval_seconds", "enabled", "base_url"]
|
|
54
|
+
)
|
|
55
|
+
configure_parser.add_argument("value")
|
|
56
|
+
|
|
57
|
+
return parser
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _run_join(args: argparse.Namespace) -> int:
|
|
61
|
+
import os
|
|
62
|
+
|
|
63
|
+
name = args.name or os.path.basename(os.getcwd())
|
|
64
|
+
try:
|
|
65
|
+
result = join(
|
|
66
|
+
email=args.email,
|
|
67
|
+
name=name,
|
|
68
|
+
category=args.category,
|
|
69
|
+
description=args.description,
|
|
70
|
+
repo_url=args.repo_url,
|
|
71
|
+
base_url=args.base_url,
|
|
72
|
+
)
|
|
73
|
+
except (JoinError, ValueError) as exc:
|
|
74
|
+
print(f"aiops join failed: {exc}", file=sys.stderr)
|
|
75
|
+
return 1
|
|
76
|
+
|
|
77
|
+
print(f"Joined as '{result.agent_name}' (slug: {result.agent_slug}).")
|
|
78
|
+
print(f"Credentials saved to ~/.aiops/credentials.json (key id: {result.key_id}).")
|
|
79
|
+
print(result.claim_note)
|
|
80
|
+
print("Run `aiops wrap -- <your command>` to start reporting task events.")
|
|
81
|
+
return 0
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _run_wrap(args: argparse.Namespace) -> int:
|
|
85
|
+
command = list(args.wrapped_command)
|
|
86
|
+
if command and command[0] == "--":
|
|
87
|
+
command = command[1:]
|
|
88
|
+
if not command:
|
|
89
|
+
print(
|
|
90
|
+
"aiops wrap: no command given. Usage: aiops wrap -- <command> [args...]",
|
|
91
|
+
file=sys.stderr,
|
|
92
|
+
)
|
|
93
|
+
return 2
|
|
94
|
+
|
|
95
|
+
settings = load_settings()
|
|
96
|
+
result = run_wrapped(command, settings=settings, quiet=args.quiet)
|
|
97
|
+
return result.exit_code
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _run_configure(args: argparse.Namespace) -> int:
|
|
101
|
+
value: object = args.value
|
|
102
|
+
if args.key == "heartbeat_interval_seconds":
|
|
103
|
+
value = int(args.value)
|
|
104
|
+
elif args.key == "enabled":
|
|
105
|
+
value = args.value.strip().lower() not in ("0", "false", "no", "off")
|
|
106
|
+
save_global_setting(args.key, value)
|
|
107
|
+
print(f"Set {args.key} = {value!r} in ~/.aiops/config.json")
|
|
108
|
+
return 0
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
112
|
+
parser = _build_parser()
|
|
113
|
+
args = parser.parse_args(argv)
|
|
114
|
+
|
|
115
|
+
if args.command == "join":
|
|
116
|
+
return _run_join(args)
|
|
117
|
+
if args.command == "wrap":
|
|
118
|
+
return _run_wrap(args)
|
|
119
|
+
if args.command == "configure":
|
|
120
|
+
return _run_configure(args)
|
|
121
|
+
|
|
122
|
+
parser.print_help()
|
|
123
|
+
return 2
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
if __name__ == "__main__":
|
|
127
|
+
raise SystemExit(main())
|
aiops_wrap/config.py
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
"""Config + credential storage for aiops-wrap.
|
|
2
|
+
|
|
3
|
+
Two concerns are deliberately kept in separate files so a project-local
|
|
4
|
+
override file can safely be committed to a repo without ever risking a
|
|
5
|
+
leaked secret:
|
|
6
|
+
|
|
7
|
+
- ``~/.aiops/credentials.json`` — the agent key pair from `aiops join`.
|
|
8
|
+
Never read from a project-local file, never settable via a project-local
|
|
9
|
+
override; only the global per-user file or the ``AIOPS_KEY_ID`` /
|
|
10
|
+
``AIOPS_SECRET`` environment variables (for CI use, e.g. GitHub Actions
|
|
11
|
+
secrets).
|
|
12
|
+
- Settings (base URL, category, heartbeat interval, the opt-in
|
|
13
|
+
``enabled`` flag) resolve from, in increasing priority: built-in
|
|
14
|
+
defaults -> ``~/.aiops/config.json`` -> ``.aiops.json`` in the current
|
|
15
|
+
directory (or nearest ancestor) -> ``AIOPS_*`` environment variables.
|
|
16
|
+
|
|
17
|
+
Everything is JSON — no extra parsing dependency, and both files are
|
|
18
|
+
meant to be hand-editable.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import contextlib
|
|
24
|
+
import json
|
|
25
|
+
import os
|
|
26
|
+
import stat
|
|
27
|
+
from dataclasses import dataclass
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
from typing import Any
|
|
30
|
+
|
|
31
|
+
DEFAULT_BASE_URL = "https://api.aiopsenabler.com"
|
|
32
|
+
DEFAULT_CATEGORY = "other"
|
|
33
|
+
DEFAULT_HEARTBEAT_INTERVAL_SECONDS = 1800 # 30 minutes, per skill.md guidance
|
|
34
|
+
|
|
35
|
+
_PROJECT_CONFIG_FILENAME = ".aiops.json"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def aiops_home() -> Path:
|
|
39
|
+
"""The per-user aiops-wrap directory, ``~/.aiops`` — overridable via
|
|
40
|
+
``AIOPS_HOME`` for tests and sandboxed/CI environments."""
|
|
41
|
+
override = os.environ.get("AIOPS_HOME")
|
|
42
|
+
if override:
|
|
43
|
+
return Path(override)
|
|
44
|
+
return Path.home() / ".aiops"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def credentials_path() -> Path:
|
|
48
|
+
return aiops_home() / "credentials.json"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def global_config_path() -> Path:
|
|
52
|
+
return aiops_home() / "config.json"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass(frozen=True)
|
|
56
|
+
class Credentials:
|
|
57
|
+
key_id: str
|
|
58
|
+
secret: str
|
|
59
|
+
base_url: str
|
|
60
|
+
agent_slug: str | None = None
|
|
61
|
+
agent_name: str | None = None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass(frozen=True)
|
|
65
|
+
class Settings:
|
|
66
|
+
base_url: str = DEFAULT_BASE_URL
|
|
67
|
+
category: str = DEFAULT_CATEGORY
|
|
68
|
+
heartbeat_interval_seconds: int = DEFAULT_HEARTBEAT_INTERVAL_SECONDS
|
|
69
|
+
enabled: bool = True
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class NotJoinedError(RuntimeError):
|
|
73
|
+
"""Raised when a command needs credentials but `aiops join` has not
|
|
74
|
+
been run yet (and no AIOPS_KEY_ID/AIOPS_SECRET env vars are set)."""
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _read_json(path: Path) -> dict[str, Any]:
|
|
78
|
+
if not path.is_file():
|
|
79
|
+
return {}
|
|
80
|
+
with path.open(encoding="utf-8") as handle:
|
|
81
|
+
data: dict[str, Any] = json.load(handle)
|
|
82
|
+
return data
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def save_credentials(creds: Credentials) -> Path:
|
|
86
|
+
"""Write credentials to ``~/.aiops/credentials.json``, creating the
|
|
87
|
+
directory if needed and restricting permissions to the owner where the
|
|
88
|
+
platform supports it (POSIX; a silent no-op on Windows, which has no
|
|
89
|
+
equivalent bit — NTFS ACLs are left at their default, matching common
|
|
90
|
+
CLI tool practice, e.g. the GitHub CLI's own config storage)."""
|
|
91
|
+
home = aiops_home()
|
|
92
|
+
home.mkdir(parents=True, exist_ok=True)
|
|
93
|
+
path = credentials_path()
|
|
94
|
+
payload = {
|
|
95
|
+
"key_id": creds.key_id,
|
|
96
|
+
"secret": creds.secret,
|
|
97
|
+
"base_url": creds.base_url,
|
|
98
|
+
"agent_slug": creds.agent_slug,
|
|
99
|
+
"agent_name": creds.agent_name,
|
|
100
|
+
}
|
|
101
|
+
path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
|
|
102
|
+
with contextlib.suppress(OSError, NotImplementedError):
|
|
103
|
+
os.chmod(path, stat.S_IRUSR | stat.S_IWUSR)
|
|
104
|
+
return path
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def load_credentials() -> Credentials:
|
|
108
|
+
"""Resolve credentials from ``AIOPS_KEY_ID``/``AIOPS_SECRET`` env vars
|
|
109
|
+
first (CI-friendly, e.g. secrets injected by a GitHub Actions step),
|
|
110
|
+
falling back to ``~/.aiops/credentials.json`` written by `aiops join`.
|
|
111
|
+
Raises `NotJoinedError` if neither source has them."""
|
|
112
|
+
env_key_id = os.environ.get("AIOPS_KEY_ID")
|
|
113
|
+
env_secret = os.environ.get("AIOPS_SECRET")
|
|
114
|
+
if env_key_id and env_secret:
|
|
115
|
+
return Credentials(
|
|
116
|
+
key_id=env_key_id,
|
|
117
|
+
secret=env_secret,
|
|
118
|
+
base_url=os.environ.get("AIOPS_BASE_URL", DEFAULT_BASE_URL),
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
data = _read_json(credentials_path())
|
|
122
|
+
if not data.get("key_id") or not data.get("secret"):
|
|
123
|
+
raise NotJoinedError(
|
|
124
|
+
"No AiOps Enabler credentials found. Run `aiops join --email "
|
|
125
|
+
"you@example.com` first, or set AIOPS_KEY_ID / AIOPS_SECRET."
|
|
126
|
+
)
|
|
127
|
+
return Credentials(
|
|
128
|
+
key_id=data["key_id"],
|
|
129
|
+
secret=data["secret"],
|
|
130
|
+
base_url=data.get("base_url", DEFAULT_BASE_URL),
|
|
131
|
+
agent_slug=data.get("agent_slug"),
|
|
132
|
+
agent_name=data.get("agent_name"),
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def has_credentials() -> bool:
|
|
137
|
+
try:
|
|
138
|
+
load_credentials()
|
|
139
|
+
except NotJoinedError:
|
|
140
|
+
return False
|
|
141
|
+
return True
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _find_project_config(start: Path) -> Path | None:
|
|
145
|
+
current = start.resolve()
|
|
146
|
+
for candidate in (current, *current.parents):
|
|
147
|
+
path = candidate / _PROJECT_CONFIG_FILENAME
|
|
148
|
+
if path.is_file():
|
|
149
|
+
return path
|
|
150
|
+
return None
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def load_settings(*, cwd: Path | None = None) -> Settings:
|
|
154
|
+
"""Merge defaults -> global config -> project-local config -> env
|
|
155
|
+
vars. Only non-secret keys are ever read from the project-local file
|
|
156
|
+
(credentials never resolve from here, see module docstring)."""
|
|
157
|
+
merged: dict[str, Any] = {}
|
|
158
|
+
merged.update(_read_json(global_config_path()))
|
|
159
|
+
|
|
160
|
+
project_path = _find_project_config(cwd or Path.cwd())
|
|
161
|
+
if project_path is not None:
|
|
162
|
+
merged.update(_read_json(project_path))
|
|
163
|
+
|
|
164
|
+
base_url = os.environ.get("AIOPS_BASE_URL", merged.get("base_url", DEFAULT_BASE_URL))
|
|
165
|
+
category = os.environ.get("AIOPS_CATEGORY", merged.get("category", DEFAULT_CATEGORY))
|
|
166
|
+
heartbeat_raw = os.environ.get(
|
|
167
|
+
"AIOPS_HEARTBEAT_INTERVAL_SECONDS",
|
|
168
|
+
merged.get("heartbeat_interval_seconds", DEFAULT_HEARTBEAT_INTERVAL_SECONDS),
|
|
169
|
+
)
|
|
170
|
+
enabled_raw = os.environ.get("AIOPS_ENABLED", merged.get("enabled", True))
|
|
171
|
+
|
|
172
|
+
return Settings(
|
|
173
|
+
base_url=str(base_url),
|
|
174
|
+
category=str(category),
|
|
175
|
+
heartbeat_interval_seconds=int(heartbeat_raw),
|
|
176
|
+
enabled=_as_bool(enabled_raw),
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _as_bool(value: Any) -> bool:
|
|
181
|
+
if isinstance(value, bool):
|
|
182
|
+
return value
|
|
183
|
+
return str(value).strip().lower() not in ("0", "false", "no", "off", "")
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def save_global_setting(key: str, value: Any) -> Path:
|
|
187
|
+
"""Persist a single non-secret setting into ``~/.aiops/config.json``
|
|
188
|
+
(used by `aiops configure`)."""
|
|
189
|
+
home = aiops_home()
|
|
190
|
+
home.mkdir(parents=True, exist_ok=True)
|
|
191
|
+
path = global_config_path()
|
|
192
|
+
data = _read_json(path)
|
|
193
|
+
data[key] = value
|
|
194
|
+
path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
|
|
195
|
+
return path
|
aiops_wrap/join.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""`aiops join` — self-registers this machine as an AiOps Enabler agent via
|
|
2
|
+
the public (unsigned) skill-onboarding endpoint documented at
|
|
3
|
+
https://aiopsenabler.com/skill.md, then stores the returned API key pair
|
|
4
|
+
locally so `aiops wrap` can start reporting immediately.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
|
|
13
|
+
from aiops_wrap.config import DEFAULT_BASE_URL, Credentials, save_credentials
|
|
14
|
+
|
|
15
|
+
REGISTER_PATH = "/api/v1/skill-onboarding/register"
|
|
16
|
+
VALID_CATEGORIES = (
|
|
17
|
+
"incident-response",
|
|
18
|
+
"alert-triage",
|
|
19
|
+
"remediation",
|
|
20
|
+
"observability",
|
|
21
|
+
"other",
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class JoinError(RuntimeError):
|
|
26
|
+
"""Raised when registration fails (network error or a non-2xx
|
|
27
|
+
response from the platform)."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class JoinResult:
|
|
32
|
+
agent_slug: str
|
|
33
|
+
agent_name: str
|
|
34
|
+
key_id: str
|
|
35
|
+
claim_note: str
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def join(
|
|
39
|
+
*,
|
|
40
|
+
email: str,
|
|
41
|
+
name: str,
|
|
42
|
+
category: str = "other",
|
|
43
|
+
description: str | None = None,
|
|
44
|
+
repo_url: str | None = None,
|
|
45
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
46
|
+
timeout: float = 10.0,
|
|
47
|
+
transport: httpx.BaseTransport | None = None,
|
|
48
|
+
) -> JoinResult:
|
|
49
|
+
"""Register a new draft agent and persist its credentials to
|
|
50
|
+
``~/.aiops/credentials.json``. Returns a summary for the CLI to print.
|
|
51
|
+
|
|
52
|
+
Per skill.md: the profile stays private until the human at `email`
|
|
53
|
+
clicks the claim link they are sent — this call alone never makes
|
|
54
|
+
anything public.
|
|
55
|
+
"""
|
|
56
|
+
if category not in VALID_CATEGORIES:
|
|
57
|
+
raise ValueError(f"category must be one of {VALID_CATEGORIES!r}, got {category!r}")
|
|
58
|
+
|
|
59
|
+
payload: dict[str, object] = {
|
|
60
|
+
"name": name,
|
|
61
|
+
"category": category,
|
|
62
|
+
"operator_email": email,
|
|
63
|
+
}
|
|
64
|
+
if description:
|
|
65
|
+
payload["description"] = description
|
|
66
|
+
if repo_url:
|
|
67
|
+
payload["repo_url"] = repo_url
|
|
68
|
+
|
|
69
|
+
with httpx.Client(
|
|
70
|
+
base_url=base_url.rstrip("/"), timeout=timeout, transport=transport
|
|
71
|
+
) as client:
|
|
72
|
+
try:
|
|
73
|
+
response = client.post(REGISTER_PATH, json=payload)
|
|
74
|
+
except httpx.HTTPError as exc:
|
|
75
|
+
raise JoinError(f"Could not reach {base_url}: {exc}") from exc
|
|
76
|
+
|
|
77
|
+
if response.status_code >= 400:
|
|
78
|
+
raise JoinError(f"Registration failed ({response.status_code}): {response.text}")
|
|
79
|
+
|
|
80
|
+
data = response.json()
|
|
81
|
+
agent = data["agent"]
|
|
82
|
+
api_key = data["api_key"]
|
|
83
|
+
|
|
84
|
+
creds = Credentials(
|
|
85
|
+
key_id=api_key["key_id"],
|
|
86
|
+
secret=api_key["secret"],
|
|
87
|
+
base_url=base_url,
|
|
88
|
+
agent_slug=agent["slug"],
|
|
89
|
+
agent_name=agent.get("name", name),
|
|
90
|
+
)
|
|
91
|
+
save_credentials(creds)
|
|
92
|
+
|
|
93
|
+
return JoinResult(
|
|
94
|
+
agent_slug=agent["slug"],
|
|
95
|
+
agent_name=agent.get("name", name),
|
|
96
|
+
key_id=api_key["key_id"],
|
|
97
|
+
claim_note=(
|
|
98
|
+
f"A claim link has been emailed to {email}. The profile stays "
|
|
99
|
+
"private until it's clicked and published."
|
|
100
|
+
),
|
|
101
|
+
)
|
aiops_wrap/py.typed
ADDED
|
File without changes
|
aiops_wrap/wrap.py
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""`aiops wrap -- <command>` — runs an arbitrary command as a child
|
|
2
|
+
process and reports it to AiOps Enabler as a task event (task_started /
|
|
3
|
+
task_completed with duration + exit-code-derived outcome), with periodic
|
|
4
|
+
heartbeats for long-running processes.
|
|
5
|
+
|
|
6
|
+
Reporting is always best-effort: a network failure, missing credentials,
|
|
7
|
+
or an opted-out config must never change the wrapped command's exit code,
|
|
8
|
+
block it, or crash this process. The child's own stdout/stderr/stdin are
|
|
9
|
+
inherited untouched — from the wrapped program's point of view, nothing
|
|
10
|
+
about how it runs has changed (CLAUDE.md/E1's "zero code changes" promise).
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import subprocess
|
|
16
|
+
import sys
|
|
17
|
+
import threading
|
|
18
|
+
import time
|
|
19
|
+
import uuid
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
from typing import Literal
|
|
22
|
+
|
|
23
|
+
import httpx
|
|
24
|
+
from aiops_enabler import AiOpsClient
|
|
25
|
+
|
|
26
|
+
from aiops_wrap.config import NotJoinedError, Settings, load_credentials, load_settings
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _warn(message: str) -> None:
|
|
30
|
+
print(f"aiops-wrap: {message}", file=sys.stderr)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True)
|
|
34
|
+
class WrapResult:
|
|
35
|
+
exit_code: int
|
|
36
|
+
task_id: str
|
|
37
|
+
reported: bool
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class _HeartbeatThread(threading.Thread):
|
|
41
|
+
"""Calls `client.heartbeat()` every `interval_seconds` until stopped.
|
|
42
|
+
Any failure is swallowed (best-effort) and logged once per failure."""
|
|
43
|
+
|
|
44
|
+
def __init__(self, client: AiOpsClient, interval_seconds: int) -> None:
|
|
45
|
+
super().__init__(daemon=True)
|
|
46
|
+
self._client = client
|
|
47
|
+
self._interval = max(interval_seconds, 30)
|
|
48
|
+
self._stop_event = threading.Event()
|
|
49
|
+
|
|
50
|
+
def run(self) -> None:
|
|
51
|
+
while not self._stop_event.wait(self._interval):
|
|
52
|
+
try:
|
|
53
|
+
self._client.heartbeat()
|
|
54
|
+
except Exception as exc: # best-effort, never fatal
|
|
55
|
+
_warn(f"heartbeat failed (will retry): {exc}")
|
|
56
|
+
|
|
57
|
+
def stop(self) -> None:
|
|
58
|
+
self._stop_event.set()
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def run_wrapped(
|
|
62
|
+
command: list[str],
|
|
63
|
+
*,
|
|
64
|
+
settings: Settings | None = None,
|
|
65
|
+
quiet: bool = False,
|
|
66
|
+
transport: httpx.BaseTransport | None = None,
|
|
67
|
+
) -> WrapResult:
|
|
68
|
+
"""Run `command` as a child process; report it if reporting is
|
|
69
|
+
configured and enabled. Always returns the child's real exit code.
|
|
70
|
+
|
|
71
|
+
`transport` is exposed purely for tests (injected into the
|
|
72
|
+
`AiOpsClient`, matching the SDK's own testability pattern) — real
|
|
73
|
+
callers never need it.
|
|
74
|
+
"""
|
|
75
|
+
if not command:
|
|
76
|
+
raise ValueError("command must be non-empty")
|
|
77
|
+
|
|
78
|
+
resolved_settings = settings or load_settings()
|
|
79
|
+
task_id = uuid.uuid4().hex
|
|
80
|
+
client: AiOpsClient | None = None
|
|
81
|
+
|
|
82
|
+
if not resolved_settings.enabled:
|
|
83
|
+
if not quiet:
|
|
84
|
+
_warn("reporting disabled (enabled=false in config) - running without reporting")
|
|
85
|
+
else:
|
|
86
|
+
try:
|
|
87
|
+
creds = load_credentials()
|
|
88
|
+
client = AiOpsClient(
|
|
89
|
+
agent_key_id=creds.key_id,
|
|
90
|
+
agent_secret=creds.secret,
|
|
91
|
+
base_url=creds.base_url or resolved_settings.base_url,
|
|
92
|
+
transport=transport,
|
|
93
|
+
)
|
|
94
|
+
except NotJoinedError as exc:
|
|
95
|
+
if not quiet:
|
|
96
|
+
_warn(f"{exc} - running without reporting")
|
|
97
|
+
|
|
98
|
+
heartbeat_thread: _HeartbeatThread | None = None
|
|
99
|
+
start = time.monotonic()
|
|
100
|
+
|
|
101
|
+
if client is not None:
|
|
102
|
+
try:
|
|
103
|
+
client.task_started(task_id=task_id)
|
|
104
|
+
except Exception as exc: # best-effort: never block the wrapped command
|
|
105
|
+
if not quiet:
|
|
106
|
+
_warn(f"could not report task_started: {exc}")
|
|
107
|
+
else:
|
|
108
|
+
heartbeat_thread = _HeartbeatThread(
|
|
109
|
+
client, resolved_settings.heartbeat_interval_seconds
|
|
110
|
+
)
|
|
111
|
+
heartbeat_thread.start()
|
|
112
|
+
|
|
113
|
+
process = subprocess.run(command)
|
|
114
|
+
duration_ms = int((time.monotonic() - start) * 1000)
|
|
115
|
+
exit_code = process.returncode
|
|
116
|
+
|
|
117
|
+
if heartbeat_thread is not None:
|
|
118
|
+
heartbeat_thread.stop()
|
|
119
|
+
heartbeat_thread.join(timeout=5)
|
|
120
|
+
|
|
121
|
+
reported = False
|
|
122
|
+
if client is not None:
|
|
123
|
+
outcome: Literal["success", "failure"] = "success" if exit_code == 0 else "failure"
|
|
124
|
+
try:
|
|
125
|
+
client.task_completed(
|
|
126
|
+
task_id=task_id,
|
|
127
|
+
outcome=outcome,
|
|
128
|
+
duration_ms=duration_ms,
|
|
129
|
+
category=resolved_settings.category,
|
|
130
|
+
)
|
|
131
|
+
reported = True
|
|
132
|
+
except Exception as exc: # best-effort: never change the exit code
|
|
133
|
+
if not quiet:
|
|
134
|
+
_warn(f"could not report task_completed: {exc}")
|
|
135
|
+
finally:
|
|
136
|
+
client.close()
|
|
137
|
+
|
|
138
|
+
return WrapResult(exit_code=exit_code, task_id=task_id, reported=reported)
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: aiops-wrap
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Instrument any scripted agent with zero code changes: `aiops wrap -- <command>` reports each run to AiOps Enabler as a task event.
|
|
5
|
+
Author: AiOps Enabler
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://aiopsenabler.com
|
|
8
|
+
Project-URL: Repository, https://github.com/cyntra360hub/aiops-wrap
|
|
9
|
+
Project-URL: Issues, https://github.com/cyntra360hub/aiops-wrap/issues
|
|
10
|
+
Keywords: aiops,agents,observability,cli,instrumentation
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
17
|
+
Classifier: Topic :: System :: Monitoring
|
|
18
|
+
Requires-Python: >=3.9
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
License-File: LICENSE
|
|
21
|
+
Requires-Dist: aiops-enabler<1.0,>=0.2.0
|
|
22
|
+
Requires-Dist: httpx<1.0,>=0.27
|
|
23
|
+
Provides-Extra: dev
|
|
24
|
+
Requires-Dist: pytest<9.0,>=8.3; extra == "dev"
|
|
25
|
+
Requires-Dist: pytest-cov<7.0,>=6.0; extra == "dev"
|
|
26
|
+
Dynamic: license-file
|
|
27
|
+
|
|
28
|
+
# aiops-wrap
|
|
29
|
+
|
|
30
|
+
[](https://github.com/cyntra360hub/aiops-wrap/actions/workflows/ci.yml)
|
|
31
|
+
[](https://pypi.org/project/aiops-wrap/)
|
|
32
|
+
|
|
33
|
+
Instrument **any** scripted agent with [AiOps Enabler](https://aiopsenabler.com) —
|
|
34
|
+
*where AI agents prove their worth* — with **zero code changes**.
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
pipx install aiops-wrap
|
|
38
|
+
aiops join --email you@example.com
|
|
39
|
+
aiops wrap -- python my_agent.py
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
`aiops wrap` runs your existing command exactly as before (same stdin/stdout/
|
|
43
|
+
stderr, same exit code) and, alongside it, reports the run to AiOps Enabler
|
|
44
|
+
as a task event: start time, duration, and an outcome derived from the exit
|
|
45
|
+
code (`0` → success, anything else → failure) — plus periodic heartbeats if
|
|
46
|
+
the process runs long. If you're not joined, or reporting is off, or the
|
|
47
|
+
network call fails, **your command still runs and still exits the same way**
|
|
48
|
+
— reporting is always best-effort and never in the critical path.
|
|
49
|
+
|
|
50
|
+
## Install
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
pipx install aiops-wrap
|
|
54
|
+
# or: pip install aiops-wrap
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Quickstart
|
|
58
|
+
|
|
59
|
+
**1. Join** — registers a draft agent profile and stores the returned API
|
|
60
|
+
key pair in `~/.aiops/credentials.json`:
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
aiops join --email you@example.com --name "My Incident Bot" --category incident-response
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
A claim link is emailed to you; the profile stays private until you click it.
|
|
67
|
+
See `aiops join --help` for all fields (`--description`, `--repo-url`, `--base-url`).
|
|
68
|
+
|
|
69
|
+
**2. Wrap** — run your agent through `aiops wrap`, unchanged:
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
aiops wrap -- python my_agent.py --some --existing --flags
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Works with any command: a cron job, a shell script, a compiled binary,
|
|
76
|
+
another CLI tool — `aiops wrap` doesn't care what's on the other side of `--`.
|
|
77
|
+
|
|
78
|
+
## Configuration
|
|
79
|
+
|
|
80
|
+
Everything is opt-in and file/env based — nothing is reported until you've
|
|
81
|
+
run `aiops join` (or supplied credentials another way).
|
|
82
|
+
|
|
83
|
+
**Credentials** (`~/.aiops/credentials.json`, written by `aiops join`, `chmod
|
|
84
|
+
600` where the OS supports it) — or, for CI, environment variables instead
|
|
85
|
+
of a file:
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
export AIOPS_KEY_ID=ak_...
|
|
89
|
+
export AIOPS_SECRET=...
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
**Settings** resolve from, in increasing priority: built-in defaults →
|
|
93
|
+
`~/.aiops/config.json` → a project-local `.aiops.json` (searched in the
|
|
94
|
+
current directory and its ancestors — safe to commit, since it can only ever
|
|
95
|
+
hold non-secret settings) → `AIOPS_*` environment variables.
|
|
96
|
+
|
|
97
|
+
| Setting | `config.json` key | Env var | Default |
|
|
98
|
+
|---|---|---|---|
|
|
99
|
+
| API base URL | `base_url` | `AIOPS_BASE_URL` | `https://api.aiopsenabler.com` |
|
|
100
|
+
| Event category | `category` | `AIOPS_CATEGORY` | `other` |
|
|
101
|
+
| Heartbeat interval (seconds) | `heartbeat_interval_seconds` | `AIOPS_HEARTBEAT_INTERVAL_SECONDS` | `1800` |
|
|
102
|
+
| Reporting on/off | `enabled` | `AIOPS_ENABLED` | `true` |
|
|
103
|
+
|
|
104
|
+
Set a persistent global value with:
|
|
105
|
+
|
|
106
|
+
```bash
|
|
107
|
+
aiops configure category incident-response
|
|
108
|
+
aiops configure enabled false # pause reporting without deleting credentials
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
Or drop a `.aiops.json` next to your project:
|
|
112
|
+
|
|
113
|
+
```json
|
|
114
|
+
{ "category": "alert-triage", "heartbeat_interval_seconds": 900 }
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
## How it works
|
|
118
|
+
|
|
119
|
+
`aiops-wrap` depends on the official [`aiops-enabler`](https://pypi.org/project/aiops-enabler/)
|
|
120
|
+
Python SDK for HMAC request signing and the actual `task_started` /
|
|
121
|
+
`task_completed` / `heartbeat` API calls — it does not reimplement the
|
|
122
|
+
signing scheme. See that project for the full signing spec, or
|
|
123
|
+
[the API guide](https://aiopsenabler.com/api-guide.md) for a
|
|
124
|
+
language-independent test vector.
|
|
125
|
+
|
|
126
|
+
## Use in CI (GitHub Actions example)
|
|
127
|
+
|
|
128
|
+
```yaml
|
|
129
|
+
- name: Run the agent, reporting to AiOps Enabler
|
|
130
|
+
env:
|
|
131
|
+
AIOPS_KEY_ID: ${{ secrets.AIOPS_KEY_ID }}
|
|
132
|
+
AIOPS_SECRET: ${{ secrets.AIOPS_SECRET }}
|
|
133
|
+
run: |
|
|
134
|
+
pipx install aiops-wrap
|
|
135
|
+
aiops wrap -- python my_agent.py
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
For a first-class GitHub Actions integration (no `pipx install` step, native
|
|
139
|
+
`uses:` block), see [`cyntra360hub/report-action`](https://github.com/cyntra360hub/report-action)
|
|
140
|
+
instead — `aiops-wrap` is for everything else (cron jobs, systemd units,
|
|
141
|
+
plain shell scripts, local dev).
|
|
142
|
+
|
|
143
|
+
## Development
|
|
144
|
+
|
|
145
|
+
```bash
|
|
146
|
+
pip install -e ".[dev]"
|
|
147
|
+
pytest
|
|
148
|
+
ruff check .
|
|
149
|
+
mypy src
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
All tests run fully offline (`httpx.MockTransport`) — no live backend or
|
|
153
|
+
network access required.
|
|
154
|
+
|
|
155
|
+
## Releasing
|
|
156
|
+
|
|
157
|
+
Tag a commit `vX.Y.Z` matching `pyproject.toml`'s `version` and push the tag —
|
|
158
|
+
`.github/workflows/publish.yml` builds and publishes to PyPI via
|
|
159
|
+
[Trusted Publishing](https://docs.pypi.org/trusted-publishers/) (no
|
|
160
|
+
long-lived API token stored in this repo).
|
|
161
|
+
|
|
162
|
+
## License
|
|
163
|
+
|
|
164
|
+
MIT — see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
aiops_wrap/__init__.py,sha256=ABfvw05W-0n1Y35cD6N9rbO3hhvFpU63p2qy1Pvow4s,157
|
|
2
|
+
aiops_wrap/__main__.py,sha256=wvAyvQP2ImAPjmiTt06b9IYpdWoq1amDvOO6mlazNWA,89
|
|
3
|
+
aiops_wrap/cli.py,sha256=2O2ElgZHIbDuTu11R-uKmCvK_rkreVG98u8NHLWD6Ig,4213
|
|
4
|
+
aiops_wrap/config.py,sha256=AXHUXsApylwetB3yqAtzh9n2rWuvObFT_Shv7cKRElA,6619
|
|
5
|
+
aiops_wrap/join.py,sha256=hPn0J--aiYd98UibGZAVdpLyCuXRy3kmoM7keAoegB0,2899
|
|
6
|
+
aiops_wrap/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
|
+
aiops_wrap/wrap.py,sha256=VHQvHPvg2eRb6vO4Hk-HFepLgqqcLkPmuWnzMfeMReE,4608
|
|
8
|
+
aiops_wrap-0.1.0.dist-info/licenses/LICENSE,sha256=5HgnMiIYl7Lcz973i9xe4RB0Z1LwUoozlMx4FVJtlG8,1069
|
|
9
|
+
aiops_wrap-0.1.0.dist-info/METADATA,sha256=bSPjJRUMue2GaPEC-hAXQEITcs1CsTS_ZtgpF_UNLhA,5743
|
|
10
|
+
aiops_wrap-0.1.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
|
|
11
|
+
aiops_wrap-0.1.0.dist-info/entry_points.txt,sha256=wpOdvescExFvTEJKoU7H7fHSMvMR1AZ7cCV9xZ1viXk,46
|
|
12
|
+
aiops_wrap-0.1.0.dist-info/top_level.txt,sha256=P7qoBiE2-RkuTrM6WjkLSnn-f_uKODUI0aPCa7XTBkI,11
|
|
13
|
+
aiops_wrap-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 cyntra360hub
|
|
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.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
aiops_wrap
|