azure-discovery 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.
- azure_discovery/__init__.py +33 -0
- azure_discovery/adt_types/__init__.py +18 -0
- azure_discovery/adt_types/errors.py +13 -0
- azure_discovery/adt_types/models.py +121 -0
- azure_discovery/api.py +45 -0
- azure_discovery/cli.py +132 -0
- azure_discovery/enumerators/__init__.py +3 -0
- azure_discovery/enumerators/azure_resources.py +134 -0
- azure_discovery/orchestrator.py +24 -0
- azure_discovery/reporting/__init__.py +4 -0
- azure_discovery/reporting/console.py +28 -0
- azure_discovery/reporting/html.py +79 -0
- azure_discovery/utils/__init__.py +10 -0
- azure_discovery/utils/azure_clients.py +182 -0
- azure_discovery/utils/graph_helpers.py +42 -0
- azure_discovery/utils/logging.py +59 -0
- azure_discovery-0.1.0.dist-info/METADATA +335 -0
- azure_discovery-0.1.0.dist-info/RECORD +22 -0
- azure_discovery-0.1.0.dist-info/WHEEL +5 -0
- azure_discovery-0.1.0.dist-info/entry_points.txt +2 -0
- azure_discovery-0.1.0.dist-info/licenses/LICENSE +21 -0
- azure_discovery-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Azure Discovery: Azure tenant discovery and visualization via Resource Graph.
|
|
2
|
+
|
|
3
|
+
This package can be installed from PyPI as ``azure-discovery`` and used as:
|
|
4
|
+
|
|
5
|
+
pip install azure-discovery
|
|
6
|
+
azure-discovery discover --tenant-id <id> --subscription <sub-id>
|
|
7
|
+
|
|
8
|
+
Programmatic usage:
|
|
9
|
+
|
|
10
|
+
from azure_discovery import run_discovery
|
|
11
|
+
from azure_discovery.adt_types import AzureDiscoveryRequest, AzureDiscoveryResponse
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from .orchestrator import run_discovery
|
|
15
|
+
from .adt_types import (
|
|
16
|
+
AzureDiscoveryRequest,
|
|
17
|
+
AzureDiscoveryResponse,
|
|
18
|
+
AzureEnvironment,
|
|
19
|
+
DiscoveryFilter,
|
|
20
|
+
ResourceNode,
|
|
21
|
+
ResourceRelationship,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
"run_discovery",
|
|
26
|
+
"AzureDiscoveryRequest",
|
|
27
|
+
"AzureDiscoveryResponse",
|
|
28
|
+
"AzureEnvironment",
|
|
29
|
+
"DiscoveryFilter",
|
|
30
|
+
"ResourceNode",
|
|
31
|
+
"ResourceRelationship",
|
|
32
|
+
]
|
|
33
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Typed interfaces shared across the Azure discovery tool."""
|
|
2
|
+
|
|
3
|
+
from .models import ( # noqa: F401
|
|
4
|
+
AzureDiscoveryRequest,
|
|
5
|
+
AzureDiscoveryResponse,
|
|
6
|
+
AzureEnvironment,
|
|
7
|
+
AzureEnvironmentConfig,
|
|
8
|
+
DiscoveryFilter,
|
|
9
|
+
ResourceNode,
|
|
10
|
+
ResourceRelationship,
|
|
11
|
+
VisualizationOptions,
|
|
12
|
+
VisualizationResponse,
|
|
13
|
+
)
|
|
14
|
+
from .errors import ( # noqa: F401
|
|
15
|
+
AzureClientError,
|
|
16
|
+
InvalidTargetError,
|
|
17
|
+
VisualizationError,
|
|
18
|
+
)
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Custom exceptions for Azure discovery."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class InvalidTargetError(ValueError):
|
|
5
|
+
"""Raised when a provided target identifier is malformed."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class AzureClientError(RuntimeError):
|
|
9
|
+
"""Raised when Azure SDK clients report a fatal error."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class VisualizationError(RuntimeError):
|
|
13
|
+
"""Raised when graph generation fails."""
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Pydantic models shared across modules."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
from enum import Enum
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, Dict, List, Optional
|
|
9
|
+
|
|
10
|
+
from pydantic import BaseModel, Field, field_validator
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class AzureEnvironment(str, Enum):
|
|
14
|
+
"""Supported Azure clouds."""
|
|
15
|
+
|
|
16
|
+
AZURE_PUBLIC = "azure_public"
|
|
17
|
+
AZURE_GOV = "azure_gov"
|
|
18
|
+
AZURE_CHINA = "azure_china"
|
|
19
|
+
AZURE_GERMANY = "azure_germany"
|
|
20
|
+
AZURE_STACK = "azure_stack"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class AzureEnvironmentConfig(BaseModel):
|
|
24
|
+
"""Concrete endpoints for an Azure cloud."""
|
|
25
|
+
|
|
26
|
+
name: AzureEnvironment
|
|
27
|
+
authority_host: str
|
|
28
|
+
resource_manager: str
|
|
29
|
+
storage_suffix: str
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class DiscoveryFilter(BaseModel):
|
|
33
|
+
"""Optional filters used to scope discovery."""
|
|
34
|
+
|
|
35
|
+
include_types: Optional[List[str]] = None
|
|
36
|
+
exclude_types: Optional[List[str]] = None
|
|
37
|
+
required_tags: Optional[Dict[str, str]] = None
|
|
38
|
+
resource_groups: Optional[List[str]] = None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class VisualizationOptions(BaseModel):
|
|
42
|
+
"""Graph visualization configuration."""
|
|
43
|
+
|
|
44
|
+
output_dir: Path = Field(default=Path("artifacts/graphs"))
|
|
45
|
+
file_name: Optional[str] = None
|
|
46
|
+
include_attributes: bool = True
|
|
47
|
+
physics_enabled: bool = True
|
|
48
|
+
|
|
49
|
+
@field_validator("output_dir", mode="before")
|
|
50
|
+
@classmethod
|
|
51
|
+
def _ensure_path(cls, value: Any) -> Path:
|
|
52
|
+
if value is None:
|
|
53
|
+
raise ValueError("output_dir cannot be None")
|
|
54
|
+
return Path(value)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class AzureDiscoveryRequest(BaseModel):
|
|
58
|
+
"""Input payload for discovery operations."""
|
|
59
|
+
|
|
60
|
+
tenant_id: str
|
|
61
|
+
environment: AzureEnvironment = AzureEnvironment.AZURE_PUBLIC
|
|
62
|
+
subscriptions: Optional[List[str]] = None
|
|
63
|
+
filter: Optional[DiscoveryFilter] = None
|
|
64
|
+
include_relationships: bool = True
|
|
65
|
+
max_batch_size: int = Field(default=1000, ge=100, le=5000)
|
|
66
|
+
throttle_delay_seconds: float = Field(default=0.05, ge=0.0, le=5.0)
|
|
67
|
+
prefer_cli_credentials: bool = False
|
|
68
|
+
visualization: VisualizationOptions = Field(
|
|
69
|
+
default_factory=VisualizationOptions
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
@field_validator("tenant_id")
|
|
73
|
+
@classmethod
|
|
74
|
+
def _validate_tenant(cls, value: str) -> str:
|
|
75
|
+
if not value or not value.strip():
|
|
76
|
+
raise ValueError("tenant_id is required")
|
|
77
|
+
return value.strip()
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class ResourceNode(BaseModel):
|
|
81
|
+
"""Normalized Azure resource."""
|
|
82
|
+
|
|
83
|
+
id: str
|
|
84
|
+
name: str
|
|
85
|
+
type: str
|
|
86
|
+
location: Optional[str] = None
|
|
87
|
+
subscription_id: str
|
|
88
|
+
resource_group: Optional[str] = None
|
|
89
|
+
tags: Dict[str, str] = Field(default_factory=dict)
|
|
90
|
+
dependencies: List[str] = Field(default_factory=list)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class ResourceRelationship(BaseModel):
|
|
94
|
+
"""Edge describing resource dependency."""
|
|
95
|
+
|
|
96
|
+
source_id: str
|
|
97
|
+
target_id: str
|
|
98
|
+
relation_type: str
|
|
99
|
+
weight: float = 1.0
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class AzureDiscoveryResponse(BaseModel):
|
|
103
|
+
"""Discovery result payload."""
|
|
104
|
+
|
|
105
|
+
tenant_id: str
|
|
106
|
+
discovered_subscriptions: List[str]
|
|
107
|
+
nodes: List[ResourceNode]
|
|
108
|
+
relationships: List[ResourceRelationship]
|
|
109
|
+
total_resources: int
|
|
110
|
+
generated_at: datetime = Field(
|
|
111
|
+
default_factory=lambda: datetime.now(timezone.utc)
|
|
112
|
+
)
|
|
113
|
+
html_report_path: Optional[str] = None
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class VisualizationResponse(BaseModel):
|
|
117
|
+
"""Result of graph rendering."""
|
|
118
|
+
|
|
119
|
+
html_path: str
|
|
120
|
+
nodes: int
|
|
121
|
+
relationships: int
|
azure_discovery/api.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""FastAPI surface for Azure discovery."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from fastapi import FastAPI, HTTPException
|
|
8
|
+
from fastapi.responses import FileResponse
|
|
9
|
+
|
|
10
|
+
from .orchestrator import run_discovery
|
|
11
|
+
from .adt_types import AzureDiscoveryRequest, AzureDiscoveryResponse
|
|
12
|
+
from .utils.logging import get_logger
|
|
13
|
+
|
|
14
|
+
LOGGER = get_logger()
|
|
15
|
+
app = FastAPI(title="Azure Discovery", version="1.0.0")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@app.get("/healthz", tags=["system"])
|
|
19
|
+
async def health_check() -> dict:
|
|
20
|
+
return {"status": "ok"}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@app.post(
|
|
24
|
+
"/discover",
|
|
25
|
+
response_model=AzureDiscoveryResponse,
|
|
26
|
+
tags=["discovery"],
|
|
27
|
+
)
|
|
28
|
+
async def discover_endpoint(
|
|
29
|
+
request: AzureDiscoveryRequest,
|
|
30
|
+
) -> AzureDiscoveryResponse:
|
|
31
|
+
LOGGER.info(
|
|
32
|
+
"API discovery invoked",
|
|
33
|
+
extra={"context": {"tenant_id": request.tenant_id}},
|
|
34
|
+
)
|
|
35
|
+
return await run_discovery(request)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@app.get("/visuals/{file_name}", tags=["visualization"])
|
|
39
|
+
async def visualization_endpoint(file_name: str) -> FileResponse:
|
|
40
|
+
if not file_name:
|
|
41
|
+
raise HTTPException(status_code=400, detail="file_name missing")
|
|
42
|
+
html_path = Path("artifacts/graphs") / file_name
|
|
43
|
+
if not html_path.is_file():
|
|
44
|
+
raise HTTPException(status_code=404, detail="Visualization not found")
|
|
45
|
+
return FileResponse(html_path)
|
azure_discovery/cli.py
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""Command-line interface for Azure discovery."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import List, Optional
|
|
10
|
+
|
|
11
|
+
import typer
|
|
12
|
+
|
|
13
|
+
from .orchestrator import run_discovery
|
|
14
|
+
from .adt_types import (
|
|
15
|
+
AzureDiscoveryRequest,
|
|
16
|
+
AzureEnvironment,
|
|
17
|
+
DiscoveryFilter,
|
|
18
|
+
VisualizationOptions,
|
|
19
|
+
)
|
|
20
|
+
from .utils.logging import get_logger
|
|
21
|
+
|
|
22
|
+
cli = typer.Typer(help="Azure tenant discovery and visualization CLI")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _parse_tags(tag_args: Optional[List[str]]) -> Optional[dict]:
|
|
26
|
+
if not tag_args:
|
|
27
|
+
return None
|
|
28
|
+
tags = {}
|
|
29
|
+
for tag_arg in tag_args:
|
|
30
|
+
if "=" not in tag_arg:
|
|
31
|
+
raise typer.BadParameter("Tags must use key=value syntax")
|
|
32
|
+
key, value = tag_arg.split("=", 1)
|
|
33
|
+
tags[key.strip()] = value.strip()
|
|
34
|
+
return tags
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@cli.command("discover")
|
|
38
|
+
def discover_command(
|
|
39
|
+
tenant_id: str = typer.Option(..., help="Azure AD tenant identifier"),
|
|
40
|
+
environment: AzureEnvironment = typer.Option(
|
|
41
|
+
AzureEnvironment.AZURE_PUBLIC, help="Azure cloud environment"
|
|
42
|
+
),
|
|
43
|
+
subscription: Optional[List[str]] = typer.Option(
|
|
44
|
+
None, "--subscription", "-s", help="Explicit subscription identifier"
|
|
45
|
+
),
|
|
46
|
+
include_type: Optional[List[str]] = typer.Option(
|
|
47
|
+
None, "--include-type", help="Resource type to include (repeatable)"
|
|
48
|
+
),
|
|
49
|
+
exclude_type: Optional[List[str]] = typer.Option(
|
|
50
|
+
None, "--exclude-type", help="Resource type to exclude (repeatable)"
|
|
51
|
+
),
|
|
52
|
+
resource_group: Optional[List[str]] = typer.Option(
|
|
53
|
+
None, "--resource-group", help="Resource group filter (repeatable)"
|
|
54
|
+
),
|
|
55
|
+
required_tag: Optional[List[str]] = typer.Option(
|
|
56
|
+
None, "--required-tag", help="Required tag key=value (repeatable)"
|
|
57
|
+
),
|
|
58
|
+
prefer_cli: bool = typer.Option(
|
|
59
|
+
False, "--prefer-cli", help="Prefer Azure CLI credential in chain"
|
|
60
|
+
),
|
|
61
|
+
visualization_output_dir: Path = typer.Option(
|
|
62
|
+
Path("artifacts/graphs"),
|
|
63
|
+
"--visualization-output-dir",
|
|
64
|
+
help="Directory for HTML output",
|
|
65
|
+
),
|
|
66
|
+
visualization_file: Optional[str] = typer.Option(
|
|
67
|
+
None, "--visualization-file", help="Optional HTML file name"
|
|
68
|
+
),
|
|
69
|
+
output: Optional[Path] = typer.Option(
|
|
70
|
+
None, "--output", "-o", help="Write JSON output to file instead of stdout"
|
|
71
|
+
),
|
|
72
|
+
quiet: bool = typer.Option(
|
|
73
|
+
False, "--quiet", "-q", help="Suppress all logs except errors"
|
|
74
|
+
),
|
|
75
|
+
format: str = typer.Option(
|
|
76
|
+
"json", "--format", "-f", help="Output format: json, json-compact"
|
|
77
|
+
),
|
|
78
|
+
) -> None:
|
|
79
|
+
"""Discover Azure resources and emit JSON output.
|
|
80
|
+
|
|
81
|
+
By default, JSON is written to stdout and logs go to stderr.
|
|
82
|
+
Use --output to write JSON to a file.
|
|
83
|
+
Use --quiet to suppress all logs except errors.
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
# Initialize logger with quiet mode
|
|
87
|
+
get_logger(quiet=quiet)
|
|
88
|
+
|
|
89
|
+
tag_filters = _parse_tags(required_tag)
|
|
90
|
+
discovery_filter = DiscoveryFilter(
|
|
91
|
+
include_types=include_type,
|
|
92
|
+
exclude_types=exclude_type,
|
|
93
|
+
required_tags=tag_filters,
|
|
94
|
+
resource_groups=resource_group,
|
|
95
|
+
)
|
|
96
|
+
visualization_options = VisualizationOptions(
|
|
97
|
+
output_dir=visualization_output_dir,
|
|
98
|
+
file_name=visualization_file,
|
|
99
|
+
)
|
|
100
|
+
request = AzureDiscoveryRequest(
|
|
101
|
+
tenant_id=tenant_id,
|
|
102
|
+
environment=environment,
|
|
103
|
+
subscriptions=subscription,
|
|
104
|
+
filter=discovery_filter,
|
|
105
|
+
prefer_cli_credentials=prefer_cli,
|
|
106
|
+
visualization=visualization_options,
|
|
107
|
+
)
|
|
108
|
+
response = asyncio.run(run_discovery(request))
|
|
109
|
+
|
|
110
|
+
# Format output based on format option
|
|
111
|
+
if format == "json-compact":
|
|
112
|
+
json_output = json.dumps(response.model_dump(), default=str)
|
|
113
|
+
else: # json
|
|
114
|
+
json_output = json.dumps(response.model_dump(), indent=2, default=str)
|
|
115
|
+
|
|
116
|
+
# Write output to file or stdout
|
|
117
|
+
if output:
|
|
118
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
119
|
+
output.write_text(json_output)
|
|
120
|
+
# Log to stderr that output was written
|
|
121
|
+
typer.echo(f"Discovery results written to {output}", err=True)
|
|
122
|
+
else:
|
|
123
|
+
# Write JSON to stdout (clean output for piping)
|
|
124
|
+
sys.stdout.write(json_output + "\n")
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def main() -> None:
|
|
128
|
+
cli()
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
if __name__ == "__main__":
|
|
132
|
+
main()
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""Azure resource enumeration logic."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Dict, List, Optional
|
|
6
|
+
|
|
7
|
+
from azure.core.credentials import TokenCredential
|
|
8
|
+
from ..adt_types import (
|
|
9
|
+
AzureDiscoveryRequest,
|
|
10
|
+
AzureDiscoveryResponse,
|
|
11
|
+
ResourceNode,
|
|
12
|
+
ResourceRelationship,
|
|
13
|
+
)
|
|
14
|
+
from ..utils import (
|
|
15
|
+
build_environment_config,
|
|
16
|
+
ensure_subscription_ids,
|
|
17
|
+
get_credential,
|
|
18
|
+
query_resource_graph,
|
|
19
|
+
)
|
|
20
|
+
from ..utils.graph_helpers import build_graph_edges, ensure_unique_nodes
|
|
21
|
+
from ..utils.logging import get_logger
|
|
22
|
+
|
|
23
|
+
LOGGER = get_logger()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _build_query(request: AzureDiscoveryRequest) -> str:
|
|
27
|
+
clauses = ["resources"]
|
|
28
|
+
filters = []
|
|
29
|
+
|
|
30
|
+
if request.filter:
|
|
31
|
+
discovery_filter = request.filter
|
|
32
|
+
if discovery_filter.include_types:
|
|
33
|
+
include_clause = " or ".join(
|
|
34
|
+
f"tolower(type) == '{resource_type.lower()}'"
|
|
35
|
+
for resource_type in discovery_filter.include_types
|
|
36
|
+
)
|
|
37
|
+
filters.append(f"({include_clause})")
|
|
38
|
+
if discovery_filter.exclude_types:
|
|
39
|
+
exclude_clause = " and ".join(
|
|
40
|
+
f"tolower(type) != '{resource_type.lower()}'"
|
|
41
|
+
for resource_type in discovery_filter.exclude_types
|
|
42
|
+
)
|
|
43
|
+
filters.append(f"({exclude_clause})")
|
|
44
|
+
if discovery_filter.resource_groups:
|
|
45
|
+
groups_clause = " or ".join(
|
|
46
|
+
f"tolower(resourceGroup) == '{group.lower()}'"
|
|
47
|
+
for group in discovery_filter.resource_groups
|
|
48
|
+
)
|
|
49
|
+
filters.append(f"({groups_clause})")
|
|
50
|
+
if discovery_filter.required_tags:
|
|
51
|
+
tag_clause = " and ".join(
|
|
52
|
+
f"tags['{key}'] =~ '{value}'"
|
|
53
|
+
for key, value in discovery_filter.required_tags.items()
|
|
54
|
+
)
|
|
55
|
+
filters.append(f"({tag_clause})")
|
|
56
|
+
|
|
57
|
+
if filters:
|
|
58
|
+
clauses.append("| where " + " and ".join(filters))
|
|
59
|
+
|
|
60
|
+
clauses.append(
|
|
61
|
+
"| project id, name, type, location, resourceGroup,"
|
|
62
|
+
" subscriptionId, tags, properties"
|
|
63
|
+
)
|
|
64
|
+
return " ".join(clauses)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _to_node(payload: Dict[str, Any]) -> ResourceNode:
|
|
68
|
+
dependencies = []
|
|
69
|
+
properties = payload.get("properties") or {}
|
|
70
|
+
raw_dependencies = properties.get("dependencies") or []
|
|
71
|
+
for dependency in raw_dependencies:
|
|
72
|
+
resource_id = dependency.get("resourceId")
|
|
73
|
+
if resource_id:
|
|
74
|
+
dependencies.append(resource_id)
|
|
75
|
+
|
|
76
|
+
return ResourceNode(
|
|
77
|
+
id=payload["id"],
|
|
78
|
+
name=payload.get("name", payload["id"].split("/")[-1]),
|
|
79
|
+
type=payload.get("type", "unknown"),
|
|
80
|
+
location=payload.get("location"),
|
|
81
|
+
subscription_id=payload.get("subscriptionId"),
|
|
82
|
+
resource_group=payload.get("resourceGroup"),
|
|
83
|
+
tags=payload.get("tags") or {},
|
|
84
|
+
dependencies=dependencies,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
async def enumerate_azure_resources(
|
|
89
|
+
request: AzureDiscoveryRequest,
|
|
90
|
+
credential: Optional[TokenCredential] = None,
|
|
91
|
+
) -> AzureDiscoveryResponse:
|
|
92
|
+
"""Return normalized Azure resources for the provided tenant.
|
|
93
|
+
|
|
94
|
+
When credential is provided (e.g. from an embedding app like auto_iac), it is
|
|
95
|
+
used with the request's environment so sovereign clouds work. Otherwise the
|
|
96
|
+
default credential chain is built from the request.
|
|
97
|
+
"""
|
|
98
|
+
if not request:
|
|
99
|
+
raise ValueError("request cannot be None")
|
|
100
|
+
|
|
101
|
+
environment_config = build_environment_config(request.environment)
|
|
102
|
+
if credential is None:
|
|
103
|
+
credential = get_credential(request, environment_config)
|
|
104
|
+
subscription_ids = await ensure_subscription_ids(
|
|
105
|
+
request, credential, base_url=environment_config.resource_manager
|
|
106
|
+
)
|
|
107
|
+
query = _build_query(request)
|
|
108
|
+
raw_resources = await query_resource_graph(
|
|
109
|
+
credential=credential,
|
|
110
|
+
query=query,
|
|
111
|
+
subscriptions=subscription_ids,
|
|
112
|
+
batch_size=request.max_batch_size,
|
|
113
|
+
base_url=environment_config.resource_manager,
|
|
114
|
+
)
|
|
115
|
+
nodes = ensure_unique_nodes(_to_node(item) for item in raw_resources)
|
|
116
|
+
relationships = build_graph_edges(nodes, request.include_relationships)
|
|
117
|
+
|
|
118
|
+
LOGGER.info(
|
|
119
|
+
"Enumeration completed",
|
|
120
|
+
extra={
|
|
121
|
+
"context": {
|
|
122
|
+
"tenant_id": request.tenant_id,
|
|
123
|
+
"nodes": len(nodes),
|
|
124
|
+
"relationships": len(relationships),
|
|
125
|
+
}
|
|
126
|
+
},
|
|
127
|
+
)
|
|
128
|
+
return AzureDiscoveryResponse(
|
|
129
|
+
tenant_id=request.tenant_id,
|
|
130
|
+
discovered_subscriptions=subscription_ids,
|
|
131
|
+
nodes=nodes,
|
|
132
|
+
relationships=relationships,
|
|
133
|
+
total_resources=len(nodes),
|
|
134
|
+
)
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Discovery orchestrator."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .adt_types import AzureDiscoveryRequest, AzureDiscoveryResponse
|
|
6
|
+
from .enumerators import enumerate_azure_resources
|
|
7
|
+
from .reporting import emit_console_summary, render_visualization
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
async def run_discovery(request: AzureDiscoveryRequest) -> AzureDiscoveryResponse:
|
|
11
|
+
"""Coordinate enumeration and visualization."""
|
|
12
|
+
|
|
13
|
+
if not request:
|
|
14
|
+
raise ValueError("request cannot be None")
|
|
15
|
+
|
|
16
|
+
discovery_response = await enumerate_azure_resources(request)
|
|
17
|
+
visualization = render_visualization(
|
|
18
|
+
response=discovery_response, options=request.visualization
|
|
19
|
+
)
|
|
20
|
+
enriched_response = discovery_response.model_copy(
|
|
21
|
+
update={"html_report_path": visualization.html_path}
|
|
22
|
+
)
|
|
23
|
+
emit_console_summary(enriched_response)
|
|
24
|
+
return enriched_response
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Console reporting utilities."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from ..adt_types import AzureDiscoveryResponse
|
|
6
|
+
from ..utils.logging import get_logger
|
|
7
|
+
|
|
8
|
+
LOGGER = get_logger()
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def emit_console_summary(response: AzureDiscoveryResponse) -> None:
|
|
12
|
+
"""Log a concise discovery summary."""
|
|
13
|
+
|
|
14
|
+
if not response:
|
|
15
|
+
LOGGER.warning("Discovery response missing")
|
|
16
|
+
return
|
|
17
|
+
LOGGER.info(
|
|
18
|
+
"Discovery summary",
|
|
19
|
+
extra={
|
|
20
|
+
"context": {
|
|
21
|
+
"tenant_id": response.tenant_id,
|
|
22
|
+
"subscriptions": response.discovered_subscriptions,
|
|
23
|
+
"nodes": response.total_resources,
|
|
24
|
+
"relationships": len(response.relationships),
|
|
25
|
+
"report": response.html_report_path,
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
)
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""HTML visualization helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
|
|
8
|
+
from pyvis.network import Network
|
|
9
|
+
|
|
10
|
+
from ..adt_types import (
|
|
11
|
+
AzureDiscoveryResponse,
|
|
12
|
+
VisualizationOptions,
|
|
13
|
+
VisualizationResponse,
|
|
14
|
+
)
|
|
15
|
+
from ..adt_types.errors import VisualizationError
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def render_visualization(
|
|
19
|
+
response: AzureDiscoveryResponse,
|
|
20
|
+
options: VisualizationOptions,
|
|
21
|
+
) -> VisualizationResponse:
|
|
22
|
+
"""Render an interactive dependency graph."""
|
|
23
|
+
|
|
24
|
+
if not response:
|
|
25
|
+
raise VisualizationError("Discovery response missing")
|
|
26
|
+
output_dir = options.output_dir.expanduser()
|
|
27
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
28
|
+
file_name = (
|
|
29
|
+
options.file_name
|
|
30
|
+
or f"azure-graph-{datetime.utcnow().strftime('%Y%m%d%H%M%S')}.html"
|
|
31
|
+
)
|
|
32
|
+
html_path = output_dir / file_name
|
|
33
|
+
|
|
34
|
+
net = Network(
|
|
35
|
+
height="960px",
|
|
36
|
+
width="100%",
|
|
37
|
+
directed=True,
|
|
38
|
+
bgcolor="#05060a",
|
|
39
|
+
font_color="#f5f7fa",
|
|
40
|
+
)
|
|
41
|
+
if options.physics_enabled:
|
|
42
|
+
net.barnes_hut()
|
|
43
|
+
else:
|
|
44
|
+
net.force_atlas_2based(gravity=-50, central_gravity=0.015)
|
|
45
|
+
|
|
46
|
+
for node in response.nodes:
|
|
47
|
+
attributes = {
|
|
48
|
+
"id": node.id,
|
|
49
|
+
"type": node.type,
|
|
50
|
+
"location": node.location,
|
|
51
|
+
"resource_group": node.resource_group,
|
|
52
|
+
"tags": node.tags,
|
|
53
|
+
}
|
|
54
|
+
title = json.dumps(attributes, indent=2) if options.include_attributes else node.type
|
|
55
|
+
net.add_node(
|
|
56
|
+
node.id,
|
|
57
|
+
label=node.name,
|
|
58
|
+
title=title,
|
|
59
|
+
group=node.type.split("/")[-1],
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
for edge in response.relationships:
|
|
63
|
+
net.add_edge(
|
|
64
|
+
edge.source_id,
|
|
65
|
+
edge.target_id,
|
|
66
|
+
title=edge.relation_type,
|
|
67
|
+
value=edge.weight,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
try:
|
|
71
|
+
net.write_html(str(html_path), notebook=False)
|
|
72
|
+
except Exception as exc: # noqa: BLE001
|
|
73
|
+
raise VisualizationError("Failed to render HTML graph") from exc
|
|
74
|
+
|
|
75
|
+
return VisualizationResponse(
|
|
76
|
+
html_path=str(html_path),
|
|
77
|
+
nodes=len(response.nodes),
|
|
78
|
+
relationships=len(response.relationships),
|
|
79
|
+
)
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""Utility helpers for Azure discovery."""
|
|
2
|
+
|
|
3
|
+
from .azure_clients import ( # noqa: F401
|
|
4
|
+
build_environment_config,
|
|
5
|
+
ensure_subscription_ids,
|
|
6
|
+
get_credential,
|
|
7
|
+
query_resource_graph,
|
|
8
|
+
)
|
|
9
|
+
from .graph_helpers import build_graph_edges, ensure_unique_nodes
|
|
10
|
+
from .logging import get_logger
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"""Azure SDK client helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from typing import Any, Dict, List, Optional
|
|
7
|
+
|
|
8
|
+
from azure.identity import (
|
|
9
|
+
AzureCliCredential,
|
|
10
|
+
ChainedTokenCredential,
|
|
11
|
+
DefaultAzureCredential,
|
|
12
|
+
)
|
|
13
|
+
from azure.identity._constants import KnownAuthorities
|
|
14
|
+
from azure.mgmt.resourcegraph import ResourceGraphClient
|
|
15
|
+
from azure.mgmt.resourcegraph.models import QueryRequest
|
|
16
|
+
from azure.mgmt.subscription import SubscriptionClient
|
|
17
|
+
|
|
18
|
+
from ..adt_types import (
|
|
19
|
+
AzureDiscoveryRequest,
|
|
20
|
+
AzureEnvironment,
|
|
21
|
+
AzureEnvironmentConfig,
|
|
22
|
+
AzureClientError,
|
|
23
|
+
)
|
|
24
|
+
from .logging import get_logger
|
|
25
|
+
|
|
26
|
+
LOGGER = get_logger()
|
|
27
|
+
|
|
28
|
+
_ENVIRONMENT_MAP: Dict[AzureEnvironment, AzureEnvironmentConfig] = {
|
|
29
|
+
AzureEnvironment.AZURE_PUBLIC: AzureEnvironmentConfig(
|
|
30
|
+
name=AzureEnvironment.AZURE_PUBLIC,
|
|
31
|
+
authority_host=KnownAuthorities.AZURE_PUBLIC_CLOUD,
|
|
32
|
+
resource_manager="https://management.azure.com/",
|
|
33
|
+
storage_suffix="core.windows.net",
|
|
34
|
+
),
|
|
35
|
+
AzureEnvironment.AZURE_GOV: AzureEnvironmentConfig(
|
|
36
|
+
name=AzureEnvironment.AZURE_GOV,
|
|
37
|
+
authority_host=KnownAuthorities.AZURE_GOVERNMENT,
|
|
38
|
+
resource_manager="https://management.usgovcloudapi.net/",
|
|
39
|
+
storage_suffix="core.usgovcloudapi.net",
|
|
40
|
+
),
|
|
41
|
+
AzureEnvironment.AZURE_CHINA: AzureEnvironmentConfig(
|
|
42
|
+
name=AzureEnvironment.AZURE_CHINA,
|
|
43
|
+
authority_host=KnownAuthorities.AZURE_CHINA,
|
|
44
|
+
resource_manager="https://management.chinacloudapi.cn/",
|
|
45
|
+
storage_suffix="core.chinacloudapi.cn",
|
|
46
|
+
),
|
|
47
|
+
AzureEnvironment.AZURE_GERMANY: AzureEnvironmentConfig(
|
|
48
|
+
name=AzureEnvironment.AZURE_GERMANY,
|
|
49
|
+
authority_host=KnownAuthorities.AZURE_GERMANY,
|
|
50
|
+
resource_manager="https://management.microsoftazure.de/",
|
|
51
|
+
storage_suffix="core.cloudapi.de",
|
|
52
|
+
),
|
|
53
|
+
AzureEnvironment.AZURE_STACK: AzureEnvironmentConfig(
|
|
54
|
+
name=AzureEnvironment.AZURE_STACK,
|
|
55
|
+
authority_host=KnownAuthorities.AZURE_PUBLIC_CLOUD,
|
|
56
|
+
resource_manager="https://management.local.azurestack.external/",
|
|
57
|
+
storage_suffix="local.azurestack.external",
|
|
58
|
+
),
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def build_environment_config(
|
|
63
|
+
environment: AzureEnvironment,
|
|
64
|
+
) -> AzureEnvironmentConfig:
|
|
65
|
+
"""Return endpoints for the requested cloud."""
|
|
66
|
+
|
|
67
|
+
if environment not in _ENVIRONMENT_MAP:
|
|
68
|
+
raise AzureClientError(f"Unsupported environment: {environment}")
|
|
69
|
+
return _ENVIRONMENT_MAP[environment]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def get_credential(
|
|
73
|
+
request: AzureDiscoveryRequest,
|
|
74
|
+
config: AzureEnvironmentConfig,
|
|
75
|
+
) -> ChainedTokenCredential:
|
|
76
|
+
"""Return credential chain honoring CLI preference."""
|
|
77
|
+
|
|
78
|
+
credential_chain = []
|
|
79
|
+
if request.prefer_cli_credentials:
|
|
80
|
+
credential_chain.append(
|
|
81
|
+
AzureCliCredential(
|
|
82
|
+
authority=config.authority_host,
|
|
83
|
+
tenant_id=request.tenant_id,
|
|
84
|
+
)
|
|
85
|
+
)
|
|
86
|
+
credential_chain.append(
|
|
87
|
+
DefaultAzureCredential(
|
|
88
|
+
authority=config.authority_host,
|
|
89
|
+
exclude_interactive_browser_credential=False,
|
|
90
|
+
)
|
|
91
|
+
)
|
|
92
|
+
return ChainedTokenCredential(*credential_chain)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
async def ensure_subscription_ids(
|
|
96
|
+
request: AzureDiscoveryRequest,
|
|
97
|
+
credential: ChainedTokenCredential,
|
|
98
|
+
base_url: Optional[str] = None,
|
|
99
|
+
) -> List[str]:
|
|
100
|
+
"""Resolve the subscription list, querying Azure if needed."""
|
|
101
|
+
|
|
102
|
+
if request.subscriptions:
|
|
103
|
+
return sorted({sub.strip() for sub in request.subscriptions if sub})
|
|
104
|
+
|
|
105
|
+
loop = asyncio.get_running_loop()
|
|
106
|
+
|
|
107
|
+
# Add explicit credential scopes for sovereign cloud support
|
|
108
|
+
credential_scopes = [base_url + ".default"] if base_url else None
|
|
109
|
+
client = SubscriptionClient(
|
|
110
|
+
credential=credential,
|
|
111
|
+
base_url=base_url,
|
|
112
|
+
credential_scopes=credential_scopes,
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
def _list_subscriptions() -> List[str]:
|
|
116
|
+
return [sub.subscription_id for sub in client.subscriptions.list()]
|
|
117
|
+
|
|
118
|
+
try:
|
|
119
|
+
results: List[str] = await loop.run_in_executor(
|
|
120
|
+
None, _list_subscriptions
|
|
121
|
+
)
|
|
122
|
+
except Exception as exc: # noqa: BLE001
|
|
123
|
+
LOGGER.error(
|
|
124
|
+
"Failed to enumerate subscriptions",
|
|
125
|
+
extra={"context": {"error": str(exc)}},
|
|
126
|
+
)
|
|
127
|
+
raise AzureClientError("Unable to enumerate subscriptions") from exc
|
|
128
|
+
if not results:
|
|
129
|
+
raise AzureClientError("No subscriptions available for discovery")
|
|
130
|
+
return sorted(results)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
async def query_resource_graph(
|
|
134
|
+
credential: ChainedTokenCredential,
|
|
135
|
+
query: str,
|
|
136
|
+
subscriptions: List[str],
|
|
137
|
+
batch_size: int,
|
|
138
|
+
base_url: Optional[str] = None,
|
|
139
|
+
) -> List[Dict[str, Any]]:
|
|
140
|
+
"""Execute a Resource Graph query with pagination."""
|
|
141
|
+
|
|
142
|
+
if not query or not query.strip():
|
|
143
|
+
raise AzureClientError("Resource Graph query cannot be empty")
|
|
144
|
+
|
|
145
|
+
# Add explicit credential scopes for sovereign cloud support
|
|
146
|
+
credential_scopes = [base_url + ".default"] if base_url else None
|
|
147
|
+
client = ResourceGraphClient(
|
|
148
|
+
credential=credential,
|
|
149
|
+
base_url=base_url,
|
|
150
|
+
credential_scopes=credential_scopes,
|
|
151
|
+
)
|
|
152
|
+
payload = QueryRequest(
|
|
153
|
+
subscriptions=subscriptions,
|
|
154
|
+
query=query,
|
|
155
|
+
options={"resultFormat": "objectArray", "top": batch_size},
|
|
156
|
+
)
|
|
157
|
+
loop = asyncio.get_running_loop()
|
|
158
|
+
results: List[Dict[str, Any]] = []
|
|
159
|
+
skip_token: Optional[str] = None
|
|
160
|
+
|
|
161
|
+
while True:
|
|
162
|
+
if skip_token:
|
|
163
|
+
payload.options["skipToken"] = skip_token
|
|
164
|
+
|
|
165
|
+
def _run_query() -> Any:
|
|
166
|
+
return client.resources(payload)
|
|
167
|
+
|
|
168
|
+
try:
|
|
169
|
+
response = await loop.run_in_executor(None, _run_query)
|
|
170
|
+
except Exception as exc: # noqa: BLE001
|
|
171
|
+
LOGGER.error(
|
|
172
|
+
"Resource Graph query failed",
|
|
173
|
+
extra={"context": {"error": str(exc)}},
|
|
174
|
+
)
|
|
175
|
+
raise AzureClientError("Resource Graph query failure") from exc
|
|
176
|
+
|
|
177
|
+
results.extend(response.data or [])
|
|
178
|
+
skip_token = getattr(response, "skip_token", None)
|
|
179
|
+
if not skip_token:
|
|
180
|
+
break
|
|
181
|
+
|
|
182
|
+
return results
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Graph helper utilities."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Iterable, List
|
|
6
|
+
|
|
7
|
+
from ..adt_types import ResourceNode, ResourceRelationship
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def ensure_unique_nodes(nodes: Iterable[ResourceNode]) -> List[ResourceNode]:
|
|
11
|
+
"""Remove duplicate nodes preserving last occurrence."""
|
|
12
|
+
|
|
13
|
+
seen = {}
|
|
14
|
+
for node in nodes:
|
|
15
|
+
if node.id in seen:
|
|
16
|
+
del seen[node.id]
|
|
17
|
+
seen[node.id] = node
|
|
18
|
+
return list(seen.values())
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def build_graph_edges(
|
|
22
|
+
nodes: Iterable[ResourceNode],
|
|
23
|
+
include_relationships: bool,
|
|
24
|
+
) -> List[ResourceRelationship]:
|
|
25
|
+
"""Create inferred relationships if required."""
|
|
26
|
+
|
|
27
|
+
if not include_relationships:
|
|
28
|
+
return []
|
|
29
|
+
|
|
30
|
+
node_map = {node.id: node for node in nodes}
|
|
31
|
+
edges: List[ResourceRelationship] = []
|
|
32
|
+
for node in node_map.values():
|
|
33
|
+
for dependency_id in node.dependencies:
|
|
34
|
+
if dependency_id in node_map:
|
|
35
|
+
edges.append(
|
|
36
|
+
ResourceRelationship(
|
|
37
|
+
source_id=node.id,
|
|
38
|
+
target_id=dependency_id,
|
|
39
|
+
relation_type="depends_on",
|
|
40
|
+
)
|
|
41
|
+
)
|
|
42
|
+
return edges
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Structured logging helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import os
|
|
8
|
+
import sys
|
|
9
|
+
from typing import Any, Dict
|
|
10
|
+
|
|
11
|
+
_LOGGER_NAME = "azure_discovery"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _build_json_formatter() -> logging.Formatter:
|
|
15
|
+
class JsonFormatter(logging.Formatter):
|
|
16
|
+
def format(self, record: logging.LogRecord) -> str:
|
|
17
|
+
payload: Dict[str, Any] = {
|
|
18
|
+
"logger": record.name,
|
|
19
|
+
"level": record.levelname,
|
|
20
|
+
"message": record.getMessage(),
|
|
21
|
+
"module": record.module,
|
|
22
|
+
"function": record.funcName,
|
|
23
|
+
}
|
|
24
|
+
if record.exc_info:
|
|
25
|
+
payload["exc_info"] = self.formatException(record.exc_info)
|
|
26
|
+
if record.__dict__.get("context"):
|
|
27
|
+
payload["context"] = record.__dict__["context"]
|
|
28
|
+
return json.dumps(payload)
|
|
29
|
+
|
|
30
|
+
return JsonFormatter()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def get_logger(quiet: bool = False) -> logging.Logger:
|
|
34
|
+
"""Return module-wide structured logger.
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
quiet: If True, only log ERROR and above. Logs always go to stderr.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
logger = logging.getLogger(_LOGGER_NAME)
|
|
41
|
+
if logger.handlers:
|
|
42
|
+
# Update log level if quiet mode changed
|
|
43
|
+
if quiet:
|
|
44
|
+
logger.setLevel(logging.ERROR)
|
|
45
|
+
return logger
|
|
46
|
+
|
|
47
|
+
# Always use stderr for logs, never stdout
|
|
48
|
+
handler = logging.StreamHandler(sys.stderr)
|
|
49
|
+
handler.setFormatter(_build_json_formatter())
|
|
50
|
+
logger.addHandler(handler)
|
|
51
|
+
|
|
52
|
+
if quiet:
|
|
53
|
+
logger.setLevel(logging.ERROR)
|
|
54
|
+
else:
|
|
55
|
+
env_level = os.getenv("AZURE_DISCOVERY_LOG_LEVEL", "INFO").upper()
|
|
56
|
+
logger.setLevel(env_level)
|
|
57
|
+
|
|
58
|
+
logger.propagate = False
|
|
59
|
+
return logger
|
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: azure-discovery
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Lightweight Azure tenant discovery and visualization via Resource Graph. Enumerates subscriptions and resources, normalizes results, and renders interactive dependency graphs. Supports public and sovereign clouds (Gov, China, Germany, Azure Stack).
|
|
5
|
+
Author: David Frazer <david.frazer336@gmail.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Documentation, https://github.com/maravedi/AzureDiscovery#readme
|
|
8
|
+
Project-URL: Repository, https://github.com/maravedi/AzureDiscovery
|
|
9
|
+
Project-URL: Bug Tracker, https://github.com/maravedi/AzureDiscovery/issues
|
|
10
|
+
Keywords: azure,resource-graph,discovery,inventory,sovereign-cloud,visualization
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Topic :: System :: Systems Administration
|
|
18
|
+
Requires-Python: >=3.11
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
License-File: LICENSE
|
|
21
|
+
Requires-Dist: pydantic>=2.7
|
|
22
|
+
Requires-Dist: fastapi>=0.110
|
|
23
|
+
Requires-Dist: uvicorn>=0.30
|
|
24
|
+
Requires-Dist: typer>=0.12
|
|
25
|
+
Requires-Dist: pyvis>=0.3.2
|
|
26
|
+
Requires-Dist: azure-identity>=1.17
|
|
27
|
+
Requires-Dist: azure-mgmt-resourcegraph>=8.0
|
|
28
|
+
Requires-Dist: azure-mgmt-subscription>=3.1
|
|
29
|
+
Provides-Extra: dev
|
|
30
|
+
Requires-Dist: pytest>=8.2; extra == "dev"
|
|
31
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
|
|
32
|
+
Requires-Dist: pytest-cov>=5.0; extra == "dev"
|
|
33
|
+
Requires-Dist: ruff>=0.4.0; extra == "dev"
|
|
34
|
+
Requires-Dist: mypy>=1.10; extra == "dev"
|
|
35
|
+
Dynamic: license-file
|
|
36
|
+
|
|
37
|
+
# Azure Discovery
|
|
38
|
+
|
|
39
|
+
Azure Discovery is a lightweight Azure tenant mapper that enumerates subscriptions and resources via Azure Resource Graph, normalizes the results, and renders an interactive dependency graph. The tool exposes both a Typer-based CLI (`azure-discovery`) and a FastAPI surface so the same discovery workflow can be automated or embedded in other services.
|
|
40
|
+
|
|
41
|
+
The package is published on [PyPI](https://pypi.org/project/azure-discovery/) as **azure-discovery** and can be installed with `pip install azure-discovery`.
|
|
42
|
+
|
|
43
|
+
## Core capabilities
|
|
44
|
+
|
|
45
|
+
- Builds environment-aware credential chains (Azure CLI + DefaultAzureCredential) with guardrails for unsupported clouds.
|
|
46
|
+
- Queries Azure Resource Graph with include/exclude filters, tag constraints, and resource group scopes.
|
|
47
|
+
- Resolves subscriptions automatically when not provided and de-duplicates resources for consistent graph IDs.
|
|
48
|
+
- Produces JSON summaries, console metrics, and PyVis HTML graphs for quick triage.
|
|
49
|
+
- Offers identical request/response contracts (Pydantic models) across CLI and API, following the Receive an Object, Return an Object (RORO) pattern.
|
|
50
|
+
- Supports all Azure clouds: public, Government (GCC/GCC-H), China, Germany, and Azure Stack.
|
|
51
|
+
|
|
52
|
+
## Installation
|
|
53
|
+
|
|
54
|
+
**From PyPI (recommended):**
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
pip install azure-discovery
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
**With optional development dependencies:**
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
pip install azure-discovery[dev]
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
**From source (e.g. for development or when embedded in another repo):**
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
git clone https://github.com/maravedi/AzureDiscovery.git
|
|
70
|
+
cd AzureDiscovery
|
|
71
|
+
pip install -e .[dev]
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## Package layout
|
|
75
|
+
|
|
76
|
+
When installed, the package provides the **azure_discovery** Python package:
|
|
77
|
+
|
|
78
|
+
```
|
|
79
|
+
azure_discovery/
|
|
80
|
+
__init__.py # run_discovery, AzureDiscoveryRequest, AzureDiscoveryResponse, etc.
|
|
81
|
+
cli.py # Typer command surface (entry point: azure-discovery)
|
|
82
|
+
api.py # FastAPI app for /discover and visualization endpoints
|
|
83
|
+
orchestrator.py # Async coordinator for enumeration + visualization
|
|
84
|
+
adt_types/ # Pydantic models and custom exceptions
|
|
85
|
+
enumerators/ # Resource Graph query builder and normalization
|
|
86
|
+
reporting/ # Console logging and HTML/PyVis graph generation
|
|
87
|
+
utils/ # Azure SDK clients, graph helpers, structured logging
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
**Programmatic usage:**
|
|
91
|
+
|
|
92
|
+
```python
|
|
93
|
+
from azure_discovery import run_discovery
|
|
94
|
+
from azure_discovery.adt_types import (
|
|
95
|
+
AzureDiscoveryRequest,
|
|
96
|
+
AzureDiscoveryResponse,
|
|
97
|
+
AzureEnvironment,
|
|
98
|
+
DiscoveryFilter,
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
request = AzureDiscoveryRequest(
|
|
102
|
+
tenant_id="<tenant-guid>",
|
|
103
|
+
environment=AzureEnvironment.AZURE_GOV,
|
|
104
|
+
subscriptions=["<sub-id>"],
|
|
105
|
+
filter=DiscoveryFilter(include_types=["Microsoft.Compute/virtualMachines"]),
|
|
106
|
+
)
|
|
107
|
+
response = await run_discovery(request)
|
|
108
|
+
# response.nodes, response.relationships, response.html_report_path
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
To pass your own credential (e.g. for sovereign cloud from another app):
|
|
112
|
+
|
|
113
|
+
```python
|
|
114
|
+
from azure_discovery.enumerators.azure_resources import enumerate_azure_resources
|
|
115
|
+
|
|
116
|
+
response = await enumerate_azure_resources(request, credential=my_credential)
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
## Prerequisites
|
|
120
|
+
|
|
121
|
+
- Python 3.11+
|
|
122
|
+
- Azure CLI 2.60+ (optional, used when `--prefer-cli` is set) or service principal credentials exported as `AZURE_CLIENT_ID`, `AZURE_TENANT_ID`, and `AZURE_CLIENT_SECRET`
|
|
123
|
+
- Azure Resource Graph access (Reader or above on the subscriptions you plan to scan)
|
|
124
|
+
- Network egress to `management.azure.com` and `api.azure.com` (or the sovereign cloud endpoints you select)
|
|
125
|
+
|
|
126
|
+
## Required Azure permissions
|
|
127
|
+
|
|
128
|
+
| Capability | Minimum RBAC role | Scope recommendation |
|
|
129
|
+
| ---------- | ----------------- | -------------------- |
|
|
130
|
+
| Run Resource Graph queries | `Reader`, `Resource Graph Reader`, or any custom role with `Microsoft.ResourceGraph/*/read` | Every subscription you plan to inventory or the parent management group |
|
|
131
|
+
| Auto-discover subscriptions (when `--subscription` is omitted) | `Reader` on the management group or `Directory.Read.All` consent for service principals | Tenant root (`/providers/Microsoft.Management/managementGroups/<root>`) |
|
|
132
|
+
| Register Microsoft.ResourceGraph (one-time) | `Contributor` or `Owner` | Each subscription being scanned |
|
|
133
|
+
|
|
134
|
+
The tool never mutates resources, but it cannot enumerate subscriptions or call Resource Graph unless the identity has at least `Reader` at the relevant scope. Grant the narrowest scope that still covers your target estate (for example, a dedicated management group for security tooling).
|
|
135
|
+
|
|
136
|
+
### Service principal flow (CLI based)
|
|
137
|
+
|
|
138
|
+
```
|
|
139
|
+
az ad sp create-for-rbac \
|
|
140
|
+
--name azure-discovery-sp \
|
|
141
|
+
--role "Reader" \
|
|
142
|
+
--scopes /subscriptions/<sub-id-1> /subscriptions/<sub-id-2> \
|
|
143
|
+
--years 1
|
|
144
|
+
|
|
145
|
+
az role assignment create \
|
|
146
|
+
--assignee <appId> \
|
|
147
|
+
--role "Resource Graph Reader" \
|
|
148
|
+
--scope /subscriptions/<sub-id-1>
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
Export the emitted `appId`, `tenant`, and `password` as `AZURE_CLIENT_ID`, `AZURE_TENANT_ID`, and `AZURE_CLIENT_SECRET`. Repeat the role assignment command for every subscription or assign at the management group scope (`/providers/Microsoft.Management/managementGroups/<mg-id>`) to cover multiple subscriptions at once.
|
|
152
|
+
|
|
153
|
+
### User-assigned permissions (Portal)
|
|
154
|
+
|
|
155
|
+
1. Open Azure Portal → **Subscriptions** → select each target subscription.
|
|
156
|
+
2. Navigate to **Access control (IAM)** → **Add** → **Add role assignment**.
|
|
157
|
+
3. Pick the `Reader` (or `Resource Graph Reader`) role, then select the user or managed identity that will run AzureDiscovery.
|
|
158
|
+
4. If you want automatic subscription discovery, repeat the assignment at the tenant root management group (visible under **Management groups**). Users need the **Azure RBAC Reader** role there.
|
|
159
|
+
|
|
160
|
+
### Provider registration and validation
|
|
161
|
+
|
|
162
|
+
Run the following once per subscription to ensure the Resource Graph service is registered and the identity can query it:
|
|
163
|
+
|
|
164
|
+
```
|
|
165
|
+
az account set --subscription <sub-id>
|
|
166
|
+
az provider register --namespace Microsoft.ResourceGraph
|
|
167
|
+
az graph query -q "Resources | take 1"
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
Successful output from `az graph query` confirms both the provider registration and the assigned role. If the command fails with `AuthorizationFailed`, double-check the scope of the role assignments and replicate them for every subscription you intend to scan.
|
|
171
|
+
|
|
172
|
+
## Getting started
|
|
173
|
+
|
|
174
|
+
1. **Install** (see [Installation](#installation) above).
|
|
175
|
+
|
|
176
|
+
2. **Authenticate to Azure**
|
|
177
|
+
|
|
178
|
+
- Interactive: `az login --tenant <tenant-id>` and, if multiple tenants, `az account set --subscription <sub-id>`.
|
|
179
|
+
- Service principal: export `AZURE_CLIENT_ID`, `AZURE_TENANT_ID`, `AZURE_CLIENT_SECRET`, and (optionally) `AZURE_SUBSCRIPTION_ID`.
|
|
180
|
+
- For sovereign clouds (e.g. Azure Government): set `az cloud set --name AzureUSGovernment` and log in; or use a service principal with the appropriate authority.
|
|
181
|
+
|
|
182
|
+
3. **Run the CLI**
|
|
183
|
+
|
|
184
|
+
After install, the `azure-discovery` console script is on your PATH:
|
|
185
|
+
|
|
186
|
+
```bash
|
|
187
|
+
azure-discovery discover \
|
|
188
|
+
--tenant-id <tenant-guid> \
|
|
189
|
+
--subscription <sub-id-1> --subscription <sub-id-2> \
|
|
190
|
+
--include-type "Microsoft.Compute/virtualMachines" \
|
|
191
|
+
--resource-group core-infra \
|
|
192
|
+
--required-tag environment=prod \
|
|
193
|
+
--visualization-output-dir artifacts/graphs
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
**Run as a module from source** (from the repo root):
|
|
197
|
+
|
|
198
|
+
```bash
|
|
199
|
+
python -m azure_discovery.cli discover --help
|
|
200
|
+
python -m azure_discovery.cli discover --tenant-id <tenant-guid> --environment azure_gov [options...]
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
The command prints the structured `AzureDiscoveryResponse` JSON and writes an interactive HTML graph under `artifacts/graphs/`.
|
|
204
|
+
|
|
205
|
+
4. **Run the API (optional)**
|
|
206
|
+
|
|
207
|
+
```bash
|
|
208
|
+
uvicorn azure_discovery.api:app --host 0.0.0.0 --port 8000 --reload
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
- Health check: `curl http://localhost:8000/healthz`
|
|
212
|
+
- Discovery: `curl -X POST http://localhost:8000/discover -H "Content-Type: application/json" -d '{"tenant_id": "...", ... }'`
|
|
213
|
+
- Download visualization: `curl http://localhost:8000/visuals/<file-name> --output graph.html`
|
|
214
|
+
|
|
215
|
+
## Configuration reference
|
|
216
|
+
|
|
217
|
+
| Option | Description |
|
|
218
|
+
| ------ | ----------- |
|
|
219
|
+
| `--tenant-id` | Required Entra ID tenant GUID. |
|
|
220
|
+
| `--environment` | Azure cloud (`azure_public`, `azure_gov`, `azure_china`, `azure_germany`, `azure_stack`). |
|
|
221
|
+
| `--subscription/-s` | Repeatable flag to scope runs to explicit subscription IDs. Omit to auto-resolve. |
|
|
222
|
+
| `--include-type` / `--exclude-type` | Filter resource types (case-insensitive). |
|
|
223
|
+
| `--resource-group` | Restrict discovery to named resource groups. |
|
|
224
|
+
| `--required-tag` | Enforce tag key=value pairs (repeatable). |
|
|
225
|
+
| `--prefer-cli` | Place Azure CLI credentials at the front of the chain. |
|
|
226
|
+
| `--visualization-output-dir` | Directory for PyVis HTML output (default `artifacts/graphs`). |
|
|
227
|
+
| `--visualization-file` | Override the generated HTML file name. |
|
|
228
|
+
| `--output/-o` | Write JSON output to file instead of stdout. |
|
|
229
|
+
| `--quiet/-q` | Suppress all logs except errors. |
|
|
230
|
+
| `--format/-f` | Output format: `json` (default) or `json-compact`. |
|
|
231
|
+
|
|
232
|
+
Programmatic workflows can instantiate `AzureDiscoveryRequest` directly and call `orchestrator.run_discovery`, receiving an `AzureDiscoveryResponse` that contains resolved subscriptions, normalized nodes, inferred relationships, and an optional `html_report_path`.
|
|
233
|
+
|
|
234
|
+
### Output and logging separation
|
|
235
|
+
|
|
236
|
+
By default, the CLI writes JSON results to stdout and logs to stderr. This allows clean piping:
|
|
237
|
+
|
|
238
|
+
```bash
|
|
239
|
+
# Pipe JSON output to jq for filtering
|
|
240
|
+
azure-discovery discover --tenant-id <id> | jq '.discovered_subscriptions'
|
|
241
|
+
|
|
242
|
+
# Write output to file and suppress logs
|
|
243
|
+
azure-discovery discover --tenant-id <id> --output results.json --quiet
|
|
244
|
+
|
|
245
|
+
# Compact JSON output for scripting
|
|
246
|
+
azure-discovery discover --tenant-id <id> --format json-compact
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
## Development
|
|
250
|
+
|
|
251
|
+
### Quick start
|
|
252
|
+
|
|
253
|
+
```bash
|
|
254
|
+
# Install with development dependencies
|
|
255
|
+
make install-dev
|
|
256
|
+
|
|
257
|
+
# Run tests
|
|
258
|
+
make test
|
|
259
|
+
|
|
260
|
+
# Format code
|
|
261
|
+
make format
|
|
262
|
+
|
|
263
|
+
# Run linting
|
|
264
|
+
make lint
|
|
265
|
+
|
|
266
|
+
# Type checking
|
|
267
|
+
make typecheck
|
|
268
|
+
|
|
269
|
+
# Generate coverage report
|
|
270
|
+
make coverage
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
### Available make commands
|
|
274
|
+
|
|
275
|
+
Run `make help` to see all available commands:
|
|
276
|
+
- `make install` - Install package dependencies
|
|
277
|
+
- `make install-dev` - Install with development dependencies
|
|
278
|
+
- `make test` - Run tests with pytest
|
|
279
|
+
- `make lint` - Run ruff linter
|
|
280
|
+
- `make format` - Format code with ruff
|
|
281
|
+
- `make typecheck` - Run mypy type checking
|
|
282
|
+
- `make coverage` - Generate test coverage report
|
|
283
|
+
- `make clean` - Remove build artifacts and cache
|
|
284
|
+
- `make run-api` - Run FastAPI server locally
|
|
285
|
+
|
|
286
|
+
### Pre-commit hooks
|
|
287
|
+
|
|
288
|
+
Install pre-commit hooks to automatically run linting and formatting on commit:
|
|
289
|
+
|
|
290
|
+
```bash
|
|
291
|
+
pip install pre-commit
|
|
292
|
+
pre-commit install
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
This will run ruff formatting, linting, and mypy type checking before each commit.
|
|
296
|
+
|
|
297
|
+
### Environment variables
|
|
298
|
+
|
|
299
|
+
Copy `.env.example` to `.env` and configure your Azure credentials:
|
|
300
|
+
|
|
301
|
+
```bash
|
|
302
|
+
cp .env.example .env
|
|
303
|
+
# Edit .env with your credentials
|
|
304
|
+
```
|
|
305
|
+
|
|
306
|
+
For detailed contributing guidelines, see [CONTRIBUTING.md](CONTRIBUTING.md).
|
|
307
|
+
|
|
308
|
+
## Troubleshooting
|
|
309
|
+
|
|
310
|
+
- **`AzureClientError: Unable to enumerate subscriptions`** – ensure the identity has at least `Reader` on one subscription and that the Resource Graph service is registered (`az provider register --namespace Microsoft.ResourceGraph`).
|
|
311
|
+
- **`Resource Graph query failure`** – check that the tenant/subscription pair belongs to the same cloud you selected, and verify network egress to the relevant `resource_manager` endpoint (see `_ENVIRONMENT_MAP` in `azure_discovery.utils.azure_clients`).
|
|
312
|
+
- **`VisualizationError: Failed to render HTML graph`** – confirm the `--visualization-output-dir` path exists and is writable; the PyVis writer does not auto-create directories unless it has permissions on each parent.
|
|
313
|
+
- **`401` or `interaction_required` errors** – when running non-interactively, use a service principal credential chain and set `AZURE_CLIENT_SECRET`; the default chain will otherwise attempt to launch an interactive browser flow.
|
|
314
|
+
- **Empty graph output** – verify filters are not mutually exclusive (e.g., mixing include/exclude for the same type) and that `--max-batch-size` in the request (via API) is not set below the minimum (100).
|
|
315
|
+
|
|
316
|
+
## Known gaps and future opportunities
|
|
317
|
+
|
|
318
|
+
- **Identity & RBAC coverage** – the tool currently inventories Azure Resource Manager assets only; Entra ID objects, role assignments, and policy definitions are not ingested or visualized.
|
|
319
|
+
- **Change tracking** – runs are stateless; there is no persistence layer or diffing between historical discoveries.
|
|
320
|
+
- **Security context** – vulnerability, compliance, and workload insights (e.g., Defender for Cloud signals) are not integrated.
|
|
321
|
+
- **API hardening** – the FastAPI surface is unprotected (no authN/Z, rate limiting, or request quotas); deploy behind an API gateway or add middleware before production use.
|
|
322
|
+
- **Scale controls** – back-off and throttling are minimal (`throttle_delay_seconds` only); there is no adaptive rate control or batching heuristics for very large tenants.
|
|
323
|
+
|
|
324
|
+
These items are valuable next steps if you intend to operationalize Azure Discovery beyond ad-hoc investigations.
|
|
325
|
+
|
|
326
|
+
## Publishing to PyPI (maintainers)
|
|
327
|
+
|
|
328
|
+
To publish a new version to PyPI:
|
|
329
|
+
|
|
330
|
+
1. Bump `version` in `pyproject.toml`.
|
|
331
|
+
2. Ensure tests pass: `pip install -e .[dev] && pytest`.
|
|
332
|
+
3. Build: `python -m build`.
|
|
333
|
+
4. Upload: `twine upload dist/azure-discovery-<version>*` (requires PyPI credentials or token).
|
|
334
|
+
|
|
335
|
+
The package uses a single top-level package **azure_discovery** to avoid namespace conflicts on install. The console script **azure-discovery** is provided by the `[project.scripts]` entry in `pyproject.toml`.
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
azure_discovery/__init__.py,sha256=tK8HAgGDPhdvsTOdWFHZK0pB5JUZBo6G23w7np2w8dE,835
|
|
2
|
+
azure_discovery/api.py,sha256=J1iRt3SyVqbSbVpvQgfQcwDtzGK-PT7QlU5QQDTnv_k,1290
|
|
3
|
+
azure_discovery/cli.py,sha256=Qqjifxu-zbsktkxpA0_8LI7PntgnmUOvs1NlcCu_Vj0,4233
|
|
4
|
+
azure_discovery/orchestrator.py,sha256=xD_W0qHw46ahia95Lbam2UUa13jw4jnStWc-DE7nQLU,839
|
|
5
|
+
azure_discovery/adt_types/__init__.py,sha256=wKv5ggC77-hCve-96VWnkjsjAe-zbzbcHEi1jQCCjOw,433
|
|
6
|
+
azure_discovery/adt_types/errors.py,sha256=_I_2CTzEowzoXEXZcJEcyknmOYovG30IvX-jXt7d0RE,340
|
|
7
|
+
azure_discovery/adt_types/models.py,sha256=KsT1WCUByCJYaWHTA0soVg7eM3LQNXPqWueabLFh69Y,3291
|
|
8
|
+
azure_discovery/enumerators/__init__.py,sha256=NN13QgAU32Olw96YRNGNwzS3jB3aKY_elYbzGJ1tubQ,120
|
|
9
|
+
azure_discovery/enumerators/azure_resources.py,sha256=jlWpsP3RpWlNk7H_Fw_pWfTOrT_01M43zaQWnbOmiZE,4533
|
|
10
|
+
azure_discovery/reporting/__init__.py,sha256=hEqI9Aaoik3GNqf-cqG_MXfX6HxcJVHUZBHrzLbMVlA,153
|
|
11
|
+
azure_discovery/reporting/console.py,sha256=3aEq7cG0Zq0Malxsj7qroTsVWRxI3to8kUPNdBnaNjU,785
|
|
12
|
+
azure_discovery/reporting/html.py,sha256=jH4BJjZFGFpoSvquxnosMx1AIyWfUf0immdDnqm-ElM,2154
|
|
13
|
+
azure_discovery/utils/__init__.py,sha256=oIrIQN9OPeaE3djBcB5sR1PhBpkdkw9lA8QEEv4agHY,292
|
|
14
|
+
azure_discovery/utils/azure_clients.py,sha256=TG4tWivxE2IvKSBGRUtWF0eA25Wb0GrILor2nrfcwmY,5972
|
|
15
|
+
azure_discovery/utils/graph_helpers.py,sha256=XQTqEg9SPU6V3r2qas2BII6-KquBizc5DclbCLAfLKg,1181
|
|
16
|
+
azure_discovery/utils/logging.py,sha256=LvT9Fea__hqOq2UwScHpT8eElW4ArzwyZwVMBSMUgoc,1692
|
|
17
|
+
azure_discovery-0.1.0.dist-info/licenses/LICENSE,sha256=H5Cd8vqg-_kMV2v3EQEc9JgLWLa-tZKrn8ekdbVRE_0,1085
|
|
18
|
+
azure_discovery-0.1.0.dist-info/METADATA,sha256=ruZ-wVxReb5QSxtV2WEXSV6SWJ7s_ViUQ-DGu4MCbCI,15516
|
|
19
|
+
azure_discovery-0.1.0.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
|
|
20
|
+
azure_discovery-0.1.0.dist-info/entry_points.txt,sha256=ZoF7ybEeKIn5Q2Rklb77swS2lZ-4L_NzXhUOo1pFttM,61
|
|
21
|
+
azure_discovery-0.1.0.dist-info/top_level.txt,sha256=aui6halihvG0KwHYrQYpTpu5gO-dI-Xr3nQ1JRB1vIk,16
|
|
22
|
+
azure_discovery-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Azure Discovery Contributors
|
|
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
|
+
azure_discovery
|