argus-cloud-optimizer 0.6.0__tar.gz → 0.6.1__tar.gz
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.
- {argus_cloud_optimizer-0.6.0/argus_cloud_optimizer.egg-info → argus_cloud_optimizer-0.6.1}/PKG-INFO +1 -1
- {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1/argus_cloud_optimizer.egg-info}/PKG-INFO +1 -1
- {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/argus_cloud_optimizer.egg-info/SOURCES.txt +9 -1
- {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/argus_cloud_optimizer.egg-info/top_level.txt +1 -0
- argus_cloud_optimizer-0.6.1/core/__version__.py +1 -0
- {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/docs/remediation/policies.md +5 -0
- {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/docs/roadmap.md +2 -0
- argus_cloud_optimizer-0.6.1/integrations/__init__.py +0 -0
- argus_cloud_optimizer-0.6.1/integrations/base.py +33 -0
- argus_cloud_optimizer-0.6.1/integrations/jira/__init__.py +0 -0
- argus_cloud_optimizer-0.6.1/integrations/jira/client.py +116 -0
- argus_cloud_optimizer-0.6.1/integrations/jira/formatter.py +281 -0
- argus_cloud_optimizer-0.6.1/integrations/jira/tracker.py +265 -0
- argus_cloud_optimizer-0.6.1/integrations/jira/webhook.py +32 -0
- {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/pyproject.toml +1 -1
- argus_cloud_optimizer-0.6.1/tests/test_packaging.py +48 -0
- argus_cloud_optimizer-0.6.0/core/__version__.py +0 -1
- {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/.env.example +0 -0
- {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/LICENSE +0 -0
- {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/MANIFEST.in +0 -0
- {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/README.md +0 -0
- {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/accounts.yaml.example +0 -0
- {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/adapters/__init__.py +0 -0
- {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/adapters/aws/__init__.py +0 -0
- {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/adapters/aws/adapter.py +0 -0
- {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/adapters/aws/auth.py +0 -0
- {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/adapters/aws/cloudtrail.py +0 -0
- {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/adapters/aws/cloudwatch.py +0 -0
- {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/adapters/aws/config.py +0 -0
- {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/adapters/aws/cost_explorer.py +0 -0
- {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/adapters/aws/resource_explorer.py +0 -0
- {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/adapters/aws/retry.py +0 -0
- {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/adapters/azure/__init__.py +0 -0
- {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/adapters/azure/activity_log.py +0 -0
- {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/adapters/azure/adapter.py +0 -0
- {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/adapters/azure/cost_management.py +0 -0
- {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/adapters/azure/monitor.py +0 -0
- {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/adapters/azure/resource_graph.py +0 -0
- {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/adapters/azure/retry.py +0 -0
- {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/adapters/base.py +0 -0
- {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/adapters/gcp/__init__.py +0 -0
- {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/adapters/gcp/adapter.py +0 -0
- {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/adapters/gcp/asset_inventory.py +0 -0
- {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/adapters/gcp/billing.py +0 -0
- {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/adapters/gcp/cloud_logging.py +0 -0
- {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/adapters/gcp/cloud_monitoring.py +0 -0
- {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/adapters/gcp/retry.py +0 -0
- {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/ai/__init__.py +0 -0
- {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/ai/anthropic.py +0 -0
- {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/ai/azure_openai.py +0 -0
- {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/ai/base.py +0 -0
- {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/ai/bedrock.py +0 -0
- {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/ai/vertexai.py +0 -0
- {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/argus_cloud_optimizer.egg-info/dependency_links.txt +0 -0
- {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/argus_cloud_optimizer.egg-info/entry_points.txt +0 -0
- {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/argus_cloud_optimizer.egg-info/requires.txt +0 -0
- {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/core/__init__.py +0 -0
- {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/core/agent/__init__.py +0 -0
- {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/core/agent/chat.py +0 -0
- {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/core/agent/loop.py +0 -0
- {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/core/agent/prompts.py +0 -0
- {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/core/config.py +0 -0
- {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/core/log.py +0 -0
- {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/core/models/__init__.py +0 -0
- {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/core/models/finding.py +0 -0
- {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/core/py.typed +0 -0
- {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/core/registry/__init__.py +0 -0
- {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/core/registry/aws.py +0 -0
- {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/core/registry/azure.py +0 -0
- {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/core/registry/factory.py +0 -0
- {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/core/registry/gcp.py +0 -0
- {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/core/registry/models.py +0 -0
- {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/core/registry/registry.py +0 -0
- {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/core/remediation/__init__.py +0 -0
- {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/core/remediation/audit.py +0 -0
- {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/core/remediation/engine.py +0 -0
- {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/core/remediation/loader.py +0 -0
- {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/core/remediation/models.py +0 -0
- {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/core/remediation/rightsizing.py +0 -0
- {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/core/remediation/validator.py +0 -0
- {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/core/reports/__init__.py +0 -0
- {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/core/reports/comparison.py +0 -0
- {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/core/reports/delivery.py +0 -0
- {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/core/reports/export.py +0 -0
- {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/core/reports/generator.py +0 -0
- {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/core/reports/html.py +0 -0
- {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/core/reports/multi_cloud.py +0 -0
- {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/core/secrets.py +0 -0
- {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/core/token_tracker.py +0 -0
- {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/core/validation.py +0 -0
- {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/deploy/aws/multi-account/hub/template.yaml +0 -0
- {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/deploy/aws/multi-account/spoke-role.yaml +0 -0
- {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/deploy/aws/single-account/template.yaml +0 -0
- {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/deploy/azure/function-app.bicep +0 -0
- {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/deploy/gcp/deploy.sh +0 -0
- {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/docs/blog/index.md +0 -0
- {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/docs/blog/posts/react-vs-rule-based.md +0 -0
- {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/docs/concepts/adapters.md +0 -0
- {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/docs/concepts/ai-providers.md +0 -0
- {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/docs/concepts/ai-reasoning.md +0 -0
- {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/docs/concepts/how-it-works.md +0 -0
- {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/docs/concepts/index.md +0 -0
- {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/docs/contributing/development.md +0 -0
- {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/docs/contributing/index.md +0 -0
- {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/docs/contributing/new-adapter.md +0 -0
- {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/docs/contributing/new-ai-provider.md +0 -0
- {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/docs/deployment/aws.md +0 -0
- {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/docs/deployment/azure.md +0 -0
- {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/docs/deployment/gcp.md +0 -0
- {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/docs/deployment/index.md +0 -0
- {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/docs/deployment/multi-account.md +0 -0
- {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/docs/getting-started/configuration.md +0 -0
- {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/docs/getting-started/first-scan.md +0 -0
- {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/docs/getting-started/index.md +0 -0
- {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/docs/getting-started/quickstart.md +0 -0
- {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/docs/index.md +0 -0
- {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/docs/reference/env-vars.md +0 -0
- {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/docs/reference/iam-permissions.md +0 -0
- {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/docs/reference/index.md +0 -0
- {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/docs/reference/report-schema.md +0 -0
- {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/docs/reference/resource-types.md +0 -0
- {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/docs/reference/security.md +0 -0
- {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/docs/reference/troubleshooting.md +0 -0
- {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/docs/remediation/cli.md +0 -0
- {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/docs/remediation/index.md +0 -0
- {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/docs/remediation/jira.md +0 -0
- {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/entrypoints/__init__.py +0 -0
- {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/entrypoints/_remediation.py +0 -0
- {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/entrypoints/aws_lambda.py +0 -0
- {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/entrypoints/azure_function.py +0 -0
- {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/entrypoints/cli.py +0 -0
- {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/entrypoints/cli_chat.py +0 -0
- {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/entrypoints/gcp_cloudrun.py +0 -0
- {argus_cloud_optimizer-0.6.0 → argus_cloud_optimizer-0.6.1}/setup.cfg +0 -0
|
@@ -120,4 +120,12 @@ entrypoints/aws_lambda.py
|
|
|
120
120
|
entrypoints/azure_function.py
|
|
121
121
|
entrypoints/cli.py
|
|
122
122
|
entrypoints/cli_chat.py
|
|
123
|
-
entrypoints/gcp_cloudrun.py
|
|
123
|
+
entrypoints/gcp_cloudrun.py
|
|
124
|
+
integrations/__init__.py
|
|
125
|
+
integrations/base.py
|
|
126
|
+
integrations/jira/__init__.py
|
|
127
|
+
integrations/jira/client.py
|
|
128
|
+
integrations/jira/formatter.py
|
|
129
|
+
integrations/jira/tracker.py
|
|
130
|
+
integrations/jira/webhook.py
|
|
131
|
+
tests/test_packaging.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.6.1"
|
|
@@ -2,6 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
Policies are YAML files that tell Argus which findings deserve a Jira ticket and what action to propose. Argus ships 13 bundled policies; you can add your own in `config/policies/`.
|
|
4
4
|
|
|
5
|
+
!!! note "Installed from PyPI?"
|
|
6
|
+
The bundled policies live in the repository's `config/policies/` folder and aren't included in the
|
|
7
|
+
`pip` package. Copy that folder (or `config/policies.example/`) into your project and set
|
|
8
|
+
`ARGUS_POLICY_DIR` to it.
|
|
9
|
+
|
|
5
10
|
!!! tip "Starter templates"
|
|
6
11
|
`config/policies.example/` contains three annotated example policies (EC2 stop, RDS resize, GCE stop) with inline comments explaining every field and production-safe exclude defaults. Copy them to `config/policies/` and edit to match your environment.
|
|
7
12
|
|
|
@@ -55,6 +55,8 @@ Small, known issues in what has shipped. Not scheduled; contributions welcome.
|
|
|
55
55
|
- **`BEDROCK_MAX_TOKENS` is ignored.** The Bedrock provider always requests up to 4096 output tokens.
|
|
56
56
|
- **AWS template doesn't expose `RESOURCE_EXPLORER_REGION`.** Deployed Lambdas assume the aggregator index is in `us-east-1` unless you add the variable by hand.
|
|
57
57
|
- **GCP deploy script doesn't enable the Vertex AI API** even though Vertex AI is the default provider on Cloud Run.
|
|
58
|
+
- **Bundled policies aren't in the PyPI package.** `pip install` users don't get `config/policies/`; copy it from the repository (or write your own) and point `ARGUS_POLICY_DIR` at it.
|
|
59
|
+
- **Docs deploys can race.** The docs workflow has no `concurrency` group, so merges landing seconds apart start parallel `mike deploy --push` runs to `gh-pages` and all but the first fail. Re-running the latest failed run fixes the site; adding a concurrency group to `.github/workflows/docs.yml` would prevent it.
|
|
58
60
|
|
|
59
61
|
---
|
|
60
62
|
|
|
File without changes
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
|
|
5
|
+
from core.remediation.models import ChangeProposal
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ChangeTracker(ABC):
|
|
9
|
+
"""
|
|
10
|
+
Integration-agnostic interface for tracking change proposals.
|
|
11
|
+
|
|
12
|
+
v1 implementation: JiraTracker (creates tickets, deduplicates via labels).
|
|
13
|
+
Future: ServiceNow, Linear, GitHub Issues — same interface, swap the class.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
@abstractmethod
|
|
17
|
+
def create(self, proposal: ChangeProposal) -> str:
|
|
18
|
+
"""
|
|
19
|
+
Record a change proposal and return its URL.
|
|
20
|
+
|
|
21
|
+
Implementations must:
|
|
22
|
+
- Be idempotent: same proposal on a second call returns the existing URL
|
|
23
|
+
- Update the existing record if the analysis has meaningfully changed
|
|
24
|
+
- Never raise on transient errors — log and re-raise as TrackerError
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
@abstractmethod
|
|
28
|
+
def close(self, url: str, reason: str) -> None:
|
|
29
|
+
"""Mark a tracked item as resolved (used when a resource is gone)."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class TrackerError(Exception):
|
|
33
|
+
"""Raised when a tracker operation fails after retries."""
|
|
File without changes
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Thin Jira REST API wrapper — no Jira SDK, plain requests.
|
|
3
|
+
|
|
4
|
+
Covers only the operations Argus needs:
|
|
5
|
+
- search issues by JQL
|
|
6
|
+
- create issue
|
|
7
|
+
- add comment
|
|
8
|
+
- get issue (fetch description for snapshot extraction)
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import logging
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
import requests
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
_TIMEOUT = 15 # seconds
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class JiraClient:
|
|
24
|
+
def __init__(self, base_url: str, email: str, api_token: str) -> None:
|
|
25
|
+
self._base = base_url.rstrip("/")
|
|
26
|
+
self._auth = (email, api_token)
|
|
27
|
+
self._headers = {
|
|
28
|
+
"Accept": "application/json",
|
|
29
|
+
"Content-Type": "application/json",
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
# ------------------------------------------------------------------
|
|
33
|
+
# Public API
|
|
34
|
+
# ------------------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
def search(self, jql: str, max_results: int = 10) -> list[dict[str, Any]]:
|
|
37
|
+
"""Return list of issue dicts matching the JQL query."""
|
|
38
|
+
resp = self._get(
|
|
39
|
+
"/rest/api/3/search",
|
|
40
|
+
params={
|
|
41
|
+
"jql": jql,
|
|
42
|
+
"maxResults": max_results,
|
|
43
|
+
"fields": "summary,status,description",
|
|
44
|
+
},
|
|
45
|
+
)
|
|
46
|
+
issues: list[dict[str, Any]] = resp.get("issues", [])
|
|
47
|
+
return issues
|
|
48
|
+
|
|
49
|
+
def create_issue(self, fields: dict[str, Any]) -> dict[str, Any]:
|
|
50
|
+
"""Create an issue and return the response dict (contains key + self URL)."""
|
|
51
|
+
result: dict[str, Any] = self._post(
|
|
52
|
+
"/rest/api/3/issue", json={"fields": fields}
|
|
53
|
+
)
|
|
54
|
+
return result
|
|
55
|
+
|
|
56
|
+
def add_comment(self, issue_key: str, body: str | dict[str, Any]) -> None:
|
|
57
|
+
"""Add a comment to an issue. body may be plain text or an ADF dict."""
|
|
58
|
+
adf_body = body if isinstance(body, dict) else _adf_paragraph(body)
|
|
59
|
+
self._post(
|
|
60
|
+
f"/rest/api/3/issue/{issue_key}/comment",
|
|
61
|
+
json={"body": adf_body},
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
def get_issue(self, issue_key: str) -> dict[str, Any]:
|
|
65
|
+
"""Fetch a single issue by key."""
|
|
66
|
+
return self._get(
|
|
67
|
+
f"/rest/api/3/issue/{issue_key}",
|
|
68
|
+
params={"fields": "summary,status,description"},
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
def issue_url(self, issue_key: str) -> str:
|
|
72
|
+
return f"{self._base}/browse/{issue_key}"
|
|
73
|
+
|
|
74
|
+
# ------------------------------------------------------------------
|
|
75
|
+
# HTTP helpers
|
|
76
|
+
# ------------------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
def _get(self, path: str, params: dict | None = None) -> dict[str, Any]:
|
|
79
|
+
resp = requests.get(
|
|
80
|
+
self._base + path,
|
|
81
|
+
auth=self._auth,
|
|
82
|
+
headers=self._headers,
|
|
83
|
+
params=params,
|
|
84
|
+
timeout=_TIMEOUT,
|
|
85
|
+
)
|
|
86
|
+
resp.raise_for_status()
|
|
87
|
+
result: dict[str, Any] = resp.json()
|
|
88
|
+
return result
|
|
89
|
+
|
|
90
|
+
def _post(self, path: str, json: dict | None = None) -> dict[str, Any]:
|
|
91
|
+
resp = requests.post(
|
|
92
|
+
self._base + path,
|
|
93
|
+
auth=self._auth,
|
|
94
|
+
headers=self._headers,
|
|
95
|
+
json=json,
|
|
96
|
+
timeout=_TIMEOUT,
|
|
97
|
+
)
|
|
98
|
+
resp.raise_for_status()
|
|
99
|
+
if resp.content:
|
|
100
|
+
result: dict[str, Any] = resp.json()
|
|
101
|
+
return result
|
|
102
|
+
return {}
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _adf_paragraph(text: str) -> dict[str, Any]:
|
|
106
|
+
"""Wrap plain text in Atlassian Document Format (ADF) for API v3."""
|
|
107
|
+
return {
|
|
108
|
+
"version": 1,
|
|
109
|
+
"type": "doc",
|
|
110
|
+
"content": [
|
|
111
|
+
{
|
|
112
|
+
"type": "paragraph",
|
|
113
|
+
"content": [{"type": "text", "text": text}],
|
|
114
|
+
}
|
|
115
|
+
],
|
|
116
|
+
}
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Converts a ChangeProposal into Jira issue fields (ADF format).
|
|
3
|
+
|
|
4
|
+
Description structure:
|
|
5
|
+
h2 Finding
|
|
6
|
+
resource details, cost, action
|
|
7
|
+
h2 Key Metrics
|
|
8
|
+
table of metrics_summary values
|
|
9
|
+
h2 Why Argus Flagged This
|
|
10
|
+
AI waste_reason
|
|
11
|
+
h2 Recommendation
|
|
12
|
+
AI recommendation
|
|
13
|
+
h2 Runbook
|
|
14
|
+
code block with CLI commands
|
|
15
|
+
h2 Policy
|
|
16
|
+
policy_id, weight, source file
|
|
17
|
+
h2 Full Report (if report_url given)
|
|
18
|
+
--- snapshot footer (machine-readable fingerprint for diff-on-update)
|
|
19
|
+
|
|
20
|
+
Snapshot fingerprint stored in description footer enables dedup and
|
|
21
|
+
change detection on subsequent scans without a separate DB.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import hashlib
|
|
27
|
+
import json
|
|
28
|
+
from datetime import date
|
|
29
|
+
from typing import Any
|
|
30
|
+
|
|
31
|
+
from core.remediation.models import ChangeProposal
|
|
32
|
+
|
|
33
|
+
_SNAPSHOT_MARKER = "<!-- argus-snapshot:"
|
|
34
|
+
_SNAPSHOT_END = "-->"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def build_issue_fields(
|
|
38
|
+
proposal: ChangeProposal,
|
|
39
|
+
*,
|
|
40
|
+
project: str,
|
|
41
|
+
issue_type: str,
|
|
42
|
+
default_labels: list[str],
|
|
43
|
+
priority_map: dict[str, str],
|
|
44
|
+
dedup_label: str,
|
|
45
|
+
report_url: str | None = None,
|
|
46
|
+
) -> dict[str, Any]:
|
|
47
|
+
"""Return the Jira issue fields dict ready to POST to /rest/api/3/issue."""
|
|
48
|
+
f = proposal.finding
|
|
49
|
+
p = proposal.policy
|
|
50
|
+
|
|
51
|
+
summary = (
|
|
52
|
+
f"[Argus] {_action_verb(p.action)} {f.name or f.resource_id} "
|
|
53
|
+
f"(${proposal.estimated_monthly_cost_usd:.0f}/mo · {f.priority} priority)"
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
priority_name = priority_map.get(f.priority, "Medium")
|
|
57
|
+
labels = list(default_labels) + [
|
|
58
|
+
dedup_label,
|
|
59
|
+
f"argus-priority-{f.priority}",
|
|
60
|
+
f"argus-action-{p.action}",
|
|
61
|
+
]
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
"project": {"key": project},
|
|
65
|
+
"summary": summary,
|
|
66
|
+
"description": _build_adf_description(proposal, report_url=report_url),
|
|
67
|
+
"issuetype": {"name": issue_type},
|
|
68
|
+
"priority": {"name": priority_name},
|
|
69
|
+
"labels": labels,
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def build_update_comment(
|
|
74
|
+
proposal: ChangeProposal,
|
|
75
|
+
stored: dict[str, Any],
|
|
76
|
+
) -> dict[str, Any]:
|
|
77
|
+
"""ADF comment body describing what changed since the last scan."""
|
|
78
|
+
f = proposal.finding
|
|
79
|
+
current = fingerprint(proposal)
|
|
80
|
+
lines = [f"Argus re-scan update — {date.today()}"]
|
|
81
|
+
|
|
82
|
+
if stored.get("cost") != current["cost"]:
|
|
83
|
+
lines.append(f"Cost: ${stored.get('cost', '?')}/mo → ${current['cost']}/mo")
|
|
84
|
+
if stored.get("priority") != current["priority"]:
|
|
85
|
+
lines.append(f"Priority: {stored.get('priority', '?')} → {current['priority']}")
|
|
86
|
+
if stored.get("reason_hash") != current["reason_hash"]:
|
|
87
|
+
lines.append(f'AI reasoning updated: "{f.waste_reason[:160]}"')
|
|
88
|
+
lines.append(f"Proposal ID: {proposal.proposal_id}")
|
|
89
|
+
|
|
90
|
+
return _adf_paragraph("\n".join(lines))
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def fingerprint(proposal: ChangeProposal) -> dict[str, Any]:
|
|
94
|
+
"""Compact snapshot of fields that trigger a comment on change.
|
|
95
|
+
|
|
96
|
+
Deliberately excludes proposal_id: it is a fresh UUID every scan, so
|
|
97
|
+
including it made every re-scan look like a change.
|
|
98
|
+
"""
|
|
99
|
+
return {
|
|
100
|
+
"cost": round(proposal.estimated_monthly_cost_usd, 0),
|
|
101
|
+
"priority": proposal.finding.priority,
|
|
102
|
+
"reason_hash": hashlib.md5(
|
|
103
|
+
proposal.finding.waste_reason[:120].encode()
|
|
104
|
+
).hexdigest()[:8],
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
FINGERPRINT_KEYS = ("cost", "priority", "reason_hash")
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def snapshot_changed(stored: dict[str, Any], current: dict[str, Any]) -> bool:
|
|
112
|
+
"""Compare only the fingerprint keys (ignores legacy keys such as proposal_id)."""
|
|
113
|
+
return any(stored.get(k) != current.get(k) for k in FINGERPRINT_KEYS)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def extract_snapshot(description_text: str) -> dict[str, Any] | None:
|
|
117
|
+
"""
|
|
118
|
+
Parse the argus-snapshot JSON from the issue description footer.
|
|
119
|
+
Returns None if not present (ticket created by an older version).
|
|
120
|
+
"""
|
|
121
|
+
start = description_text.find(_SNAPSHOT_MARKER)
|
|
122
|
+
if start == -1:
|
|
123
|
+
return None
|
|
124
|
+
end = description_text.find(_SNAPSHOT_END, start)
|
|
125
|
+
if end == -1:
|
|
126
|
+
return None
|
|
127
|
+
raw = description_text[start + len(_SNAPSHOT_MARKER) : end].strip()
|
|
128
|
+
try:
|
|
129
|
+
result: dict[str, Any] = json.loads(raw)
|
|
130
|
+
return result
|
|
131
|
+
except (json.JSONDecodeError, ValueError):
|
|
132
|
+
return None
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
# ------------------------------------------------------------------
|
|
136
|
+
# ADF builder
|
|
137
|
+
# ------------------------------------------------------------------
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _build_adf_description(
|
|
141
|
+
proposal: ChangeProposal,
|
|
142
|
+
report_url: str | None,
|
|
143
|
+
) -> dict[str, Any]:
|
|
144
|
+
f = proposal.finding
|
|
145
|
+
p = proposal.policy
|
|
146
|
+
snap = json.dumps(fingerprint(proposal))
|
|
147
|
+
|
|
148
|
+
content: list[dict[str, Any]] = []
|
|
149
|
+
|
|
150
|
+
# h2 Finding
|
|
151
|
+
content.append(_adf_heading("Finding", level=2))
|
|
152
|
+
content.append(
|
|
153
|
+
_adf_paragraph(
|
|
154
|
+
f"Resource: {f.name or f.resource_id}\n"
|
|
155
|
+
f"Type: {f.resource_type}\n"
|
|
156
|
+
f"Cloud: {f.cloud.upper()}\n"
|
|
157
|
+
f"Region: {f.region}\n"
|
|
158
|
+
f"Cost: ${proposal.estimated_monthly_cost_usd:.2f}/mo\n"
|
|
159
|
+
f"Priority: {f.priority.upper()}\n"
|
|
160
|
+
f"Action: {p.action}\n"
|
|
161
|
+
f"Proposal: {proposal.proposal_id}"
|
|
162
|
+
)
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
# h2 Key Metrics (only if present)
|
|
166
|
+
if f.metrics_summary:
|
|
167
|
+
content.append(_adf_heading("Key Metrics", level=2))
|
|
168
|
+
rows = [
|
|
169
|
+
[
|
|
170
|
+
_adf_text(k),
|
|
171
|
+
_adf_text(str(round(v, 4)) if isinstance(v, float) else str(v)),
|
|
172
|
+
]
|
|
173
|
+
for k, v in sorted(f.metrics_summary.items())
|
|
174
|
+
]
|
|
175
|
+
content.append(_adf_table(["Metric", "Value"], rows))
|
|
176
|
+
|
|
177
|
+
# h2 Why Argus Flagged This
|
|
178
|
+
content.append(_adf_heading("Why Argus Flagged This", level=2))
|
|
179
|
+
content.append(_adf_paragraph(f.waste_reason))
|
|
180
|
+
|
|
181
|
+
# h2 Recommendation
|
|
182
|
+
content.append(_adf_heading("Recommendation", level=2))
|
|
183
|
+
content.append(_adf_paragraph(f.recommendation))
|
|
184
|
+
if proposal.resize_recommendation:
|
|
185
|
+
content.append(_adf_paragraph(f"Rightsizing: {proposal.resize_recommendation}"))
|
|
186
|
+
|
|
187
|
+
# h2 Runbook
|
|
188
|
+
content.append(_adf_heading("Runbook", level=2))
|
|
189
|
+
content.append(
|
|
190
|
+
_adf_paragraph(
|
|
191
|
+
"⚠ Human approval required. Argus does not execute these commands."
|
|
192
|
+
)
|
|
193
|
+
)
|
|
194
|
+
content.append(_adf_code_block(proposal.runbook))
|
|
195
|
+
|
|
196
|
+
# h2 Policy
|
|
197
|
+
content.append(_adf_heading("Policy", level=2))
|
|
198
|
+
content.append(
|
|
199
|
+
_adf_paragraph(
|
|
200
|
+
f"Policy ID: {p.policy_id}\n"
|
|
201
|
+
f"Weight: {p.weight}\n"
|
|
202
|
+
f"Source: {p.source_file}"
|
|
203
|
+
)
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
# h2 Full Report
|
|
207
|
+
if report_url:
|
|
208
|
+
content.append(_adf_heading("Full Report", level=2))
|
|
209
|
+
content.append(_adf_paragraph(report_url))
|
|
210
|
+
|
|
211
|
+
# Machine-readable snapshot footer
|
|
212
|
+
content.append(_adf_paragraph(f"{_SNAPSHOT_MARKER} {snap} {_SNAPSHOT_END}"))
|
|
213
|
+
|
|
214
|
+
return {"version": 1, "type": "doc", "content": content}
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
# ------------------------------------------------------------------
|
|
218
|
+
# ADF node helpers
|
|
219
|
+
# ------------------------------------------------------------------
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _adf_heading(text: str, level: int) -> dict[str, Any]:
|
|
223
|
+
return {
|
|
224
|
+
"type": "heading",
|
|
225
|
+
"attrs": {"level": level},
|
|
226
|
+
"content": [{"type": "text", "text": text}],
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _adf_paragraph(text: str) -> dict[str, Any]:
|
|
231
|
+
return {
|
|
232
|
+
"type": "paragraph",
|
|
233
|
+
"content": [{"type": "text", "text": text}],
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _adf_code_block(text: str) -> dict[str, Any]:
|
|
238
|
+
return {
|
|
239
|
+
"type": "codeBlock",
|
|
240
|
+
"attrs": {"language": "bash"},
|
|
241
|
+
"content": [{"type": "text", "text": text}],
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _adf_text(text: str) -> dict[str, Any]:
|
|
246
|
+
return {"type": "tableCell", "content": [_adf_paragraph(text)]}
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _adf_table(
|
|
250
|
+
headers: list[str],
|
|
251
|
+
rows: list[list[dict[str, Any]]],
|
|
252
|
+
) -> dict[str, Any]:
|
|
253
|
+
header_row = {
|
|
254
|
+
"type": "tableRow",
|
|
255
|
+
"content": [
|
|
256
|
+
{
|
|
257
|
+
"type": "tableHeader",
|
|
258
|
+
"content": [_adf_paragraph(h)],
|
|
259
|
+
}
|
|
260
|
+
for h in headers
|
|
261
|
+
],
|
|
262
|
+
}
|
|
263
|
+
body_rows = [{"type": "tableRow", "content": row} for row in rows]
|
|
264
|
+
return {
|
|
265
|
+
"type": "table",
|
|
266
|
+
"attrs": {"isNumberColumnEnabled": False, "layout": "default"},
|
|
267
|
+
"content": [header_row, *body_rows],
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _action_verb(action: str) -> str:
|
|
272
|
+
return {
|
|
273
|
+
"delete": "Delete",
|
|
274
|
+
"resize": "Resize",
|
|
275
|
+
"stop": "Stop",
|
|
276
|
+
"snapshot_delete": "Snapshot & delete",
|
|
277
|
+
"reduce_replicas": "Reduce replicas for",
|
|
278
|
+
"reduce_nodes": "Reduce nodes for",
|
|
279
|
+
"archive": "Archive",
|
|
280
|
+
"convert_spot": "Convert to Spot",
|
|
281
|
+
}.get(action, action.capitalize())
|