granny-devops 0.18.0__tar.gz → 0.18.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.
- {granny_devops-0.18.0 → granny_devops-0.18.1}/PKG-INFO +10 -1
- {granny_devops-0.18.0 → granny_devops-0.18.1}/README.md +9 -0
- {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/__init__.py +1 -1
- granny_devops-0.18.1/granny/cli/oauth.py +164 -0
- {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/credentials/secrets.py +1 -0
- granny_devops-0.18.1/granny/oauth/__init__.py +19 -0
- granny_devops-0.18.1/granny/oauth/productivity.py +237 -0
- {granny_devops-0.18.0 → granny_devops-0.18.1}/pyproject.toml +1 -1
- granny_devops-0.18.0/granny/cli/oauth.py +0 -96
- {granny_devops-0.18.0 → granny_devops-0.18.1}/.gitignore +0 -0
- {granny_devops-0.18.0 → granny_devops-0.18.1}/LICENSE +0 -0
- {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/airtable/__init__.py +0 -0
- {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/airtable/client.py +0 -0
- {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/analyze/__init__.py +0 -0
- {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/analyze/costs.py +0 -0
- {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/analyze/credits.py +0 -0
- {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/analyze/gpu_pricing.py +0 -0
- {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/analyze/gpus.py +0 -0
- {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/analyze/kubernetes.py +0 -0
- {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/analyze/lambdas.py +0 -0
- {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/analyze/vpcs.py +0 -0
- {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/authentik/__init__.py +0 -0
- {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/authentik/client.py +0 -0
- {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/authentik/provision.py +0 -0
- {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/aws/__init__.py +0 -0
- {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/aws/bedrock.py +0 -0
- {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/aws/quota.py +0 -0
- {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/azure/__init__.py +0 -0
- {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/azure/_client.py +0 -0
- {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/azure/account.py +0 -0
- {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/azure/deployment.py +0 -0
- {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/azure/openai.py +0 -0
- {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/azure/quota.py +0 -0
- {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/azure/resourcegroup.py +0 -0
- {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/azure/role.py +0 -0
- {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/azure/sku.py +0 -0
- {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/azure/vm.py +0 -0
- {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/azure/webapp.py +0 -0
- {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/cdn/__init__.py +0 -0
- {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/cdn/bunny.py +0 -0
- {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/cli/__init__.py +0 -0
- {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/cli/airtable.py +0 -0
- {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/cli/analyze.py +0 -0
- {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/cli/authentik.py +0 -0
- {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/cli/aws.py +0 -0
- {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/cli/azure.py +0 -0
- {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/cli/cdn.py +0 -0
- {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/cli/cloudflare.py +0 -0
- {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/cli/create.py +0 -0
- {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/cli/credentials.py +0 -0
- {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/cli/dns.py +0 -0
- {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/cli/docker.py +0 -0
- {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/cli/edge.py +0 -0
- {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/cli/elk.py +0 -0
- {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/cli/email.py +0 -0
- {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/cli/indexing.py +0 -0
- {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/cli/knative.py +0 -0
- {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/cli/main.py +0 -0
- {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/cli/serverless.py +0 -0
- {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/cli/storage.py +0 -0
- {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/cli/zitadel.py +0 -0
- {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/cloudflare/__init__.py +0 -0
- {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/cloudflare/d1.py +0 -0
- {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/cloudflare/r2.py +0 -0
- {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/cloudflare/workers.py +0 -0
- {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/create/__init__.py +0 -0
- {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/create/auto_certificate.py +0 -0
- {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/create/cloudfront-security-headers.js +0 -0
- {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/create/letsencrypt_cert.py +0 -0
- {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/create/manage-dns.sh +0 -0
- {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/create/manage_mailjet_contacts.py +0 -0
- {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/create/registrars.py +0 -0
- {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/create/setup_aws_cloudfront.py +0 -0
- {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/create/setup_bunny_edge_script.py +0 -0
- {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/create/setup_bunny_storage.py +0 -0
- {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/create/setup_cognito_identity_pool.py +0 -0
- {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/create/setup_hetzner_bunny.py +0 -0
- {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/create/setup_mailjet_dns.py +0 -0
- {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/create/setup_private_cdn.py +0 -0
- {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/create/setup_s3_website.py +0 -0
- {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/create/setup_scaleway_container.py +0 -0
- {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/create/setup_scaleway_faas.py +0 -0
- {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/create/setup_workmail.py +0 -0
- {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/create/www-redirect-function.js +0 -0
- {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/credentials/__init__.py +0 -0
- {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/dns/__init__.py +0 -0
- {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/dns/base.py +0 -0
- {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/dns/bunny.py +0 -0
- {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/dns/cloudflare.py +0 -0
- {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/dns/cloudns.py +0 -0
- {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/dns/desec.py +0 -0
- {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/dns/factory.py +0 -0
- {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/dns/hetzner.py +0 -0
- {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/dns/inwx.py +0 -0
- {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/dns/manual.py +0 -0
- {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/dns/records.py +0 -0
- {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/docker/__init__.py +0 -0
- {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/docker/build_base.py +0 -0
- {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/edge/__init__.py +0 -0
- {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/edge/bunny.py +0 -0
- {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/elk/__init__.py +0 -0
- {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/elk/client.py +0 -0
- {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/email/__init__.py +0 -0
- {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/email/mailjet.py +0 -0
- {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/email/mailjet_contacts.py +0 -0
- {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/email/ses_forwarding.py +0 -0
- {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/email/stalwart.py +0 -0
- {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/email/workmail.py +0 -0
- {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/indexing/__init__.py +0 -0
- {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/indexing/google.py +0 -0
- {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/indexing/indexnow.py +0 -0
- {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/knative/__init__.py +0 -0
- {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/knative/client.py +0 -0
- {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/report.py +0 -0
- {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/serverless/__init__.py +0 -0
- {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/serverless/scaleway.py +0 -0
- {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/storage/__init__.py +0 -0
- {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/storage/aws.py +0 -0
- {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/storage/bunny.py +0 -0
- {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/storage/hetzner.py +0 -0
- {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/zitadel/__init__.py +0 -0
- {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/zitadel/client.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: granny-devops
|
|
3
|
-
Version: 0.18.
|
|
3
|
+
Version: 0.18.1
|
|
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
|
|
@@ -220,6 +220,7 @@ Common keys:
|
|
|
220
220
|
| Hetzner | `HETZNER_S3_ACCESS_KEY`, `HETZNER_S3_SECRET_KEY`, `HETZNER_DNS_API_TOKEN` |
|
|
221
221
|
| Scaleway | `SCW_ACCESS_KEY`, `SCW_SECRET_KEY`, `SCW_DEFAULT_PROJECT_ID` |
|
|
222
222
|
| Knative FaaS API | `OBACHAN_API_KEY`, `OBACHAN_API_BASE_URL` |
|
|
223
|
+
| Obachan OpenBao | `OBACHAN_BAO_ADDR`, `OBACHAN_BAO_TOKEN` |
|
|
223
224
|
| Mailjet | `MAILJET_API_KEY`, `MAILJET_SECRET_KEY` |
|
|
224
225
|
| deSEC | `DESEC_API_TOKEN` |
|
|
225
226
|
| ClouDNS | `CLOUDNS_AUTH_ID`/`_PASSWORD` (or `_SUB_AUTH_ID`/`_SUB_AUTH_USER`) |
|
|
@@ -235,6 +236,12 @@ what's configured at any time.
|
|
|
235
236
|
```shell
|
|
236
237
|
granny --help # see every command group
|
|
237
238
|
granny <group> --help # drill into one
|
|
239
|
+
|
|
240
|
+
# Provision mail/calendar OAuth clients to a dashboard env and Obachan OpenBao
|
|
241
|
+
granny oauth provision-productivity --env-file ../dashboard/.deploy.env \
|
|
242
|
+
--obachan-env staging --obachan-app dashboard \
|
|
243
|
+
--google-client-json client_secret.json \
|
|
244
|
+
--azure-client-id "$AZURE_CLIENT_ID" --azure-client-secret "$AZURE_CLIENT_SECRET"
|
|
238
245
|
```
|
|
239
246
|
|
|
240
247
|
The CLI is organized by capability, not by provider. You pick the
|
|
@@ -413,6 +420,7 @@ granny indexing google https://example.com/job-posting --action URL_UPDATED
|
|
|
413
420
|
| Observability admin | Elasticsearch / Kibana native-user management |
|
|
414
421
|
| Azure ops | Identity, subscriptions, ARM deployments, Cognitive Services (Azure OpenAI), App Service, VM sizes, VM inventory/deallocate/delete, RBAC role assignment, compute quota |
|
|
415
422
|
| Search indexing | IndexNow (Bing/Yandex/Seznam/Naver/Yep), Google Indexing API |
|
|
423
|
+
| Productivity OAuth | Google Cloud and Microsoft Entra mail/calendar client provisioning |
|
|
416
424
|
|
|
417
425
|
## As a library
|
|
418
426
|
|
|
@@ -456,6 +464,7 @@ granny/
|
|
|
456
464
|
email/ Mailjet, WorkMail, SES forwarding
|
|
457
465
|
indexing/ IndexNow + Google Indexing API
|
|
458
466
|
knative/ Knative FaaS control API client
|
|
467
|
+
oauth/ Google/Microsoft productivity OAuth provisioning
|
|
459
468
|
serverless/ Scaleway FaaS
|
|
460
469
|
storage/ Object storage (AWS / Bunny / Hetzner)
|
|
461
470
|
```
|
|
@@ -86,6 +86,7 @@ Common keys:
|
|
|
86
86
|
| Hetzner | `HETZNER_S3_ACCESS_KEY`, `HETZNER_S3_SECRET_KEY`, `HETZNER_DNS_API_TOKEN` |
|
|
87
87
|
| Scaleway | `SCW_ACCESS_KEY`, `SCW_SECRET_KEY`, `SCW_DEFAULT_PROJECT_ID` |
|
|
88
88
|
| Knative FaaS API | `OBACHAN_API_KEY`, `OBACHAN_API_BASE_URL` |
|
|
89
|
+
| Obachan OpenBao | `OBACHAN_BAO_ADDR`, `OBACHAN_BAO_TOKEN` |
|
|
89
90
|
| Mailjet | `MAILJET_API_KEY`, `MAILJET_SECRET_KEY` |
|
|
90
91
|
| deSEC | `DESEC_API_TOKEN` |
|
|
91
92
|
| ClouDNS | `CLOUDNS_AUTH_ID`/`_PASSWORD` (or `_SUB_AUTH_ID`/`_SUB_AUTH_USER`) |
|
|
@@ -101,6 +102,12 @@ what's configured at any time.
|
|
|
101
102
|
```shell
|
|
102
103
|
granny --help # see every command group
|
|
103
104
|
granny <group> --help # drill into one
|
|
105
|
+
|
|
106
|
+
# Provision mail/calendar OAuth clients to a dashboard env and Obachan OpenBao
|
|
107
|
+
granny oauth provision-productivity --env-file ../dashboard/.deploy.env \
|
|
108
|
+
--obachan-env staging --obachan-app dashboard \
|
|
109
|
+
--google-client-json client_secret.json \
|
|
110
|
+
--azure-client-id "$AZURE_CLIENT_ID" --azure-client-secret "$AZURE_CLIENT_SECRET"
|
|
104
111
|
```
|
|
105
112
|
|
|
106
113
|
The CLI is organized by capability, not by provider. You pick the
|
|
@@ -279,6 +286,7 @@ granny indexing google https://example.com/job-posting --action URL_UPDATED
|
|
|
279
286
|
| Observability admin | Elasticsearch / Kibana native-user management |
|
|
280
287
|
| Azure ops | Identity, subscriptions, ARM deployments, Cognitive Services (Azure OpenAI), App Service, VM sizes, VM inventory/deallocate/delete, RBAC role assignment, compute quota |
|
|
281
288
|
| Search indexing | IndexNow (Bing/Yandex/Seznam/Naver/Yep), Google Indexing API |
|
|
289
|
+
| Productivity OAuth | Google Cloud and Microsoft Entra mail/calendar client provisioning |
|
|
282
290
|
|
|
283
291
|
## As a library
|
|
284
292
|
|
|
@@ -322,6 +330,7 @@ granny/
|
|
|
322
330
|
email/ Mailjet, WorkMail, SES forwarding
|
|
323
331
|
indexing/ IndexNow + Google Indexing API
|
|
324
332
|
knative/ Knative FaaS control API client
|
|
333
|
+
oauth/ Google/Microsoft productivity OAuth provisioning
|
|
325
334
|
serverless/ Scaleway FaaS
|
|
326
335
|
storage/ Object storage (AWS / Bunny / Hetzner)
|
|
327
336
|
```
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""OAuth provisioning commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import click
|
|
8
|
+
|
|
9
|
+
from granny.oauth import (
|
|
10
|
+
ProductivityOAuthCredentials,
|
|
11
|
+
load_google_client_json,
|
|
12
|
+
provision_productivity_oauth,
|
|
13
|
+
update_env_file,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@click.group()
|
|
18
|
+
def oauth() -> None:
|
|
19
|
+
"""Provision OAuth clients for applications."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@oauth.command("provision-productivity")
|
|
23
|
+
@click.option(
|
|
24
|
+
"--env-file",
|
|
25
|
+
type=click.Path(path_type=Path),
|
|
26
|
+
default=None,
|
|
27
|
+
help="Dashboard dotenv file to update.",
|
|
28
|
+
)
|
|
29
|
+
@click.option(
|
|
30
|
+
"--obachan-env",
|
|
31
|
+
default=None,
|
|
32
|
+
help="Obachan environment whose OpenBao application secret is updated.",
|
|
33
|
+
)
|
|
34
|
+
@click.option(
|
|
35
|
+
"--obachan-app",
|
|
36
|
+
default="dashboard",
|
|
37
|
+
show_default=True,
|
|
38
|
+
help="Application name used in the Obachan secret path.",
|
|
39
|
+
)
|
|
40
|
+
@click.option(
|
|
41
|
+
"--google-client-json",
|
|
42
|
+
type=click.Path(exists=True, dir_okay=False, path_type=Path),
|
|
43
|
+
default=None,
|
|
44
|
+
help="OAuth client JSON downloaded from Google Cloud.",
|
|
45
|
+
)
|
|
46
|
+
@click.option("--google-client-id", default=None, help="Google OAuth client ID.")
|
|
47
|
+
@click.option(
|
|
48
|
+
"--google-client-secret",
|
|
49
|
+
default=None,
|
|
50
|
+
help="Google OAuth client secret (not echoed).",
|
|
51
|
+
)
|
|
52
|
+
@click.option("--azure-client-id", default=None, help="Microsoft Entra client ID.")
|
|
53
|
+
@click.option(
|
|
54
|
+
"--azure-client-secret",
|
|
55
|
+
default=None,
|
|
56
|
+
help="Microsoft Entra client secret (not echoed).",
|
|
57
|
+
)
|
|
58
|
+
@click.option(
|
|
59
|
+
"--dry-run", is_flag=True, help="Validate and show destinations without writing."
|
|
60
|
+
)
|
|
61
|
+
def provision_productivity(
|
|
62
|
+
env_file: Path | None,
|
|
63
|
+
obachan_env: str | None,
|
|
64
|
+
obachan_app: str,
|
|
65
|
+
google_client_json: Path | None,
|
|
66
|
+
google_client_id: str | None,
|
|
67
|
+
google_client_secret: str | None,
|
|
68
|
+
azure_client_id: str | None,
|
|
69
|
+
azure_client_secret: str | None,
|
|
70
|
+
dry_run: bool,
|
|
71
|
+
) -> None:
|
|
72
|
+
"""Add Google and Microsoft mail/calendar OAuth clients.
|
|
73
|
+
|
|
74
|
+
Set OBACHAN_BAO_ADDR and OBACHAN_BAO_TOKEN when --obachan-env is used.
|
|
75
|
+
Google Cloud OAuth client downloads can be passed with --google-client-json.
|
|
76
|
+
"""
|
|
77
|
+
if google_client_json:
|
|
78
|
+
if google_client_id or google_client_secret:
|
|
79
|
+
raise click.UsageError(
|
|
80
|
+
"Use --google-client-json or explicit Google values, not both."
|
|
81
|
+
)
|
|
82
|
+
try:
|
|
83
|
+
google_client_id, google_client_secret = load_google_client_json(
|
|
84
|
+
google_client_json
|
|
85
|
+
)
|
|
86
|
+
except ValueError as exc:
|
|
87
|
+
raise click.ClickException(str(exc)) from exc
|
|
88
|
+
|
|
89
|
+
credentials = ProductivityOAuthCredentials(
|
|
90
|
+
google_client_id=google_client_id,
|
|
91
|
+
google_client_secret=google_client_secret,
|
|
92
|
+
azure_client_id=azure_client_id,
|
|
93
|
+
azure_client_secret=azure_client_secret,
|
|
94
|
+
)
|
|
95
|
+
try:
|
|
96
|
+
result = provision_productivity_oauth(
|
|
97
|
+
credentials,
|
|
98
|
+
env_file=env_file,
|
|
99
|
+
obachan_env=obachan_env,
|
|
100
|
+
obachan_app=obachan_app,
|
|
101
|
+
dry_run=dry_run,
|
|
102
|
+
)
|
|
103
|
+
except (ValueError, RuntimeError) as exc:
|
|
104
|
+
raise click.ClickException(str(exc)) from exc
|
|
105
|
+
|
|
106
|
+
for key in result.keys:
|
|
107
|
+
click.echo(f"would set {key}" if dry_run else f"set {key}")
|
|
108
|
+
if result.env_file:
|
|
109
|
+
action = "Would update" if dry_run else "Updated"
|
|
110
|
+
click.echo(f"{action} {result.env_file}")
|
|
111
|
+
if result.obachan_path:
|
|
112
|
+
action = "Would update" if dry_run else "Updated"
|
|
113
|
+
click.echo(f"{action} OpenBao path {result.obachan_path}")
|
|
114
|
+
if dry_run:
|
|
115
|
+
click.echo("Dry run; no destinations written.")
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@oauth.command("configure-productivity-local")
|
|
119
|
+
@click.option(
|
|
120
|
+
"--env-file",
|
|
121
|
+
type=click.Path(path_type=Path),
|
|
122
|
+
default=Path(".deploy.env"),
|
|
123
|
+
show_default=True,
|
|
124
|
+
)
|
|
125
|
+
@click.option("--google-client-id", default=None)
|
|
126
|
+
@click.option("--google-client-secret", default=None)
|
|
127
|
+
@click.option("--azure-client-id", default=None)
|
|
128
|
+
@click.option("--azure-client-secret", default=None)
|
|
129
|
+
@click.option("--dry-run", is_flag=True, help="Validate without writing the env file.")
|
|
130
|
+
def configure_productivity_local(
|
|
131
|
+
env_file: Path,
|
|
132
|
+
google_client_id: str | None,
|
|
133
|
+
google_client_secret: str | None,
|
|
134
|
+
azure_client_id: str | None,
|
|
135
|
+
azure_client_secret: str | None,
|
|
136
|
+
dry_run: bool,
|
|
137
|
+
) -> None:
|
|
138
|
+
"""Configure a local dashboard (compatibility alias)."""
|
|
139
|
+
credentials = ProductivityOAuthCredentials(
|
|
140
|
+
google_client_id=google_client_id,
|
|
141
|
+
google_client_secret=google_client_secret,
|
|
142
|
+
azure_client_id=azure_client_id,
|
|
143
|
+
azure_client_secret=azure_client_secret,
|
|
144
|
+
)
|
|
145
|
+
values = credentials.as_env()
|
|
146
|
+
if not values:
|
|
147
|
+
raise click.UsageError("Provide at least one client id or secret value.")
|
|
148
|
+
if not dry_run:
|
|
149
|
+
update_env_file(env_file, values)
|
|
150
|
+
click.echo(f"Env file: {env_file}")
|
|
151
|
+
for key in sorted(values):
|
|
152
|
+
value = values[key]
|
|
153
|
+
display = _mask(value) if key.endswith("SECRET") else value
|
|
154
|
+
click.echo(f" set {key}={display}")
|
|
155
|
+
if dry_run:
|
|
156
|
+
click.echo("Dry run; no file written.")
|
|
157
|
+
else:
|
|
158
|
+
click.echo("Updated local OAuth configuration.")
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _mask(value: str) -> str:
|
|
162
|
+
if len(value) <= 8:
|
|
163
|
+
return "****"
|
|
164
|
+
return f"{value[:4]}…{value[-4:]}"
|
|
@@ -193,6 +193,7 @@ SECRET_MAP: dict[str, str] = {
|
|
|
193
193
|
# Knative FaaS control API. The base URL is vault-distributed configuration.
|
|
194
194
|
"OBACHAN_API_KEY": "obachan-api-key",
|
|
195
195
|
"OBACHAN_API_BASE_URL": "obachan-api-base-url",
|
|
196
|
+
"OBACHAN_BAO_TOKEN": "obachan-bao-token",
|
|
196
197
|
}
|
|
197
198
|
|
|
198
199
|
# Module-level cache so vault is only contacted once per process
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""OAuth client provisioning helpers."""
|
|
2
|
+
|
|
3
|
+
from granny.oauth.productivity import (
|
|
4
|
+
ObachanSecretClient,
|
|
5
|
+
ProductivityOAuthCredentials,
|
|
6
|
+
ProvisionResult,
|
|
7
|
+
load_google_client_json,
|
|
8
|
+
provision_productivity_oauth,
|
|
9
|
+
update_env_file,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"ObachanSecretClient",
|
|
14
|
+
"ProductivityOAuthCredentials",
|
|
15
|
+
"ProvisionResult",
|
|
16
|
+
"load_google_client_json",
|
|
17
|
+
"provision_productivity_oauth",
|
|
18
|
+
"update_env_file",
|
|
19
|
+
]
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
"""Provision Google and Microsoft productivity OAuth client credentials.
|
|
2
|
+
|
|
3
|
+
Credentials can be written to an application's local dotenv file and to an
|
|
4
|
+
Obachan OpenBao KV v2 path. Provider-side application registration remains a
|
|
5
|
+
cloud-admin operation; Google client JSON downloaded from Google Cloud is
|
|
6
|
+
accepted directly.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import re
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any
|
|
17
|
+
from urllib.parse import quote
|
|
18
|
+
|
|
19
|
+
import requests
|
|
20
|
+
|
|
21
|
+
from granny.credentials import get_secret
|
|
22
|
+
|
|
23
|
+
_SEGMENT_RE = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9._-]*$")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class ProductivityOAuthCredentials:
|
|
28
|
+
"""Google and Microsoft OAuth client credentials."""
|
|
29
|
+
|
|
30
|
+
google_client_id: str | None = None
|
|
31
|
+
google_client_secret: str | None = None
|
|
32
|
+
azure_client_id: str | None = None
|
|
33
|
+
azure_client_secret: str | None = None
|
|
34
|
+
|
|
35
|
+
def as_env(self) -> dict[str, str]:
|
|
36
|
+
"""Return configured values using dashboard runtime variable names."""
|
|
37
|
+
values = {
|
|
38
|
+
"GOOGLE_CLIENT_ID": self.google_client_id,
|
|
39
|
+
"GOOGLE_CLIENT_SECRET": self.google_client_secret,
|
|
40
|
+
"AZURE_CLIENT_ID": self.azure_client_id,
|
|
41
|
+
"AZURE_CLIENT_SECRET": self.azure_client_secret,
|
|
42
|
+
}
|
|
43
|
+
return {key: value for key, value in values.items() if value}
|
|
44
|
+
|
|
45
|
+
def validate(self) -> None:
|
|
46
|
+
"""Require complete client ID/secret pairs for each selected provider."""
|
|
47
|
+
values = self.as_env()
|
|
48
|
+
if not values:
|
|
49
|
+
raise ValueError("At least one Google or Azure OAuth client is required")
|
|
50
|
+
for provider in ("GOOGLE", "AZURE"):
|
|
51
|
+
client_id = values.get(f"{provider}_CLIENT_ID")
|
|
52
|
+
client_secret = values.get(f"{provider}_CLIENT_SECRET")
|
|
53
|
+
if bool(client_id) != bool(client_secret):
|
|
54
|
+
raise ValueError(
|
|
55
|
+
f"{provider}_CLIENT_ID and {provider}_CLIENT_SECRET must be provided together"
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass(frozen=True)
|
|
60
|
+
class ProvisionResult:
|
|
61
|
+
"""Locations updated by a productivity OAuth provisioning run."""
|
|
62
|
+
|
|
63
|
+
keys: tuple[str, ...]
|
|
64
|
+
env_file: Path | None = None
|
|
65
|
+
obachan_path: str | None = None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def load_google_client_json(path: Path) -> tuple[str, str]:
|
|
69
|
+
"""Read a Google Cloud OAuth client download and return ID and secret."""
|
|
70
|
+
try:
|
|
71
|
+
document = json.loads(path.read_text(encoding="utf-8"))
|
|
72
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
73
|
+
raise ValueError(f"Cannot read Google OAuth client JSON {path}: {exc}") from exc
|
|
74
|
+
|
|
75
|
+
config = document.get("web") or document.get("installed") or document
|
|
76
|
+
if not isinstance(config, dict):
|
|
77
|
+
raise ValueError(f"Invalid Google OAuth client JSON: {path}")
|
|
78
|
+
client_id = config.get("client_id")
|
|
79
|
+
client_secret = config.get("client_secret")
|
|
80
|
+
if not isinstance(client_id, str) or not isinstance(client_secret, str):
|
|
81
|
+
raise ValueError(
|
|
82
|
+
f"Google OAuth client JSON has no client_id/client_secret: {path}"
|
|
83
|
+
)
|
|
84
|
+
return client_id, client_secret
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class ObachanSecretClient:
|
|
88
|
+
"""Small OpenBao KV v2 client for Obachan application secrets."""
|
|
89
|
+
|
|
90
|
+
def __init__(
|
|
91
|
+
self,
|
|
92
|
+
base_url: str | None = None,
|
|
93
|
+
token: str | None = None,
|
|
94
|
+
*,
|
|
95
|
+
mount: str = "kv",
|
|
96
|
+
) -> None:
|
|
97
|
+
self.base_url = (base_url or os.environ.get("OBACHAN_BAO_ADDR") or "").rstrip(
|
|
98
|
+
"/"
|
|
99
|
+
)
|
|
100
|
+
self.token = token or get_secret("OBACHAN_BAO_TOKEN")
|
|
101
|
+
self.mount = mount.strip("/")
|
|
102
|
+
|
|
103
|
+
def _url(self, path: str) -> str:
|
|
104
|
+
if not self.base_url:
|
|
105
|
+
raise ValueError("OBACHAN_BAO_ADDR is not set")
|
|
106
|
+
if not self.token:
|
|
107
|
+
raise ValueError("OBACHAN_BAO_TOKEN is not set (env or vault)")
|
|
108
|
+
encoded = "/".join(quote(segment, safe="") for segment in path.split("/"))
|
|
109
|
+
return f"{self.base_url}/v1/{quote(self.mount, safe='')}/data/{encoded}"
|
|
110
|
+
|
|
111
|
+
def merge(self, path: str, values: dict[str, str]) -> None:
|
|
112
|
+
"""Merge fields into one Obachan OpenBao KV v2 secret using CAS."""
|
|
113
|
+
url = self._url(path)
|
|
114
|
+
headers = {"X-Vault-Token": self.token or ""}
|
|
115
|
+
try:
|
|
116
|
+
response = requests.get(url, headers=headers, timeout=30)
|
|
117
|
+
except requests.RequestException as exc:
|
|
118
|
+
raise RuntimeError(f"Obachan OpenBao GET {path} failed: {exc}") from exc
|
|
119
|
+
|
|
120
|
+
version = 0
|
|
121
|
+
if response.status_code == 404:
|
|
122
|
+
existing: dict[str, Any] = {}
|
|
123
|
+
elif response.status_code == 200:
|
|
124
|
+
try:
|
|
125
|
+
body = response.json()
|
|
126
|
+
data = body.get("data", {})
|
|
127
|
+
existing = data.get("data", {})
|
|
128
|
+
metadata = data.get("metadata", {})
|
|
129
|
+
version = metadata.get("version", 0)
|
|
130
|
+
except (AttributeError, TypeError, ValueError) as exc:
|
|
131
|
+
raise RuntimeError(
|
|
132
|
+
f"Obachan OpenBao GET {path} returned invalid KV v2 JSON"
|
|
133
|
+
) from exc
|
|
134
|
+
if not isinstance(existing, dict) or not isinstance(version, int):
|
|
135
|
+
raise RuntimeError(
|
|
136
|
+
f"Obachan OpenBao GET {path} returned invalid KV v2 data"
|
|
137
|
+
)
|
|
138
|
+
else:
|
|
139
|
+
_raise_bao_error("GET", path, response)
|
|
140
|
+
|
|
141
|
+
try:
|
|
142
|
+
response = requests.post(
|
|
143
|
+
url,
|
|
144
|
+
headers={**headers, "Content-Type": "application/json"},
|
|
145
|
+
json={
|
|
146
|
+
"data": {**existing, **values},
|
|
147
|
+
"options": {"cas": version},
|
|
148
|
+
},
|
|
149
|
+
timeout=30,
|
|
150
|
+
)
|
|
151
|
+
except requests.RequestException as exc:
|
|
152
|
+
raise RuntimeError(f"Obachan OpenBao POST {path} failed: {exc}") from exc
|
|
153
|
+
if response.status_code not in (200, 204):
|
|
154
|
+
_raise_bao_error("POST", path, response)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def provision_productivity_oauth(
|
|
158
|
+
credentials: ProductivityOAuthCredentials,
|
|
159
|
+
*,
|
|
160
|
+
env_file: Path | None = None,
|
|
161
|
+
obachan_env: str | None = None,
|
|
162
|
+
obachan_app: str = "dashboard",
|
|
163
|
+
obachan_client: ObachanSecretClient | None = None,
|
|
164
|
+
dry_run: bool = False,
|
|
165
|
+
) -> ProvisionResult:
|
|
166
|
+
"""Validate and provision OAuth credentials to selected destinations."""
|
|
167
|
+
credentials.validate()
|
|
168
|
+
values = credentials.as_env()
|
|
169
|
+
|
|
170
|
+
if env_file is None and obachan_env is None:
|
|
171
|
+
raise ValueError("Select an env_file or obachan_env destination")
|
|
172
|
+
|
|
173
|
+
if env_file is not None and not dry_run:
|
|
174
|
+
update_env_file(env_file, values)
|
|
175
|
+
|
|
176
|
+
obachan_path = None
|
|
177
|
+
if obachan_env is not None:
|
|
178
|
+
_validate_segment("Obachan environment", obachan_env)
|
|
179
|
+
_validate_segment("Obachan application", obachan_app)
|
|
180
|
+
obachan_path = f"obachan/{obachan_env}/apps/{obachan_app}/productivity-oauth"
|
|
181
|
+
if not dry_run:
|
|
182
|
+
(obachan_client or ObachanSecretClient()).merge(obachan_path, values)
|
|
183
|
+
|
|
184
|
+
return ProvisionResult(
|
|
185
|
+
keys=tuple(sorted(values)), env_file=env_file, obachan_path=obachan_path
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def update_env_file(path: Path, updates: dict[str, str]) -> None:
|
|
190
|
+
"""Update dotenv assignments while preserving comments and unrelated lines."""
|
|
191
|
+
lines = path.read_text(encoding="utf-8").splitlines() if path.exists() else []
|
|
192
|
+
remaining = dict(updates)
|
|
193
|
+
replaced: set[str] = set()
|
|
194
|
+
output: list[str] = []
|
|
195
|
+
assignment = re.compile(r"^(\s*)(export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=")
|
|
196
|
+
for line in lines:
|
|
197
|
+
match = assignment.match(line)
|
|
198
|
+
key = match.group(3) if match else None
|
|
199
|
+
if key in updates:
|
|
200
|
+
if key not in replaced:
|
|
201
|
+
prefix = f"{match.group(1)}{match.group(2) or ''}"
|
|
202
|
+
output.append(f"{prefix}{key}={_quote_env(updates[key])}")
|
|
203
|
+
replaced.add(key)
|
|
204
|
+
remaining.pop(key, None)
|
|
205
|
+
continue
|
|
206
|
+
output.append(line)
|
|
207
|
+
if output and remaining and output[-1]:
|
|
208
|
+
output.append("")
|
|
209
|
+
output.extend(
|
|
210
|
+
f"{key}={_quote_env(value)}" for key, value in sorted(remaining.items())
|
|
211
|
+
)
|
|
212
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
213
|
+
path.write_text("\n".join(output) + "\n", encoding="utf-8")
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _validate_segment(label: str, value: str) -> None:
|
|
217
|
+
if not _SEGMENT_RE.fullmatch(value):
|
|
218
|
+
raise ValueError(
|
|
219
|
+
f"{label} must contain only letters, digits, '.', '_', and '-'"
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _quote_env(value: str) -> str:
|
|
224
|
+
if (
|
|
225
|
+
not value
|
|
226
|
+
or any(ch.isspace() for ch in value)
|
|
227
|
+
or any(ch in value for ch in "\"'#$")
|
|
228
|
+
):
|
|
229
|
+
return f'"{value.replace(chr(34), chr(92) + chr(34))}"'
|
|
230
|
+
return value
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _raise_bao_error(method: str, path: str, response: requests.Response) -> None:
|
|
234
|
+
raise RuntimeError(
|
|
235
|
+
f"Obachan OpenBao {method} {path} failed: "
|
|
236
|
+
f"{response.status_code} {response.text[:1000]}"
|
|
237
|
+
)
|
|
@@ -1,96 +0,0 @@
|
|
|
1
|
-
"""OAuth configuration commands."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
from pathlib import Path
|
|
6
|
-
|
|
7
|
-
import click
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
@click.group()
|
|
11
|
-
def oauth() -> None:
|
|
12
|
-
"""Configure OAuth client settings for local apps."""
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
@oauth.command("configure-productivity-local")
|
|
16
|
-
@click.option(
|
|
17
|
-
"--env-file",
|
|
18
|
-
type=click.Path(path_type=Path),
|
|
19
|
-
default=Path(".deploy.env"),
|
|
20
|
-
show_default=True,
|
|
21
|
-
help="Env file to update in the current project.",
|
|
22
|
-
)
|
|
23
|
-
@click.option("--google-client-id", default=None, help="Google OAuth client ID.")
|
|
24
|
-
@click.option("--google-client-secret", default=None, help="Google OAuth client secret (optional; not echoed).")
|
|
25
|
-
@click.option("--azure-client-id", default=None, help="Microsoft Entra application/client ID.")
|
|
26
|
-
@click.option("--azure-client-secret", default=None, help="Microsoft Entra client secret (optional; not echoed).")
|
|
27
|
-
@click.option("--dry-run", is_flag=True, help="Print planned keys without writing the env file.")
|
|
28
|
-
def configure_productivity_local(
|
|
29
|
-
env_file: Path,
|
|
30
|
-
google_client_id: str | None,
|
|
31
|
-
google_client_secret: str | None,
|
|
32
|
-
azure_client_id: str | None,
|
|
33
|
-
azure_client_secret: str | None,
|
|
34
|
-
dry_run: bool,
|
|
35
|
-
) -> None:
|
|
36
|
-
"""Write real Google/Microsoft OAuth IDs into a local dashboard env file.
|
|
37
|
-
|
|
38
|
-
Provider application creation is intentionally not automated here: Google
|
|
39
|
-
Cloud OAuth consent screens and Microsoft Entra app registrations require
|
|
40
|
-
tenant-admin policy choices. Use this after real provider apps exist.
|
|
41
|
-
"""
|
|
42
|
-
updates = {
|
|
43
|
-
"GOOGLE_CLIENT_ID": google_client_id,
|
|
44
|
-
"GOOGLE_CLIENT_SECRET": google_client_secret,
|
|
45
|
-
"AZURE_CLIENT_ID": azure_client_id,
|
|
46
|
-
"AZURE_CLIENT_SECRET": azure_client_secret,
|
|
47
|
-
}
|
|
48
|
-
updates = {key: value for key, value in updates.items() if value}
|
|
49
|
-
if not updates:
|
|
50
|
-
raise click.UsageError("Provide at least one client id or secret value.")
|
|
51
|
-
|
|
52
|
-
existing = _read_env(env_file)
|
|
53
|
-
merged = {**existing, **updates}
|
|
54
|
-
|
|
55
|
-
click.echo(f"Env file: {env_file}")
|
|
56
|
-
for key in sorted(updates):
|
|
57
|
-
display = _mask(updates[key]) if key.endswith("SECRET") else updates[key]
|
|
58
|
-
click.echo(f" set {key}={display}")
|
|
59
|
-
|
|
60
|
-
if dry_run:
|
|
61
|
-
click.echo("Dry run; no file written.")
|
|
62
|
-
return
|
|
63
|
-
|
|
64
|
-
_write_env(env_file, merged)
|
|
65
|
-
click.echo("Updated local OAuth configuration.")
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
def _read_env(path: Path) -> dict[str, str]:
|
|
69
|
-
if not path.exists():
|
|
70
|
-
return {}
|
|
71
|
-
values: dict[str, str] = {}
|
|
72
|
-
for line in path.read_text(encoding="utf-8").splitlines():
|
|
73
|
-
stripped = line.strip()
|
|
74
|
-
if not stripped or stripped.startswith("#") or "=" not in stripped:
|
|
75
|
-
continue
|
|
76
|
-
key, value = stripped.split("=", 1)
|
|
77
|
-
values[key.strip()] = value.strip().strip('"').strip("'")
|
|
78
|
-
return values
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
def _write_env(path: Path, values: dict[str, str]) -> None:
|
|
82
|
-
path.parent.mkdir(parents=True, exist_ok=True)
|
|
83
|
-
lines = [f"{key}={_quote_env(value)}" for key, value in sorted(values.items())]
|
|
84
|
-
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
def _quote_env(value: str) -> str:
|
|
88
|
-
if not value or any(ch.isspace() for ch in value) or any(ch in value for ch in '"\'#$'):
|
|
89
|
-
return f'"{value.replace(chr(34), chr(92) + chr(34))}"'
|
|
90
|
-
return value
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
def _mask(value: str) -> str:
|
|
94
|
-
if len(value) <= 8:
|
|
95
|
-
return "****"
|
|
96
|
-
return f"{value[:4]}…{value[-4:]}"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|