stackgen-sdk 0.1.1__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.
- stackgen/__init__.py +52 -0
- stackgen/_http.py +96 -0
- stackgen/_version.py +24 -0
- stackgen/aiden/__init__.py +9 -0
- stackgen/aiden/namespace.py +26 -0
- stackgen/aiden/runs.py +36 -0
- stackgen/aiden/webhook.py +185 -0
- stackgen/cli.py +33 -0
- stackgen/client.py +31 -0
- stackgen/config.py +88 -0
- stackgen/errors.py +41 -0
- stackgen/generated/.openapi-generator/FILES +141 -0
- stackgen/generated/.openapi-generator/VERSION +1 -0
- stackgen/generated/.openapi-generator-ignore +23 -0
- stackgen/generated/__init__.py +131 -0
- stackgen/generated/api/__init__.py +6 -0
- stackgen/generated/api/aiden_api.py +1806 -0
- stackgen/generated/api/sre_api.py +1618 -0
- stackgen/generated/api_client.py +801 -0
- stackgen/generated/api_response.py +21 -0
- stackgen/generated/configuration.py +600 -0
- stackgen/generated/exceptions.py +216 -0
- stackgen/generated/models/__init__.py +57 -0
- stackgen/generated/models/alert_analysis_status.py +40 -0
- stackgen/generated/models/alert_attention.py +39 -0
- stackgen/generated/models/alert_categorization_summary.py +97 -0
- stackgen/generated/models/alert_role.py +38 -0
- stackgen/generated/models/alert_sort_by.py +37 -0
- stackgen/generated/models/alert_status.py +38 -0
- stackgen/generated/models/alert_summary.py +95 -0
- stackgen/generated/models/alert_sync_run_status.py +38 -0
- stackgen/generated/models/alert_v1.py +213 -0
- stackgen/generated/models/artifact_info.py +98 -0
- stackgen/generated/models/error_response.py +91 -0
- stackgen/generated/models/investigate_alert_request.py +91 -0
- stackgen/generated/models/investigate_response.py +91 -0
- stackgen/generated/models/investigation.py +167 -0
- stackgen/generated/models/investigation_evidence.py +114 -0
- stackgen/generated/models/investigation_evidence_kind.py +44 -0
- stackgen/generated/models/investigation_evidence_source.py +39 -0
- stackgen/generated/models/investigation_hypothesis.py +112 -0
- stackgen/generated/models/investigation_plain_summary.py +104 -0
- stackgen/generated/models/investigation_prior_incident.py +109 -0
- stackgen/generated/models/investigation_recommended_next_step.py +101 -0
- stackgen/generated/models/investigation_ref.py +118 -0
- stackgen/generated/models/investigation_status.py +42 -0
- stackgen/generated/models/investigation_structured_hypothesis_entry.py +115 -0
- stackgen/generated/models/investigation_structured_limitation_entry.py +120 -0
- stackgen/generated/models/investigation_structured_rca.py +139 -0
- stackgen/generated/models/investigation_triage_metadata.py +134 -0
- stackgen/generated/models/json_error.py +91 -0
- stackgen/generated/models/list_alerts_response.py +119 -0
- stackgen/generated/models/list_investigations_response.py +101 -0
- stackgen/generated/models/pagination.py +91 -0
- stackgen/generated/models/schedule_run_status.py +39 -0
- stackgen/generated/models/schedule_target_type.py +38 -0
- stackgen/generated/models/session.py +141 -0
- stackgen/generated/models/session_responder_kind.py +40 -0
- stackgen/generated/models/signal_severity.py +40 -0
- stackgen/generated/models/sync_response.py +136 -0
- stackgen/generated/models/trigger_webhook202_response.py +98 -0
- stackgen/generated/models/webhook_run.py +129 -0
- stackgen/generated/models/webhook_run_detail.py +136 -0
- stackgen/generated/models/webhook_run_list_response.py +97 -0
- stackgen/generated/rest.py +258 -0
- stackgen/sre/__init__.py +5 -0
- stackgen/sre/namespace.py +57 -0
- stackgen/vault/__init__.py +5 -0
- stackgen/vault/namespace.py +17 -0
- stackgen_sdk-0.1.1.dist-info/METADATA +60 -0
- stackgen_sdk-0.1.1.dist-info/RECORD +73 -0
- stackgen_sdk-0.1.1.dist-info/WHEEL +4 -0
- stackgen_sdk-0.1.1.dist-info/entry_points.txt +2 -0
stackgen/__init__.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""StackGen Python SDK.
|
|
2
|
+
|
|
3
|
+
Public entrypoint::
|
|
4
|
+
|
|
5
|
+
from stackgen import StackgenClient, StackgenConfig
|
|
6
|
+
|
|
7
|
+
client = StackgenClient.from_env()
|
|
8
|
+
result = client.aiden.run_webhook_and_download_report(payload)
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from stackgen.client import StackgenClient
|
|
14
|
+
from stackgen.config import StackgenConfig
|
|
15
|
+
from stackgen.errors import HttpError, NotAllowlistedError, StackgenError, TimeoutError
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"HttpError",
|
|
19
|
+
"NotAllowlistedError",
|
|
20
|
+
"StackgenClient",
|
|
21
|
+
"StackgenConfig",
|
|
22
|
+
"StackgenError",
|
|
23
|
+
"TimeoutError",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _resolve_version() -> str:
|
|
28
|
+
try:
|
|
29
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
30
|
+
except ImportError: # pragma: no cover
|
|
31
|
+
from importlib_metadata import PackageNotFoundError, version # type: ignore
|
|
32
|
+
|
|
33
|
+
try:
|
|
34
|
+
return version("stackgen-sdk")
|
|
35
|
+
except PackageNotFoundError:
|
|
36
|
+
try:
|
|
37
|
+
return version("stackgen")
|
|
38
|
+
except PackageNotFoundError:
|
|
39
|
+
try:
|
|
40
|
+
from stackgen._version import __version__ as vcs_version
|
|
41
|
+
|
|
42
|
+
return vcs_version
|
|
43
|
+
except ImportError:
|
|
44
|
+
try:
|
|
45
|
+
from stackgen._version import version as vcs_version
|
|
46
|
+
|
|
47
|
+
return vcs_version
|
|
48
|
+
except ImportError:
|
|
49
|
+
return "0.0.0"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
__version__ = _resolve_version()
|
stackgen/_http.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Shared HTTP helpers (stdlib)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import ssl
|
|
7
|
+
import time
|
|
8
|
+
import urllib.error
|
|
9
|
+
import urllib.parse
|
|
10
|
+
import urllib.request
|
|
11
|
+
from typing import Any, Callable
|
|
12
|
+
|
|
13
|
+
from stackgen.errors import HttpError, TimeoutError
|
|
14
|
+
|
|
15
|
+
_RETRYABLE = {429, 502, 503, 504}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def query(org_id: str) -> str:
|
|
19
|
+
if not org_id:
|
|
20
|
+
return ""
|
|
21
|
+
return "?" + urllib.parse.urlencode({"orgId": org_id})
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def request(
|
|
25
|
+
url: str,
|
|
26
|
+
token: str,
|
|
27
|
+
*,
|
|
28
|
+
method: str = "GET",
|
|
29
|
+
body: bytes | None = None,
|
|
30
|
+
content_type: str | None = None,
|
|
31
|
+
expect_json: bool = True,
|
|
32
|
+
retries: int = 3,
|
|
33
|
+
) -> Any:
|
|
34
|
+
"""Bearer HTTP call with bounded retries on transient GET failures."""
|
|
35
|
+
headers = {"Authorization": f"Bearer {token}"}
|
|
36
|
+
if content_type:
|
|
37
|
+
headers["Content-Type"] = content_type
|
|
38
|
+
|
|
39
|
+
attempt = 0
|
|
40
|
+
while True:
|
|
41
|
+
attempt += 1
|
|
42
|
+
req = urllib.request.Request(url, data=body, headers=headers, method=method)
|
|
43
|
+
try:
|
|
44
|
+
with urllib.request.urlopen(req, context=ssl.create_default_context()) as resp:
|
|
45
|
+
raw = resp.read()
|
|
46
|
+
if not expect_json:
|
|
47
|
+
return raw
|
|
48
|
+
if not raw:
|
|
49
|
+
return {}
|
|
50
|
+
return json.loads(raw.decode("utf-8"))
|
|
51
|
+
except urllib.error.HTTPError as err:
|
|
52
|
+
detail = err.read().decode("utf-8", errors="replace")
|
|
53
|
+
if method == "GET" and err.code in _RETRYABLE and attempt < retries:
|
|
54
|
+
time.sleep(min(2**attempt, 8))
|
|
55
|
+
continue
|
|
56
|
+
raise HttpError(
|
|
57
|
+
f"{method} {url} -> HTTP {err.code}: {detail}",
|
|
58
|
+
status_code=err.code,
|
|
59
|
+
) from err
|
|
60
|
+
except urllib.error.URLError as err:
|
|
61
|
+
if method == "GET" and attempt < retries:
|
|
62
|
+
time.sleep(min(2**attempt, 8))
|
|
63
|
+
continue
|
|
64
|
+
raise HttpError(f"{method} {url} -> {err}") from err
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def as_list(payload: Any) -> list[dict[str, Any]]:
|
|
68
|
+
if isinstance(payload, list):
|
|
69
|
+
return [item for item in payload if isinstance(item, dict)]
|
|
70
|
+
if isinstance(payload, dict):
|
|
71
|
+
items = payload.get("items")
|
|
72
|
+
if isinstance(items, list):
|
|
73
|
+
return [item for item in items if isinstance(item, dict)]
|
|
74
|
+
return []
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def text(value: Any) -> str:
|
|
78
|
+
if value is None:
|
|
79
|
+
return ""
|
|
80
|
+
return str(value).strip()
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def poll_until(
|
|
84
|
+
deadline: float,
|
|
85
|
+
interval: float,
|
|
86
|
+
tick: Callable[[], Any | None],
|
|
87
|
+
*,
|
|
88
|
+
label: str,
|
|
89
|
+
timeout_seconds: int,
|
|
90
|
+
) -> Any:
|
|
91
|
+
while time.monotonic() < deadline:
|
|
92
|
+
result = tick()
|
|
93
|
+
if result is not None:
|
|
94
|
+
return result
|
|
95
|
+
time.sleep(interval)
|
|
96
|
+
raise TimeoutError(label, timeout_seconds)
|
stackgen/_version.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# file generated by vcs-versioning
|
|
2
|
+
# don't change, don't track in version control
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"__version__",
|
|
7
|
+
"__version_tuple__",
|
|
8
|
+
"version",
|
|
9
|
+
"version_tuple",
|
|
10
|
+
"__commit_id__",
|
|
11
|
+
"commit_id",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
version: str
|
|
15
|
+
__version__: str
|
|
16
|
+
__version_tuple__: tuple[int | str, ...]
|
|
17
|
+
version_tuple: tuple[int | str, ...]
|
|
18
|
+
commit_id: str | None
|
|
19
|
+
__commit_id__: str | None
|
|
20
|
+
|
|
21
|
+
__version__ = version = '0.1.1'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 1, 1)
|
|
23
|
+
|
|
24
|
+
__commit_id__ = commit_id = None
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Aiden namespace — public methods for platform APIs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from stackgen.aiden.webhook import WebhookReportJourney, WebhookReportResult
|
|
6
|
+
from stackgen.config import StackgenConfig
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class AidenNamespace:
|
|
10
|
+
"""Aiden platform APIs (sessions, webhooks, artifacts)."""
|
|
11
|
+
|
|
12
|
+
def __init__(self, config: StackgenConfig) -> None:
|
|
13
|
+
self._config = config
|
|
14
|
+
self._webhook = WebhookReportJourney(config)
|
|
15
|
+
|
|
16
|
+
def run_webhook_and_download_report(
|
|
17
|
+
self,
|
|
18
|
+
payload: str,
|
|
19
|
+
*,
|
|
20
|
+
artifact_name: str | None = None,
|
|
21
|
+
output_path: str | None = None,
|
|
22
|
+
) -> WebhookReportResult:
|
|
23
|
+
"""Trigger a webhook, wait for the session artifact, download it."""
|
|
24
|
+
return self._webhook.run(
|
|
25
|
+
payload, artifact_name=artifact_name, output_path=output_path
|
|
26
|
+
)
|
stackgen/aiden/runs.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Pure helpers for webhook run selection (unit-testable without HTTP)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from stackgen import _http
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def pick_run(
|
|
11
|
+
runs: list[dict[str, Any]],
|
|
12
|
+
dispatch_run_id: str,
|
|
13
|
+
known_run_ids: frozenset[str],
|
|
14
|
+
) -> dict[str, Any] | None:
|
|
15
|
+
"""Choose the webhook run that corresponds to a new trigger.
|
|
16
|
+
|
|
17
|
+
Preference order:
|
|
18
|
+
1. Exact match on ``id`` / ``run_id`` to ``dispatch_run_id``
|
|
19
|
+
2. First run whose id is not in ``known_run_ids`` (new since pre-trigger snapshot)
|
|
20
|
+
3. If no known ids and runs exist, the first run (best-effort on older motherships)
|
|
21
|
+
"""
|
|
22
|
+
if dispatch_run_id:
|
|
23
|
+
for item in runs:
|
|
24
|
+
if dispatch_run_id in {
|
|
25
|
+
_http.text(item.get("id")),
|
|
26
|
+
_http.text(item.get("run_id")),
|
|
27
|
+
}:
|
|
28
|
+
return item
|
|
29
|
+
for item in runs:
|
|
30
|
+
if _http.text(item.get("id")) not in known_run_ids:
|
|
31
|
+
return item
|
|
32
|
+
if not runs:
|
|
33
|
+
return None
|
|
34
|
+
if not known_run_ids:
|
|
35
|
+
return runs[0]
|
|
36
|
+
return None
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"""Aiden webhook → session-report journey."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import time
|
|
7
|
+
import urllib.parse
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from stackgen import _http
|
|
13
|
+
from stackgen.aiden.runs import pick_run
|
|
14
|
+
from stackgen.config import StackgenConfig
|
|
15
|
+
from stackgen.errors import StackgenError, TimeoutError
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class WebhookReportResult:
|
|
20
|
+
"""Result of trigger → wait → download session-report."""
|
|
21
|
+
|
|
22
|
+
webhook_id: str
|
|
23
|
+
invocation_id: str
|
|
24
|
+
session_id: str
|
|
25
|
+
artifacts: tuple[dict[str, Any], ...]
|
|
26
|
+
output_path: Path
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class WebhookReportJourney:
|
|
30
|
+
"""Trigger a webhook, wait for the session artifact, and download it.
|
|
31
|
+
|
|
32
|
+
Uses ``webhook_token`` for POST trigger and ``api_token`` for poll/download.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
def __init__(self, config: StackgenConfig) -> None:
|
|
36
|
+
self._config = config
|
|
37
|
+
|
|
38
|
+
def run(
|
|
39
|
+
self,
|
|
40
|
+
payload: str,
|
|
41
|
+
*,
|
|
42
|
+
artifact_name: str | None = None,
|
|
43
|
+
output_path: str | None = None,
|
|
44
|
+
) -> WebhookReportResult:
|
|
45
|
+
cfg = self._config
|
|
46
|
+
if not cfg.webhook_token:
|
|
47
|
+
raise ValueError("webhook_token is required for run_webhook_and_download_report")
|
|
48
|
+
|
|
49
|
+
artifact = artifact_name or cfg.artifact_name
|
|
50
|
+
dest = output_path or cfg.output_path
|
|
51
|
+
|
|
52
|
+
known_run_ids: frozenset[str] = frozenset()
|
|
53
|
+
if cfg.webhook_id:
|
|
54
|
+
known_run_ids = frozenset(
|
|
55
|
+
_http.text(item.get("id"))
|
|
56
|
+
for item in self._list_runs(cfg.webhook_id)
|
|
57
|
+
if _http.text(item.get("id"))
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
trigger = self._trigger(payload)
|
|
61
|
+
webhook_id = _http.text(trigger.get("webhook_id")) or _http.text(cfg.webhook_id)
|
|
62
|
+
invocation_id = _http.text(trigger.get("invocation_id"))
|
|
63
|
+
session_id = _http.text(trigger.get("session_id"))
|
|
64
|
+
|
|
65
|
+
if not session_id and not webhook_id:
|
|
66
|
+
raise StackgenError(
|
|
67
|
+
"trigger 202 did not include session_id or webhook_id; "
|
|
68
|
+
"set webhook_id for older motherships. "
|
|
69
|
+
f"body={json.dumps(trigger)}"
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
if not session_id:
|
|
73
|
+
session_id = self._wait_for_session(
|
|
74
|
+
webhook_id,
|
|
75
|
+
invocation_id,
|
|
76
|
+
_http.text(trigger.get("run_id")),
|
|
77
|
+
known_run_ids,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
artifacts = self._wait_for_artifact(session_id, artifact)
|
|
81
|
+
path = self._download_artifact(session_id, artifact, dest)
|
|
82
|
+
return WebhookReportResult(
|
|
83
|
+
webhook_id=webhook_id,
|
|
84
|
+
invocation_id=invocation_id,
|
|
85
|
+
session_id=session_id,
|
|
86
|
+
artifacts=tuple(artifacts),
|
|
87
|
+
output_path=path,
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
def _trigger(self, payload: str) -> dict[str, Any]:
|
|
91
|
+
url = f"{self._config.aiden_url()}/api/v1/webhooks/trigger{_http.query(self._config.org_id)}"
|
|
92
|
+
data = _http.request(
|
|
93
|
+
url,
|
|
94
|
+
self._config.webhook_token,
|
|
95
|
+
method="POST",
|
|
96
|
+
body=payload.encode("utf-8"),
|
|
97
|
+
content_type="text/plain",
|
|
98
|
+
)
|
|
99
|
+
if not isinstance(data, dict):
|
|
100
|
+
raise StackgenError(f"unexpected trigger payload: {data!r}")
|
|
101
|
+
return data
|
|
102
|
+
|
|
103
|
+
def _list_runs(self, webhook_id: str) -> list[dict[str, Any]]:
|
|
104
|
+
url = (
|
|
105
|
+
f"{self._config.aiden_url()}/api/v1/webhooks/{webhook_id}/runs"
|
|
106
|
+
f"{_http.query(self._config.org_id)}"
|
|
107
|
+
)
|
|
108
|
+
return _http.as_list(_http.request(url, self._config.api_token))
|
|
109
|
+
|
|
110
|
+
def _get_run(self, webhook_id: str, run_id: str) -> dict[str, Any]:
|
|
111
|
+
url = (
|
|
112
|
+
f"{self._config.aiden_url()}/api/v1/webhooks/{webhook_id}/runs/{run_id}"
|
|
113
|
+
f"{_http.query(self._config.org_id)}"
|
|
114
|
+
)
|
|
115
|
+
data = _http.request(url, self._config.api_token)
|
|
116
|
+
if not isinstance(data, dict):
|
|
117
|
+
raise StackgenError(f"unexpected run payload: {data!r}")
|
|
118
|
+
return data
|
|
119
|
+
|
|
120
|
+
def _wait_for_session(
|
|
121
|
+
self,
|
|
122
|
+
webhook_id: str,
|
|
123
|
+
invocation_id: str,
|
|
124
|
+
dispatch_run_id: str,
|
|
125
|
+
known_run_ids: frozenset[str],
|
|
126
|
+
) -> str:
|
|
127
|
+
cfg = self._config
|
|
128
|
+
deadline = time.monotonic() + cfg.timeout_seconds
|
|
129
|
+
run_id_hint = invocation_id or dispatch_run_id
|
|
130
|
+
while time.monotonic() < deadline:
|
|
131
|
+
run: dict[str, Any] | None = None
|
|
132
|
+
if run_id_hint:
|
|
133
|
+
try:
|
|
134
|
+
run = self._get_run(webhook_id, run_id_hint)
|
|
135
|
+
except StackgenError:
|
|
136
|
+
run = None
|
|
137
|
+
if run is None:
|
|
138
|
+
run = pick_run(self._list_runs(webhook_id), dispatch_run_id, known_run_ids)
|
|
139
|
+
if run is None:
|
|
140
|
+
time.sleep(cfg.poll_interval_seconds)
|
|
141
|
+
continue
|
|
142
|
+
if not run_id_hint:
|
|
143
|
+
run_id_hint = _http.text(run.get("id")) or _http.text(run.get("run_id"))
|
|
144
|
+
status = _http.text(run.get("status")).casefold()
|
|
145
|
+
session_id = _http.text(run.get("session_id"))
|
|
146
|
+
if status in {"failed", "skipped"}:
|
|
147
|
+
raise StackgenError(
|
|
148
|
+
f"webhook invocation ended with status {status}: "
|
|
149
|
+
f"{run.get('error') or 'no error detail'}"
|
|
150
|
+
)
|
|
151
|
+
if session_id:
|
|
152
|
+
return session_id
|
|
153
|
+
time.sleep(cfg.poll_interval_seconds)
|
|
154
|
+
raise TimeoutError("webhook session", cfg.timeout_seconds)
|
|
155
|
+
|
|
156
|
+
def _wait_for_artifact(
|
|
157
|
+
self, session_id: str, artifact_name: str
|
|
158
|
+
) -> list[dict[str, Any]]:
|
|
159
|
+
cfg = self._config
|
|
160
|
+
deadline = time.monotonic() + cfg.timeout_seconds
|
|
161
|
+
url = (
|
|
162
|
+
f"{cfg.aiden_url()}/api/v1/sessions/{session_id}/artifacts"
|
|
163
|
+
f"{_http.query(cfg.org_id)}"
|
|
164
|
+
)
|
|
165
|
+
want = artifact_name.casefold()
|
|
166
|
+
artifacts: list[dict[str, Any]] = []
|
|
167
|
+
while time.monotonic() < deadline:
|
|
168
|
+
artifacts = _http.as_list(_http.request(url, cfg.api_token))
|
|
169
|
+
if any(_http.text(item.get("name")).casefold() == want for item in artifacts):
|
|
170
|
+
return artifacts
|
|
171
|
+
time.sleep(cfg.poll_interval_seconds)
|
|
172
|
+
raise TimeoutError(artifact_name, cfg.timeout_seconds)
|
|
173
|
+
|
|
174
|
+
def _download_artifact(
|
|
175
|
+
self, session_id: str, artifact_name: str, output_path: str
|
|
176
|
+
) -> Path:
|
|
177
|
+
encoded = urllib.parse.quote(artifact_name, safe="")
|
|
178
|
+
url = (
|
|
179
|
+
f"{self._config.aiden_url()}/api/v1/sessions/{session_id}/artifacts/"
|
|
180
|
+
f"{encoded}/download{_http.query(self._config.org_id)}"
|
|
181
|
+
)
|
|
182
|
+
raw = _http.request(url, self._config.api_token, expect_json=False)
|
|
183
|
+
dest = Path(output_path)
|
|
184
|
+
dest.write_bytes(raw)
|
|
185
|
+
return dest
|
stackgen/cli.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""CLI: trigger webhook and download session-report.md."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from stackgen.client import StackgenClient
|
|
9
|
+
from stackgen.config import StackgenConfig
|
|
10
|
+
from stackgen.errors import StackgenError
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def main(argv: list[str] | None = None) -> None:
|
|
14
|
+
args = list(sys.argv[1:] if argv is None else argv)
|
|
15
|
+
payload = "{}"
|
|
16
|
+
if args:
|
|
17
|
+
path = Path(args[0])
|
|
18
|
+
if not path.is_file():
|
|
19
|
+
print(f"error: payload file does not exist: {path}", file=sys.stderr)
|
|
20
|
+
sys.exit(1)
|
|
21
|
+
payload = path.read_text(encoding="utf-8")
|
|
22
|
+
try:
|
|
23
|
+
client = StackgenClient(StackgenConfig.from_env())
|
|
24
|
+
result = client.aiden.run_webhook_and_download_report(payload)
|
|
25
|
+
except (StackgenError, ValueError) as err:
|
|
26
|
+
print(f"error: {err}", file=sys.stderr)
|
|
27
|
+
sys.exit(1)
|
|
28
|
+
print(f"session_id={result.session_id}")
|
|
29
|
+
print(f"output_path={result.output_path}")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
if __name__ == "__main__":
|
|
33
|
+
main()
|
stackgen/client.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""StackgenClient — one entrypoint for Aiden, SRE, and Vault."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from stackgen.aiden import AidenNamespace
|
|
6
|
+
from stackgen.config import StackgenConfig
|
|
7
|
+
from stackgen.sre import SRENamespace
|
|
8
|
+
from stackgen.vault import VaultNamespace
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class StackgenClient:
|
|
12
|
+
"""Product SDK client.
|
|
13
|
+
|
|
14
|
+
Namespaces:
|
|
15
|
+
- ``aiden`` — platform automations, sessions, session-report download
|
|
16
|
+
- ``sre`` — alerts and investigations
|
|
17
|
+
- ``vault`` — secrets (allowlisted separately)
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
def __init__(self, config: StackgenConfig):
|
|
21
|
+
if not config.api_token:
|
|
22
|
+
raise ValueError("api_token is required")
|
|
23
|
+
self.config = config
|
|
24
|
+
self.aiden = AidenNamespace(config)
|
|
25
|
+
self.sre = SRENamespace(config)
|
|
26
|
+
self.vault = VaultNamespace(config)
|
|
27
|
+
|
|
28
|
+
@classmethod
|
|
29
|
+
def from_env(cls) -> StackgenClient:
|
|
30
|
+
"""Construct from environment variables (see StackgenConfig.from_env)."""
|
|
31
|
+
return cls(StackgenConfig.from_env())
|
stackgen/config.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Configuration for StackgenClient."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True)
|
|
10
|
+
class StackgenConfig:
|
|
11
|
+
"""Connection and auth settings for the StackGen mothership.
|
|
12
|
+
|
|
13
|
+
``base_url`` is the mothership host without a product suffix
|
|
14
|
+
(for example ``https://azure-eu.cloud.stackgen.com``). Aiden routes live
|
|
15
|
+
under ``/guild``; SRE under ``/app/sre``.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
base_url: str
|
|
19
|
+
api_token: str
|
|
20
|
+
webhook_token: str = ""
|
|
21
|
+
org_id: str = ""
|
|
22
|
+
webhook_id: str = ""
|
|
23
|
+
artifact_name: str = "session-report.md"
|
|
24
|
+
output_path: str = "session-report.md"
|
|
25
|
+
poll_interval_seconds: int = 5
|
|
26
|
+
timeout_seconds: int = 1800
|
|
27
|
+
|
|
28
|
+
def mothership(self) -> str:
|
|
29
|
+
"""Normalized mothership URL without trailing slash or ``/guild``."""
|
|
30
|
+
base = self.base_url.rstrip("/")
|
|
31
|
+
if base.endswith("/guild"):
|
|
32
|
+
return base[: -len("/guild")]
|
|
33
|
+
return base
|
|
34
|
+
|
|
35
|
+
def aiden_url(self) -> str:
|
|
36
|
+
"""HTTP prefix for Aiden APIs."""
|
|
37
|
+
return self.mothership() + "/guild"
|
|
38
|
+
|
|
39
|
+
def sre_url(self) -> str:
|
|
40
|
+
"""HTTP prefix for the SRE app."""
|
|
41
|
+
return self.mothership() + "/app/sre"
|
|
42
|
+
|
|
43
|
+
def vault_url(self) -> str:
|
|
44
|
+
"""HTTP prefix for Vault on the mothership edge."""
|
|
45
|
+
return self.mothership() + "/vault/v1"
|
|
46
|
+
|
|
47
|
+
@classmethod
|
|
48
|
+
def from_env(cls) -> StackgenConfig:
|
|
49
|
+
"""Build config from environment variables.
|
|
50
|
+
|
|
51
|
+
Recognized variables:
|
|
52
|
+
STACKGEN_URL / STACKGEN_BASE_URL
|
|
53
|
+
STACKGEN_TOKEN / STACKGEN_API_TOKEN
|
|
54
|
+
WEBHOOK_TOKEN
|
|
55
|
+
ORG_ID / STACKGEN_ORG_ID / STACKGEN_PROJECT
|
|
56
|
+
WEBHOOK_ID
|
|
57
|
+
ARTIFACT_NAME, OUTPUT_PATH, POLL_INTERVAL_SECONDS, TIMEOUT_SECONDS
|
|
58
|
+
"""
|
|
59
|
+
base = (
|
|
60
|
+
os.environ.get("STACKGEN_URL")
|
|
61
|
+
or os.environ.get("STACKGEN_BASE_URL")
|
|
62
|
+
or ""
|
|
63
|
+
).strip()
|
|
64
|
+
token = (
|
|
65
|
+
os.environ.get("STACKGEN_TOKEN")
|
|
66
|
+
or os.environ.get("STACKGEN_API_TOKEN")
|
|
67
|
+
or ""
|
|
68
|
+
).strip()
|
|
69
|
+
if not base:
|
|
70
|
+
raise ValueError("STACKGEN_URL (or STACKGEN_BASE_URL) is required")
|
|
71
|
+
if not token:
|
|
72
|
+
raise ValueError("STACKGEN_TOKEN (or STACKGEN_API_TOKEN) is required")
|
|
73
|
+
return cls(
|
|
74
|
+
base_url=base,
|
|
75
|
+
api_token=token,
|
|
76
|
+
webhook_token=os.environ.get("WEBHOOK_TOKEN", "").strip(),
|
|
77
|
+
org_id=(
|
|
78
|
+
os.environ.get("ORG_ID")
|
|
79
|
+
or os.environ.get("STACKGEN_ORG_ID")
|
|
80
|
+
or os.environ.get("STACKGEN_PROJECT")
|
|
81
|
+
or ""
|
|
82
|
+
).strip(),
|
|
83
|
+
webhook_id=os.environ.get("WEBHOOK_ID", "").strip(),
|
|
84
|
+
artifact_name=os.environ.get("ARTIFACT_NAME", "session-report.md"),
|
|
85
|
+
output_path=os.environ.get("OUTPUT_PATH", "session-report.md"),
|
|
86
|
+
poll_interval_seconds=int(os.environ.get("POLL_INTERVAL_SECONDS", "5")),
|
|
87
|
+
timeout_seconds=int(os.environ.get("TIMEOUT_SECONDS", "1800")),
|
|
88
|
+
)
|
stackgen/errors.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""SDK errors — typed hierarchy (fail closed, catch at boundaries)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class StackgenError(RuntimeError):
|
|
7
|
+
"""Base class for all StackGen SDK errors."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class HttpError(StackgenError):
|
|
11
|
+
"""An HTTP call to StackGen failed.
|
|
12
|
+
|
|
13
|
+
``status_code`` is set when the server returned an HTTP status; it is
|
|
14
|
+
``None`` for transport failures (DNS, connection reset, TLS).
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
def __init__(self, message: str, *, status_code: int | None = None) -> None:
|
|
18
|
+
self.status_code = status_code
|
|
19
|
+
super().__init__(message)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class TimeoutError(StackgenError):
|
|
23
|
+
"""A poll/wait loop exceeded the configured timeout."""
|
|
24
|
+
|
|
25
|
+
def __init__(self, label: str, timeout_seconds: int) -> None:
|
|
26
|
+
self.label = label
|
|
27
|
+
self.timeout_seconds = timeout_seconds
|
|
28
|
+
super().__init__(f"timed out waiting for {label} after {timeout_seconds}s")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class NotAllowlistedError(StackgenError):
|
|
32
|
+
"""Caller requested an operation that is not in the public SDK surface."""
|
|
33
|
+
|
|
34
|
+
def __init__(self, namespace: str, operation: str) -> None:
|
|
35
|
+
self.namespace = namespace
|
|
36
|
+
self.operation = operation
|
|
37
|
+
super().__init__(
|
|
38
|
+
f"{namespace}.{operation} is not allowlisted in this SDK release. "
|
|
39
|
+
"Upstream teams publish APIs via allowlists/external.yaml "
|
|
40
|
+
"(see docs/UPSTREAM.md)."
|
|
41
|
+
)
|