granny-devops 0.9.2__tar.gz → 0.9.3__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.
Files changed (85) hide show
  1. {granny_devops-0.9.2 → granny_devops-0.9.3}/PKG-INFO +10 -1
  2. {granny_devops-0.9.2 → granny_devops-0.9.3}/README.md +9 -0
  3. {granny_devops-0.9.2 → granny_devops-0.9.3}/granny/__init__.py +1 -1
  4. granny_devops-0.9.3/granny/cli/elk.py +192 -0
  5. {granny_devops-0.9.2 → granny_devops-0.9.3}/granny/cli/main.py +5 -0
  6. {granny_devops-0.9.2 → granny_devops-0.9.3}/granny/credentials/secrets.py +4 -0
  7. granny_devops-0.9.3/granny/elk/__init__.py +5 -0
  8. granny_devops-0.9.3/granny/elk/client.py +149 -0
  9. {granny_devops-0.9.2 → granny_devops-0.9.3}/pyproject.toml +1 -1
  10. {granny_devops-0.9.2 → granny_devops-0.9.3}/.gitignore +0 -0
  11. {granny_devops-0.9.2 → granny_devops-0.9.3}/LICENSE +0 -0
  12. {granny_devops-0.9.2 → granny_devops-0.9.3}/granny/analyze/__init__.py +0 -0
  13. {granny_devops-0.9.2 → granny_devops-0.9.3}/granny/analyze/costs.py +0 -0
  14. {granny_devops-0.9.2 → granny_devops-0.9.3}/granny/analyze/credits.py +0 -0
  15. {granny_devops-0.9.2 → granny_devops-0.9.3}/granny/analyze/gpus.py +0 -0
  16. {granny_devops-0.9.2 → granny_devops-0.9.3}/granny/analyze/lambdas.py +0 -0
  17. {granny_devops-0.9.2 → granny_devops-0.9.3}/granny/analyze/vpcs.py +0 -0
  18. {granny_devops-0.9.2 → granny_devops-0.9.3}/granny/authentik/__init__.py +0 -0
  19. {granny_devops-0.9.2 → granny_devops-0.9.3}/granny/authentik/client.py +0 -0
  20. {granny_devops-0.9.2 → granny_devops-0.9.3}/granny/authentik/provision.py +0 -0
  21. {granny_devops-0.9.2 → granny_devops-0.9.3}/granny/cdn/__init__.py +0 -0
  22. {granny_devops-0.9.2 → granny_devops-0.9.3}/granny/cdn/bunny.py +0 -0
  23. {granny_devops-0.9.2 → granny_devops-0.9.3}/granny/cli/__init__.py +0 -0
  24. {granny_devops-0.9.2 → granny_devops-0.9.3}/granny/cli/analyze.py +0 -0
  25. {granny_devops-0.9.2 → granny_devops-0.9.3}/granny/cli/authentik.py +0 -0
  26. {granny_devops-0.9.2 → granny_devops-0.9.3}/granny/cli/cdn.py +0 -0
  27. {granny_devops-0.9.2 → granny_devops-0.9.3}/granny/cli/cloudflare.py +0 -0
  28. {granny_devops-0.9.2 → granny_devops-0.9.3}/granny/cli/create.py +0 -0
  29. {granny_devops-0.9.2 → granny_devops-0.9.3}/granny/cli/credentials.py +0 -0
  30. {granny_devops-0.9.2 → granny_devops-0.9.3}/granny/cli/dns.py +0 -0
  31. {granny_devops-0.9.2 → granny_devops-0.9.3}/granny/cli/docker.py +0 -0
  32. {granny_devops-0.9.2 → granny_devops-0.9.3}/granny/cli/edge.py +0 -0
  33. {granny_devops-0.9.2 → granny_devops-0.9.3}/granny/cli/email.py +0 -0
  34. {granny_devops-0.9.2 → granny_devops-0.9.3}/granny/cli/serverless.py +0 -0
  35. {granny_devops-0.9.2 → granny_devops-0.9.3}/granny/cli/storage.py +0 -0
  36. {granny_devops-0.9.2 → granny_devops-0.9.3}/granny/cloudflare/__init__.py +0 -0
  37. {granny_devops-0.9.2 → granny_devops-0.9.3}/granny/cloudflare/d1.py +0 -0
  38. {granny_devops-0.9.2 → granny_devops-0.9.3}/granny/cloudflare/r2.py +0 -0
  39. {granny_devops-0.9.2 → granny_devops-0.9.3}/granny/cloudflare/workers.py +0 -0
  40. {granny_devops-0.9.2 → granny_devops-0.9.3}/granny/create/__init__.py +0 -0
  41. {granny_devops-0.9.2 → granny_devops-0.9.3}/granny/create/auto_certificate.py +0 -0
  42. {granny_devops-0.9.2 → granny_devops-0.9.3}/granny/create/cloudfront-security-headers.js +0 -0
  43. {granny_devops-0.9.2 → granny_devops-0.9.3}/granny/create/manage-dns.sh +0 -0
  44. {granny_devops-0.9.2 → granny_devops-0.9.3}/granny/create/manage_mailjet_contacts.py +0 -0
  45. {granny_devops-0.9.2 → granny_devops-0.9.3}/granny/create/registrars.py +0 -0
  46. {granny_devops-0.9.2 → granny_devops-0.9.3}/granny/create/setup_aws_cloudfront.py +0 -0
  47. {granny_devops-0.9.2 → granny_devops-0.9.3}/granny/create/setup_bunny_edge_script.py +0 -0
  48. {granny_devops-0.9.2 → granny_devops-0.9.3}/granny/create/setup_bunny_storage.py +0 -0
  49. {granny_devops-0.9.2 → granny_devops-0.9.3}/granny/create/setup_cognito_identity_pool.py +0 -0
  50. {granny_devops-0.9.2 → granny_devops-0.9.3}/granny/create/setup_hetzner_bunny.py +0 -0
  51. {granny_devops-0.9.2 → granny_devops-0.9.3}/granny/create/setup_mailjet_dns.py +0 -0
  52. {granny_devops-0.9.2 → granny_devops-0.9.3}/granny/create/setup_private_cdn.py +0 -0
  53. {granny_devops-0.9.2 → granny_devops-0.9.3}/granny/create/setup_s3_website.py +0 -0
  54. {granny_devops-0.9.2 → granny_devops-0.9.3}/granny/create/setup_scaleway_container.py +0 -0
  55. {granny_devops-0.9.2 → granny_devops-0.9.3}/granny/create/setup_scaleway_faas.py +0 -0
  56. {granny_devops-0.9.2 → granny_devops-0.9.3}/granny/create/setup_workmail.py +0 -0
  57. {granny_devops-0.9.2 → granny_devops-0.9.3}/granny/create/www-redirect-function.js +0 -0
  58. {granny_devops-0.9.2 → granny_devops-0.9.3}/granny/credentials/__init__.py +0 -0
  59. {granny_devops-0.9.2 → granny_devops-0.9.3}/granny/dns/__init__.py +0 -0
  60. {granny_devops-0.9.2 → granny_devops-0.9.3}/granny/dns/base.py +0 -0
  61. {granny_devops-0.9.2 → granny_devops-0.9.3}/granny/dns/bunny.py +0 -0
  62. {granny_devops-0.9.2 → granny_devops-0.9.3}/granny/dns/cloudflare.py +0 -0
  63. {granny_devops-0.9.2 → granny_devops-0.9.3}/granny/dns/cloudns.py +0 -0
  64. {granny_devops-0.9.2 → granny_devops-0.9.3}/granny/dns/desec.py +0 -0
  65. {granny_devops-0.9.2 → granny_devops-0.9.3}/granny/dns/factory.py +0 -0
  66. {granny_devops-0.9.2 → granny_devops-0.9.3}/granny/dns/hetzner.py +0 -0
  67. {granny_devops-0.9.2 → granny_devops-0.9.3}/granny/dns/inwx.py +0 -0
  68. {granny_devops-0.9.2 → granny_devops-0.9.3}/granny/dns/manual.py +0 -0
  69. {granny_devops-0.9.2 → granny_devops-0.9.3}/granny/dns/records.py +0 -0
  70. {granny_devops-0.9.2 → granny_devops-0.9.3}/granny/docker/__init__.py +0 -0
  71. {granny_devops-0.9.2 → granny_devops-0.9.3}/granny/docker/build_base.py +0 -0
  72. {granny_devops-0.9.2 → granny_devops-0.9.3}/granny/edge/__init__.py +0 -0
  73. {granny_devops-0.9.2 → granny_devops-0.9.3}/granny/edge/bunny.py +0 -0
  74. {granny_devops-0.9.2 → granny_devops-0.9.3}/granny/email/__init__.py +0 -0
  75. {granny_devops-0.9.2 → granny_devops-0.9.3}/granny/email/mailjet.py +0 -0
  76. {granny_devops-0.9.2 → granny_devops-0.9.3}/granny/email/mailjet_contacts.py +0 -0
  77. {granny_devops-0.9.2 → granny_devops-0.9.3}/granny/email/ses_forwarding.py +0 -0
  78. {granny_devops-0.9.2 → granny_devops-0.9.3}/granny/email/workmail.py +0 -0
  79. {granny_devops-0.9.2 → granny_devops-0.9.3}/granny/report.py +0 -0
  80. {granny_devops-0.9.2 → granny_devops-0.9.3}/granny/serverless/__init__.py +0 -0
  81. {granny_devops-0.9.2 → granny_devops-0.9.3}/granny/serverless/scaleway.py +0 -0
  82. {granny_devops-0.9.2 → granny_devops-0.9.3}/granny/storage/__init__.py +0 -0
  83. {granny_devops-0.9.2 → granny_devops-0.9.3}/granny/storage/aws.py +0 -0
  84. {granny_devops-0.9.2 → granny_devops-0.9.3}/granny/storage/bunny.py +0 -0
  85. {granny_devops-0.9.2 → granny_devops-0.9.3}/granny/storage/hetzner.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: granny-devops
3
- Version: 0.9.2
3
+ Version: 0.9.3
4
4
  Summary: Cloud tools collection -- AWS infrastructure, CDN, and DevOps automation
5
5
  Author-email: Martin Wieser <martin.wieser@pseekoo.com>
6
6
  License: MIT License
@@ -221,6 +221,7 @@ Common keys:
221
221
  | ClouDNS | `CLOUDNS_AUTH_ID`/`_PASSWORD` (or `_SUB_AUTH_ID`/`_SUB_AUTH_USER`) |
222
222
  | INWX | `INWX_USERNAME`, `INWX_PASSWORD`, `INWX_SHARED_SECRET` (only with 2FA) |
223
223
  | Docker Hub | `DOCKER_HUB_USER`, `DOCKER_HUB_TOKEN` |
224
+ | Elasticsearch / Kibana | `ELASTICSEARCH_URL`, `ELASTICSEARCH_API_KEY` or `ELASTICSEARCH_USERNAME` + `ELASTICSEARCH_PASSWORD` |
224
225
 
225
226
  Set only the ones you need. Use `granny credentials status` to verify
226
227
  what's configured at any time.
@@ -297,6 +298,13 @@ granny authentik list providers
297
298
  granny authentik rotate-secret my-oauth-provider
298
299
  granny authentik add-user-to-group user@example.com # defaults to dash_admins
299
300
  granny authentik api GET /api/v3/core/users/me/ # generic escape hatch
301
+
302
+ # Elasticsearch / Kibana users
303
+ granny elk add-user user@example.com \
304
+ --email user@example.com \
305
+ --full-name "Example User" \
306
+ --role kibana_admin \
307
+ --generate-password
300
308
  ```
301
309
 
302
310
  ## Capability matrix
@@ -314,6 +322,7 @@ granny authentik api GET /api/v3/core/users/me/ # generic escape hatch
314
322
  | Cross-cloud inventory | GPU instances + reservations, credit balances, MTD spend + forecast (AWS, GCP, Azure) |
315
323
  | SSL automation | Bunny, Cloudflare, ACM |
316
324
  | SSO / IdP | Authentik (provider, application, group, and user operations) |
325
+ | Observability admin | Elasticsearch / Kibana native-user management |
317
326
 
318
327
  ## As a library
319
328
 
@@ -89,6 +89,7 @@ Common keys:
89
89
  | ClouDNS | `CLOUDNS_AUTH_ID`/`_PASSWORD` (or `_SUB_AUTH_ID`/`_SUB_AUTH_USER`) |
90
90
  | INWX | `INWX_USERNAME`, `INWX_PASSWORD`, `INWX_SHARED_SECRET` (only with 2FA) |
91
91
  | Docker Hub | `DOCKER_HUB_USER`, `DOCKER_HUB_TOKEN` |
92
+ | Elasticsearch / Kibana | `ELASTICSEARCH_URL`, `ELASTICSEARCH_API_KEY` or `ELASTICSEARCH_USERNAME` + `ELASTICSEARCH_PASSWORD` |
92
93
 
93
94
  Set only the ones you need. Use `granny credentials status` to verify
94
95
  what's configured at any time.
@@ -165,6 +166,13 @@ granny authentik list providers
165
166
  granny authentik rotate-secret my-oauth-provider
166
167
  granny authentik add-user-to-group user@example.com # defaults to dash_admins
167
168
  granny authentik api GET /api/v3/core/users/me/ # generic escape hatch
169
+
170
+ # Elasticsearch / Kibana users
171
+ granny elk add-user user@example.com \
172
+ --email user@example.com \
173
+ --full-name "Example User" \
174
+ --role kibana_admin \
175
+ --generate-password
168
176
  ```
169
177
 
170
178
  ## Capability matrix
@@ -182,6 +190,7 @@ granny authentik api GET /api/v3/core/users/me/ # generic escape hatch
182
190
  | Cross-cloud inventory | GPU instances + reservations, credit balances, MTD spend + forecast (AWS, GCP, Azure) |
183
191
  | SSL automation | Bunny, Cloudflare, ACM |
184
192
  | SSO / IdP | Authentik (provider, application, group, and user operations) |
193
+ | Observability admin | Elasticsearch / Kibana native-user management |
185
194
 
186
195
  ## As a library
187
196
 
@@ -1,6 +1,6 @@
1
1
  """Granny -- Cloud tools collection for AWS infrastructure and DevOps automation."""
2
2
 
3
- __version__ = "0.9.2"
3
+ __version__ = "0.9.3"
4
4
  __all__ = [
5
5
  "get_secret",
6
6
  "load_secrets_into_env",
@@ -0,0 +1,192 @@
1
+ """``granny elk`` — manage Elasticsearch / Kibana users."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import getpass
6
+ import json
7
+ import sys
8
+ from typing import Any
9
+
10
+ import click
11
+
12
+ from granny.elk.client import (
13
+ ElasticsearchSecurityClient,
14
+ ElasticsearchSecurityError,
15
+ generate_password as generate_elk_password,
16
+ )
17
+
18
+
19
+ @click.group()
20
+ def elk() -> None:
21
+ """Manage Elasticsearch / Kibana security objects."""
22
+
23
+
24
+ def _client(
25
+ url: str | None,
26
+ admin_username: str | None,
27
+ admin_password: str | None,
28
+ api_key: str | None,
29
+ verify_tls: bool,
30
+ ) -> ElasticsearchSecurityClient:
31
+ try:
32
+ return ElasticsearchSecurityClient.from_environment(
33
+ base_url=url,
34
+ username=admin_username,
35
+ password=admin_password,
36
+ api_key=api_key,
37
+ verify_tls=verify_tls,
38
+ )
39
+ except ElasticsearchSecurityError as exc:
40
+ raise click.ClickException(str(exc)) from exc
41
+
42
+
43
+ _COMMON_OPTIONS = [
44
+ click.option("--url", default=None, help="Elasticsearch URL (env: ELASTICSEARCH_URL)."),
45
+ click.option(
46
+ "--admin-username",
47
+ default=None,
48
+ help="Admin username (env/vault: ELASTICSEARCH_USERNAME).",
49
+ ),
50
+ click.option(
51
+ "--admin-password",
52
+ default=None,
53
+ help="Admin password (env/vault: ELASTICSEARCH_PASSWORD).",
54
+ ),
55
+ click.option(
56
+ "--api-key",
57
+ default=None,
58
+ help="Elasticsearch API key (env/vault: ELASTICSEARCH_API_KEY).",
59
+ ),
60
+ click.option("--verify-tls/--no-verify-tls", default=True, show_default=True),
61
+ ]
62
+
63
+
64
+ def common_options(func: Any) -> Any:
65
+ for option in reversed(_COMMON_OPTIONS):
66
+ func = option(func)
67
+ return func
68
+
69
+
70
+ @elk.command("add-user")
71
+ @click.argument("username")
72
+ @click.option("--email", default=None, help="User email address.")
73
+ @click.option("--full-name", default=None, help="User display name.")
74
+ @click.option(
75
+ "--role",
76
+ "roles",
77
+ multiple=True,
78
+ required=True,
79
+ help="Elasticsearch role. Repeat for multiple roles, e.g. --role kibana_admin.",
80
+ )
81
+ @click.option("--password", default=None, help="Initial password. Prompts if omitted.")
82
+ @click.option(
83
+ "--generate-password",
84
+ is_flag=True,
85
+ help="Generate and print a random initial password instead of prompting.",
86
+ )
87
+ @click.option("--disabled", is_flag=True, help="Create/update the user as disabled.")
88
+ @click.option(
89
+ "--metadata",
90
+ default=None,
91
+ help="Optional JSON object for Elasticsearch user metadata.",
92
+ )
93
+ @click.option("--json", "as_json", is_flag=True, help="Emit JSON result.")
94
+ @common_options
95
+ def add_user_cmd(
96
+ username: str,
97
+ email: str | None,
98
+ full_name: str | None,
99
+ roles: tuple[str, ...],
100
+ password: str | None,
101
+ generate_password: bool,
102
+ disabled: bool,
103
+ metadata: str | None,
104
+ as_json: bool,
105
+ url: str | None,
106
+ admin_username: str | None,
107
+ admin_password: str | None,
108
+ api_key: str | None,
109
+ verify_tls: bool,
110
+ ) -> None:
111
+ """Create or update an Elasticsearch native user for Kibana/ELK access."""
112
+ if password and generate_password:
113
+ raise click.ClickException("Use either --password or --generate-password, not both")
114
+ generated_password: str | None = None
115
+ if generate_password:
116
+ generated_password = generate_elk_password()
117
+ password = generated_password
118
+ elif password is None:
119
+ password = getpass.getpass(f"Initial password for {username}: ")
120
+ confirm = getpass.getpass("Confirm password: ")
121
+ if password != confirm:
122
+ raise click.ClickException("Passwords do not match")
123
+
124
+ parsed_metadata: dict[str, Any] | None = None
125
+ if metadata:
126
+ try:
127
+ raw_metadata = json.loads(metadata)
128
+ except json.JSONDecodeError as exc:
129
+ raise click.ClickException(f"Invalid --metadata JSON: {exc}") from exc
130
+ if not isinstance(raw_metadata, dict):
131
+ raise click.ClickException("--metadata must be a JSON object")
132
+ parsed_metadata = raw_metadata
133
+
134
+ client = _client(url, admin_username, admin_password, api_key, verify_tls)
135
+ try:
136
+ existed_before = client.get_user(username) is not None
137
+ result = client.put_user(
138
+ username,
139
+ password=password,
140
+ roles=roles,
141
+ full_name=full_name,
142
+ email=email,
143
+ enabled=not disabled,
144
+ metadata=parsed_metadata,
145
+ )
146
+ except ElasticsearchSecurityError as exc:
147
+ raise click.ClickException(str(exc)) from exc
148
+
149
+ out = {
150
+ "username": username,
151
+ "created": not existed_before,
152
+ "updated": existed_before,
153
+ "roles": list(roles),
154
+ "enabled": not disabled,
155
+ "result": result,
156
+ }
157
+ if generated_password is not None:
158
+ out["password"] = generated_password
159
+ if as_json:
160
+ click.echo(json.dumps(out, indent=2, default=str))
161
+ return
162
+ click.echo(f"{'Created' if not existed_before else 'Updated'} ELK user {username}")
163
+ click.echo(f"Roles: {', '.join(roles)}")
164
+ if generated_password is not None:
165
+ click.echo(f"Password: {generated_password}")
166
+
167
+ @elk.command("get-user")
168
+ @click.argument("username")
169
+ @click.option("--json", "as_json", is_flag=True, help="Emit raw JSON result.")
170
+ @common_options
171
+ def get_user_cmd(
172
+ username: str,
173
+ as_json: bool,
174
+ url: str | None,
175
+ admin_username: str | None,
176
+ admin_password: str | None,
177
+ api_key: str | None,
178
+ verify_tls: bool,
179
+ ) -> None:
180
+ """Show an Elasticsearch native user."""
181
+ client = _client(url, admin_username, admin_password, api_key, verify_tls)
182
+ try:
183
+ user = client.get_user(username)
184
+ except ElasticsearchSecurityError as exc:
185
+ raise click.ClickException(str(exc)) from exc
186
+ if user is None:
187
+ click.echo(f"User {username!r} not found", err=True)
188
+ sys.exit(1)
189
+ if as_json:
190
+ click.echo(json.dumps(user, indent=2, default=str))
191
+ return
192
+ click.echo(f"{username} | roles={','.join(user.get('roles', []))} | enabled={user.get('enabled')}")
@@ -100,6 +100,11 @@ def _register_commands() -> None:
100
100
  cli.add_command(authentik)
101
101
  except ImportError:
102
102
  pass
103
+ try:
104
+ from granny.cli.elk import elk
105
+ cli.add_command(elk)
106
+ except ImportError:
107
+ pass
103
108
 
104
109
 
105
110
  _register_commands()
@@ -172,6 +172,10 @@ SECRET_MAP: dict[str, str] = {
172
172
  # superuser-issued token (Authentik UI → Directory → Tokens). Falls back
173
173
  # to AK_TOKEN env var in granny.authentik.AuthentikClient.from_environment.
174
174
  "AUTHENTIK_API_TOKEN": "authentik-api-token",
175
+ # Elasticsearch / Kibana security API credentials for `granny elk …`.
176
+ "ELASTICSEARCH_API_KEY": "elasticsearch-api-key",
177
+ "ELASTICSEARCH_USERNAME": "elasticsearch-username",
178
+ "ELASTICSEARCH_PASSWORD": "elasticsearch-password",
175
179
  }
176
180
 
177
181
  # Module-level cache so vault is only contacted once per process
@@ -0,0 +1,5 @@
1
+ """Elasticsearch / Kibana user management helpers."""
2
+
3
+ from granny.elk.client import ElasticsearchSecurityClient, ElasticsearchSecurityError
4
+
5
+ __all__ = ["ElasticsearchSecurityClient", "ElasticsearchSecurityError"]
@@ -0,0 +1,149 @@
1
+ """Thin wrapper around Elasticsearch's security user API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import secrets
7
+ import string
8
+ from typing import Any
9
+
10
+ import requests
11
+
12
+ from granny.credentials import get_secret
13
+
14
+
15
+ class ElasticsearchSecurityError(RuntimeError):
16
+ """Raised on non-2xx responses from Elasticsearch security APIs."""
17
+
18
+ def __init__(self, status: int, body: str) -> None:
19
+ super().__init__(f"HTTP {status}: {body[:400]}")
20
+ self.status = status
21
+ self.body = body
22
+
23
+
24
+ class ElasticsearchSecurityClient:
25
+ """Authenticated client for Elasticsearch native-user management."""
26
+
27
+ def __init__(
28
+ self,
29
+ base_url: str,
30
+ *,
31
+ username: str | None = None,
32
+ password: str | None = None,
33
+ api_key: str | None = None,
34
+ timeout: float = 30.0,
35
+ verify_tls: bool = True,
36
+ ) -> None:
37
+ self.base_url = base_url.rstrip("/")
38
+ self._timeout = timeout
39
+ self._session = requests.Session()
40
+ self._session.headers.update({"Accept": "application/json"})
41
+ self._session.verify = verify_tls
42
+ if api_key:
43
+ self._session.headers["Authorization"] = f"ApiKey {api_key}"
44
+ elif username and password:
45
+ self._session.auth = (username, password)
46
+ else:
47
+ raise ElasticsearchSecurityError(
48
+ 0,
49
+ "Set ELASTICSEARCH_API_KEY or ELASTICSEARCH_USERNAME + "
50
+ "ELASTICSEARCH_PASSWORD.",
51
+ )
52
+
53
+ @classmethod
54
+ def from_environment(
55
+ cls,
56
+ *,
57
+ base_url: str | None = None,
58
+ username: str | None = None,
59
+ password: str | None = None,
60
+ api_key: str | None = None,
61
+ verify_tls: bool | None = None,
62
+ ) -> "ElasticsearchSecurityClient":
63
+ """Build a client from explicit arguments, env vars, and vault fallback."""
64
+ resolved_url = base_url or os.environ.get("ELASTICSEARCH_URL")
65
+ if not resolved_url:
66
+ raise ElasticsearchSecurityError(0, "ELASTICSEARCH_URL is not set")
67
+
68
+ resolved_api_key = api_key or get_secret("ELASTICSEARCH_API_KEY")
69
+ resolved_username = None
70
+ resolved_password = None
71
+ if not resolved_api_key:
72
+ resolved_username = username or get_secret("ELASTICSEARCH_USERNAME")
73
+ resolved_password = password or get_secret("ELASTICSEARCH_PASSWORD")
74
+ if verify_tls is None:
75
+ verify_tls = os.environ.get("ELASTICSEARCH_VERIFY_TLS", "true").lower() not in {
76
+ "0",
77
+ "false",
78
+ "no",
79
+ }
80
+ return cls(
81
+ resolved_url,
82
+ username=resolved_username,
83
+ password=resolved_password,
84
+ api_key=resolved_api_key,
85
+ verify_tls=verify_tls,
86
+ )
87
+
88
+ def request(self, method: str, path: str, *, body: Any = None) -> Any:
89
+ if not path.startswith("/"):
90
+ path = "/" + path
91
+ try:
92
+ resp = self._session.request(
93
+ method.upper(),
94
+ self.base_url + path,
95
+ json=body,
96
+ timeout=self._timeout,
97
+ )
98
+ except requests.RequestException as exc:
99
+ raise ElasticsearchSecurityError(0, str(exc)) from exc
100
+ if resp.status_code >= 400:
101
+ raise ElasticsearchSecurityError(resp.status_code, resp.text)
102
+ if not resp.content:
103
+ return None
104
+ try:
105
+ return resp.json()
106
+ except ValueError:
107
+ return resp.text
108
+
109
+ def get_user(self, username: str) -> dict[str, Any] | None:
110
+ try:
111
+ result = self.request("GET", f"/_security/user/{username}")
112
+ except ElasticsearchSecurityError as exc:
113
+ if exc.status == 404:
114
+ return None
115
+ raise
116
+ if isinstance(result, dict):
117
+ return result.get(username)
118
+ return None
119
+
120
+ def put_user(
121
+ self,
122
+ username: str,
123
+ *,
124
+ roles: list[str] | tuple[str, ...],
125
+ password: str | None = None,
126
+ full_name: str | None = None,
127
+ email: str | None = None,
128
+ enabled: bool = True,
129
+ metadata: dict[str, Any] | None = None,
130
+ ) -> dict[str, Any]:
131
+ """Create or update a native Elasticsearch user."""
132
+ body: dict[str, Any] = {
133
+ "roles": list(roles),
134
+ "enabled": enabled,
135
+ "metadata": metadata or {},
136
+ }
137
+ if password is not None:
138
+ body["password"] = password
139
+ if full_name is not None:
140
+ body["full_name"] = full_name
141
+ if email is not None:
142
+ body["email"] = email
143
+ return self.request("PUT", f"/_security/user/{username}", body=body)
144
+
145
+
146
+ def generate_password(length: int = 24) -> str:
147
+ """Generate a Kibana-friendly random password."""
148
+ alphabet = string.ascii_letters + string.digits + "-_.!~"
149
+ return "".join(secrets.choice(alphabet) for _ in range(length))
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "granny-devops"
3
- version = "0.9.2"
3
+ version = "0.9.3"
4
4
  description = "Cloud tools collection -- AWS infrastructure, CDN, and DevOps automation"
5
5
  readme = "README.md"
6
6
  license = { file = "LICENSE" }
File without changes
File without changes