granny-devops 0.19.3__tar.gz → 0.20.0__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 (125) hide show
  1. {granny_devops-0.19.3 → granny_devops-0.20.0}/PKG-INFO +1 -1
  2. {granny_devops-0.19.3 → granny_devops-0.20.0}/granny/credentials/secrets.py +90 -1
  3. {granny_devops-0.19.3 → granny_devops-0.20.0}/granny/storage/hetzner.py +49 -12
  4. {granny_devops-0.19.3 → granny_devops-0.20.0}/pyproject.toml +1 -1
  5. {granny_devops-0.19.3 → granny_devops-0.20.0}/.gitignore +0 -0
  6. {granny_devops-0.19.3 → granny_devops-0.20.0}/LICENSE +0 -0
  7. {granny_devops-0.19.3 → granny_devops-0.20.0}/README.md +0 -0
  8. {granny_devops-0.19.3 → granny_devops-0.20.0}/granny/__init__.py +0 -0
  9. {granny_devops-0.19.3 → granny_devops-0.20.0}/granny/airtable/__init__.py +0 -0
  10. {granny_devops-0.19.3 → granny_devops-0.20.0}/granny/airtable/client.py +0 -0
  11. {granny_devops-0.19.3 → granny_devops-0.20.0}/granny/analyze/__init__.py +0 -0
  12. {granny_devops-0.19.3 → granny_devops-0.20.0}/granny/analyze/costs.py +0 -0
  13. {granny_devops-0.19.3 → granny_devops-0.20.0}/granny/analyze/credits.py +0 -0
  14. {granny_devops-0.19.3 → granny_devops-0.20.0}/granny/analyze/gpu_pricing.py +0 -0
  15. {granny_devops-0.19.3 → granny_devops-0.20.0}/granny/analyze/gpus.py +0 -0
  16. {granny_devops-0.19.3 → granny_devops-0.20.0}/granny/analyze/kubernetes.py +0 -0
  17. {granny_devops-0.19.3 → granny_devops-0.20.0}/granny/analyze/lambdas.py +0 -0
  18. {granny_devops-0.19.3 → granny_devops-0.20.0}/granny/analyze/vpcs.py +0 -0
  19. {granny_devops-0.19.3 → granny_devops-0.20.0}/granny/authentik/__init__.py +0 -0
  20. {granny_devops-0.19.3 → granny_devops-0.20.0}/granny/authentik/client.py +0 -0
  21. {granny_devops-0.19.3 → granny_devops-0.20.0}/granny/authentik/provision.py +0 -0
  22. {granny_devops-0.19.3 → granny_devops-0.20.0}/granny/aws/__init__.py +0 -0
  23. {granny_devops-0.19.3 → granny_devops-0.20.0}/granny/aws/bedrock.py +0 -0
  24. {granny_devops-0.19.3 → granny_devops-0.20.0}/granny/aws/quota.py +0 -0
  25. {granny_devops-0.19.3 → granny_devops-0.20.0}/granny/azure/__init__.py +0 -0
  26. {granny_devops-0.19.3 → granny_devops-0.20.0}/granny/azure/_client.py +0 -0
  27. {granny_devops-0.19.3 → granny_devops-0.20.0}/granny/azure/account.py +0 -0
  28. {granny_devops-0.19.3 → granny_devops-0.20.0}/granny/azure/deployment.py +0 -0
  29. {granny_devops-0.19.3 → granny_devops-0.20.0}/granny/azure/openai.py +0 -0
  30. {granny_devops-0.19.3 → granny_devops-0.20.0}/granny/azure/quota.py +0 -0
  31. {granny_devops-0.19.3 → granny_devops-0.20.0}/granny/azure/resourcegroup.py +0 -0
  32. {granny_devops-0.19.3 → granny_devops-0.20.0}/granny/azure/role.py +0 -0
  33. {granny_devops-0.19.3 → granny_devops-0.20.0}/granny/azure/sku.py +0 -0
  34. {granny_devops-0.19.3 → granny_devops-0.20.0}/granny/azure/vm.py +0 -0
  35. {granny_devops-0.19.3 → granny_devops-0.20.0}/granny/azure/webapp.py +0 -0
  36. {granny_devops-0.19.3 → granny_devops-0.20.0}/granny/bunny/__init__.py +0 -0
  37. {granny_devops-0.19.3 → granny_devops-0.20.0}/granny/bunny/billing.py +0 -0
  38. {granny_devops-0.19.3 → granny_devops-0.20.0}/granny/cdn/__init__.py +0 -0
  39. {granny_devops-0.19.3 → granny_devops-0.20.0}/granny/cdn/bunny.py +0 -0
  40. {granny_devops-0.19.3 → granny_devops-0.20.0}/granny/cli/__init__.py +0 -0
  41. {granny_devops-0.19.3 → granny_devops-0.20.0}/granny/cli/airtable.py +0 -0
  42. {granny_devops-0.19.3 → granny_devops-0.20.0}/granny/cli/analyze.py +0 -0
  43. {granny_devops-0.19.3 → granny_devops-0.20.0}/granny/cli/authentik.py +0 -0
  44. {granny_devops-0.19.3 → granny_devops-0.20.0}/granny/cli/aws.py +0 -0
  45. {granny_devops-0.19.3 → granny_devops-0.20.0}/granny/cli/azure.py +0 -0
  46. {granny_devops-0.19.3 → granny_devops-0.20.0}/granny/cli/cdn.py +0 -0
  47. {granny_devops-0.19.3 → granny_devops-0.20.0}/granny/cli/cloudflare.py +0 -0
  48. {granny_devops-0.19.3 → granny_devops-0.20.0}/granny/cli/create.py +0 -0
  49. {granny_devops-0.19.3 → granny_devops-0.20.0}/granny/cli/credentials.py +0 -0
  50. {granny_devops-0.19.3 → granny_devops-0.20.0}/granny/cli/dns.py +0 -0
  51. {granny_devops-0.19.3 → granny_devops-0.20.0}/granny/cli/docker.py +0 -0
  52. {granny_devops-0.19.3 → granny_devops-0.20.0}/granny/cli/edge.py +0 -0
  53. {granny_devops-0.19.3 → granny_devops-0.20.0}/granny/cli/elk.py +0 -0
  54. {granny_devops-0.19.3 → granny_devops-0.20.0}/granny/cli/email.py +0 -0
  55. {granny_devops-0.19.3 → granny_devops-0.20.0}/granny/cli/indexing.py +0 -0
  56. {granny_devops-0.19.3 → granny_devops-0.20.0}/granny/cli/knative.py +0 -0
  57. {granny_devops-0.19.3 → granny_devops-0.20.0}/granny/cli/main.py +0 -0
  58. {granny_devops-0.19.3 → granny_devops-0.20.0}/granny/cli/oauth.py +0 -0
  59. {granny_devops-0.19.3 → granny_devops-0.20.0}/granny/cli/serverless.py +0 -0
  60. {granny_devops-0.19.3 → granny_devops-0.20.0}/granny/cli/storage.py +0 -0
  61. {granny_devops-0.19.3 → granny_devops-0.20.0}/granny/cli/zitadel.py +0 -0
  62. {granny_devops-0.19.3 → granny_devops-0.20.0}/granny/cloudflare/__init__.py +0 -0
  63. {granny_devops-0.19.3 → granny_devops-0.20.0}/granny/cloudflare/d1.py +0 -0
  64. {granny_devops-0.19.3 → granny_devops-0.20.0}/granny/cloudflare/r2.py +0 -0
  65. {granny_devops-0.19.3 → granny_devops-0.20.0}/granny/cloudflare/workers.py +0 -0
  66. {granny_devops-0.19.3 → granny_devops-0.20.0}/granny/create/__init__.py +0 -0
  67. {granny_devops-0.19.3 → granny_devops-0.20.0}/granny/create/auto_certificate.py +0 -0
  68. {granny_devops-0.19.3 → granny_devops-0.20.0}/granny/create/cloudfront-security-headers.js +0 -0
  69. {granny_devops-0.19.3 → granny_devops-0.20.0}/granny/create/letsencrypt_cert.py +0 -0
  70. {granny_devops-0.19.3 → granny_devops-0.20.0}/granny/create/manage-dns.sh +0 -0
  71. {granny_devops-0.19.3 → granny_devops-0.20.0}/granny/create/manage_mailjet_contacts.py +0 -0
  72. {granny_devops-0.19.3 → granny_devops-0.20.0}/granny/create/registrars.py +0 -0
  73. {granny_devops-0.19.3 → granny_devops-0.20.0}/granny/create/setup_aws_cloudfront.py +0 -0
  74. {granny_devops-0.19.3 → granny_devops-0.20.0}/granny/create/setup_bunny_edge_script.py +0 -0
  75. {granny_devops-0.19.3 → granny_devops-0.20.0}/granny/create/setup_bunny_storage.py +0 -0
  76. {granny_devops-0.19.3 → granny_devops-0.20.0}/granny/create/setup_cognito_identity_pool.py +0 -0
  77. {granny_devops-0.19.3 → granny_devops-0.20.0}/granny/create/setup_hetzner_bunny.py +0 -0
  78. {granny_devops-0.19.3 → granny_devops-0.20.0}/granny/create/setup_mailjet_dns.py +0 -0
  79. {granny_devops-0.19.3 → granny_devops-0.20.0}/granny/create/setup_private_cdn.py +0 -0
  80. {granny_devops-0.19.3 → granny_devops-0.20.0}/granny/create/setup_s3_website.py +0 -0
  81. {granny_devops-0.19.3 → granny_devops-0.20.0}/granny/create/setup_scaleway_container.py +0 -0
  82. {granny_devops-0.19.3 → granny_devops-0.20.0}/granny/create/setup_scaleway_faas.py +0 -0
  83. {granny_devops-0.19.3 → granny_devops-0.20.0}/granny/create/setup_workmail.py +0 -0
  84. {granny_devops-0.19.3 → granny_devops-0.20.0}/granny/create/www-redirect-function.js +0 -0
  85. {granny_devops-0.19.3 → granny_devops-0.20.0}/granny/credentials/__init__.py +0 -0
  86. {granny_devops-0.19.3 → granny_devops-0.20.0}/granny/dns/__init__.py +0 -0
  87. {granny_devops-0.19.3 → granny_devops-0.20.0}/granny/dns/base.py +0 -0
  88. {granny_devops-0.19.3 → granny_devops-0.20.0}/granny/dns/bunny.py +0 -0
  89. {granny_devops-0.19.3 → granny_devops-0.20.0}/granny/dns/cloudflare.py +0 -0
  90. {granny_devops-0.19.3 → granny_devops-0.20.0}/granny/dns/cloudns.py +0 -0
  91. {granny_devops-0.19.3 → granny_devops-0.20.0}/granny/dns/desec.py +0 -0
  92. {granny_devops-0.19.3 → granny_devops-0.20.0}/granny/dns/factory.py +0 -0
  93. {granny_devops-0.19.3 → granny_devops-0.20.0}/granny/dns/hetzner.py +0 -0
  94. {granny_devops-0.19.3 → granny_devops-0.20.0}/granny/dns/inwx.py +0 -0
  95. {granny_devops-0.19.3 → granny_devops-0.20.0}/granny/dns/manual.py +0 -0
  96. {granny_devops-0.19.3 → granny_devops-0.20.0}/granny/dns/records.py +0 -0
  97. {granny_devops-0.19.3 → granny_devops-0.20.0}/granny/docker/__init__.py +0 -0
  98. {granny_devops-0.19.3 → granny_devops-0.20.0}/granny/docker/build_base.py +0 -0
  99. {granny_devops-0.19.3 → granny_devops-0.20.0}/granny/edge/__init__.py +0 -0
  100. {granny_devops-0.19.3 → granny_devops-0.20.0}/granny/edge/bunny.py +0 -0
  101. {granny_devops-0.19.3 → granny_devops-0.20.0}/granny/elk/__init__.py +0 -0
  102. {granny_devops-0.19.3 → granny_devops-0.20.0}/granny/elk/client.py +0 -0
  103. {granny_devops-0.19.3 → granny_devops-0.20.0}/granny/email/__init__.py +0 -0
  104. {granny_devops-0.19.3 → granny_devops-0.20.0}/granny/email/mailjet.py +0 -0
  105. {granny_devops-0.19.3 → granny_devops-0.20.0}/granny/email/mailjet_contacts.py +0 -0
  106. {granny_devops-0.19.3 → granny_devops-0.20.0}/granny/email/ses_forwarding.py +0 -0
  107. {granny_devops-0.19.3 → granny_devops-0.20.0}/granny/email/stalwart.py +0 -0
  108. {granny_devops-0.19.3 → granny_devops-0.20.0}/granny/email/workmail.py +0 -0
  109. {granny_devops-0.19.3 → granny_devops-0.20.0}/granny/indexing/__init__.py +0 -0
  110. {granny_devops-0.19.3 → granny_devops-0.20.0}/granny/indexing/google.py +0 -0
  111. {granny_devops-0.19.3 → granny_devops-0.20.0}/granny/indexing/indexnow.py +0 -0
  112. {granny_devops-0.19.3 → granny_devops-0.20.0}/granny/knative/__init__.py +0 -0
  113. {granny_devops-0.19.3 → granny_devops-0.20.0}/granny/knative/client.py +0 -0
  114. {granny_devops-0.19.3 → granny_devops-0.20.0}/granny/oauth/__init__.py +0 -0
  115. {granny_devops-0.19.3 → granny_devops-0.20.0}/granny/oauth/productivity.py +0 -0
  116. {granny_devops-0.19.3 → granny_devops-0.20.0}/granny/report.py +0 -0
  117. {granny_devops-0.19.3 → granny_devops-0.20.0}/granny/scaleway/__init__.py +0 -0
  118. {granny_devops-0.19.3 → granny_devops-0.20.0}/granny/scaleway/billing.py +0 -0
  119. {granny_devops-0.19.3 → granny_devops-0.20.0}/granny/serverless/__init__.py +0 -0
  120. {granny_devops-0.19.3 → granny_devops-0.20.0}/granny/serverless/scaleway.py +0 -0
  121. {granny_devops-0.19.3 → granny_devops-0.20.0}/granny/storage/__init__.py +0 -0
  122. {granny_devops-0.19.3 → granny_devops-0.20.0}/granny/storage/aws.py +0 -0
  123. {granny_devops-0.19.3 → granny_devops-0.20.0}/granny/storage/bunny.py +0 -0
  124. {granny_devops-0.19.3 → granny_devops-0.20.0}/granny/zitadel/__init__.py +0 -0
  125. {granny_devops-0.19.3 → granny_devops-0.20.0}/granny/zitadel/client.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: granny-devops
3
- Version: 0.19.3
3
+ Version: 0.20.0
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
@@ -5,6 +5,9 @@ Resolution order for each secret:
5
5
  1. Environment variable (from ``.env``, ``.deploy.env``, or shell) — explicit wins
6
6
  2. Vaultwarden vault via Locke — only if the optional ``[vault]`` extra is
7
7
  installed and a vault session is reachable
8
+ 3. Locke OS keystore — including the ``OBACHAN_<ENV>_<NAME>`` entries obachan
9
+ stores, so a session that cannot reach Vaultwarden (locked workstation,
10
+ non-interactive shell where Windows Hello cannot prompt) still resolves
8
11
 
9
12
  Without the ``[vault]`` extra, secrets are env-var only — the vault-related
10
13
  helpers are no-ops that return ``None``. Install with vault support via::
@@ -196,6 +199,33 @@ SECRET_MAP: dict[str, str] = {
196
199
  "OBACHAN_BAO_TOKEN": "obachan-bao-token",
197
200
  }
198
201
 
202
+ # Map env var name -> the name the same secret carries in the Locke OS keystore.
203
+ #
204
+ # Vaultwarden is not the only place these live. obachan stores the same
205
+ # infrastructure credentials in the OS keystore under its own namespace,
206
+ # ``OBACHAN_<ENV>_<NAME>`` (see obachan's runners/locke_resolver._namespaced),
207
+ # and its object-storage pair is documented as holding Hetzner Object Storage
208
+ # credentials. A session that cannot reach Vaultwarden -- a locked
209
+ # workstation, a non-interactive shell where Windows Hello cannot prompt, a
210
+ # stale locke pin -- can still answer from the keystore, so it is worth asking
211
+ # before giving up.
212
+ #
213
+ # Values are the un-namespaced obachan name; ``_keystore_candidates`` expands
214
+ # them across environments.
215
+ KEYSTORE_ALIASES: dict[str, str] = {
216
+ "HETZNER_S3_ACCESS_KEY": "S3_ACCESS_KEY",
217
+ "HETZNER_S3_SECRET_KEY": "S3_SECRET_KEY",
218
+ "CLOUDFLARE_API_TOKEN": "CLOUDFLARE_API_TOKEN",
219
+ "INWX_USERNAME": "INWX_USERNAME",
220
+ "INWX_PASSWORD": "INWX_PASSWORD",
221
+ "INWX_SHARED_SECRET": "INWX_SHARED_SECRET",
222
+ }
223
+
224
+ #: Environments to try, in order, when expanding a keystore alias. Override
225
+ #: with ``GRANNY_KEYSTORE_ENV`` to pin one -- worth doing deliberately, since
226
+ #: the default reaches production credentials first.
227
+ KEYSTORE_ENV_ORDER = ("prod", "prod1", "staging", "dev2")
228
+
199
229
  # Module-level cache so vault is only contacted once per process
200
230
  _cache: dict[str, str | None] = {}
201
231
  _vault_client = None
@@ -285,13 +315,62 @@ def _fetch_from_vault(env_var: str) -> str | None:
285
315
  return None
286
316
 
287
317
 
318
+ def _keystore_candidates(env_var: str) -> list[str]:
319
+ """Names to try in the Locke keystore for ``env_var``, most specific first.
320
+
321
+ The credential's own name comes first: a keystore entry stored under
322
+ exactly what the caller asked for should always win over a namespaced
323
+ guess.
324
+ """
325
+ candidates = [env_var]
326
+ alias = KEYSTORE_ALIASES.get(env_var)
327
+ if alias:
328
+ pinned = os.environ.get("GRANNY_KEYSTORE_ENV", "").strip()
329
+ envs = (pinned,) if pinned else KEYSTORE_ENV_ORDER
330
+ candidates.extend(f"OBACHAN_{env.upper()}_{alias}" for env in envs if env)
331
+ return candidates
332
+
333
+
334
+ def _fetch_from_keystore(env_var: str) -> str | None:
335
+ """Resolve a secret from the Locke OS keystore.
336
+
337
+ Returns None when locke is absent or nothing matches. A keystore that
338
+ cannot be *consulted* -- biometric gating with no way to prompt -- is
339
+ reported as a miss here rather than raised: the vault backend below may
340
+ still answer, and this backend exists to add a source, never to remove one.
341
+ """
342
+ try:
343
+ from locke.keystore import get_credential
344
+ except ImportError:
345
+ logger.debug("locke not installed — keystore backend disabled")
346
+ return None
347
+
348
+ for name in _keystore_candidates(env_var):
349
+ try:
350
+ value = get_credential(name)
351
+ except Exception as exc: # keystore unavailable, biometric denied, ...
352
+ logger.debug("Keystore lookup failed for %s: %s", name, exc)
353
+ return None
354
+ if value:
355
+ if name != env_var:
356
+ # Say which entry answered. A credential arriving under a
357
+ # namespaced alias -- possibly a production one -- should
358
+ # never be silent about where it came from.
359
+ logger.info(
360
+ "Resolved %s from OS keystore entry %r", env_var, name
361
+ )
362
+ return value
363
+ return None
364
+
365
+
288
366
  def get_secret(env_var: str) -> str | None:
289
367
  """Get a secret by its environment variable name.
290
368
 
291
369
  Resolution order:
292
- 1. Process cache (avoids repeated vault lookups)
370
+ 1. Process cache (avoids repeated lookups)
293
371
  2. Environment variable / .env / .deploy.env (explicit wins)
294
372
  3. Vaultwarden vault via Locke (only if [vault] extra installed)
373
+ 4. Locke OS keystore, including obachan-namespaced aliases
295
374
 
296
375
  Args:
297
376
  env_var: The environment variable name (e.g. "BUNNY_API_KEY").
@@ -315,6 +394,16 @@ def get_secret(env_var: str) -> str | None:
315
394
  _cache[env_var] = value
316
395
  return value
317
396
 
397
+ # Keystore last, so this backend only ever adds an answer where there was
398
+ # none. Placing it ahead of Vaultwarden would quietly re-point every
399
+ # already-working secret at a different store, which is not a change worth
400
+ # making as a side effect of teaching granny one new lookup.
401
+ if env_var in SECRET_MAP or env_var in KEYSTORE_ALIASES:
402
+ value = _fetch_from_keystore(env_var)
403
+ if value:
404
+ _cache[env_var] = value
405
+ return value
406
+
318
407
  return None
319
408
 
320
409
 
@@ -10,6 +10,7 @@ import time
10
10
 
11
11
  import boto3
12
12
  from botocore.config import Config
13
+ from botocore.exceptions import ClientError
13
14
 
14
15
  from granny.credentials.secrets import get_secret
15
16
 
@@ -82,20 +83,56 @@ class HetznerS3Client:
82
83
  logger.info("Set public-read policy on %s", bucket_name)
83
84
 
84
85
  def set_cors_config(
85
- self, bucket_name: str, origins: list[str] | None = None
86
+ self,
87
+ bucket_name: str,
88
+ origins: list[str] | None = None,
89
+ methods: list[str] | None = None,
90
+ expose_headers: list[str] | None = None,
91
+ max_age_seconds: int = 3600,
86
92
  ) -> None:
87
- cors = {
88
- "CORSRules": [
89
- {
90
- "AllowedHeaders": ["*"],
91
- "AllowedMethods": ["GET", "HEAD"],
92
- "AllowedOrigins": origins or ["*"],
93
- "MaxAgeSeconds": 3600,
94
- }
95
- ]
93
+ """Replace the bucket's CORS policy.
94
+
95
+ ``methods`` defaults to read-only access. Pass ``PUT`` for a bucket
96
+ that browsers upload to directly: an app handed a presigned URL sends
97
+ the bytes from the page itself, so the hop is cross-origin, and a PUT
98
+ is never a CORS-simple request. The browser preflights it, and without
99
+ a matching rule it refuses to send the upload at all -- surfacing as a
100
+ bare network error with no status code, which reads like a
101
+ connectivity fault rather than a policy one.
102
+
103
+ ``expose_headers`` is what the page is allowed to read back; ``ETag``
104
+ is the one a multipart or resumable upload needs.
105
+ """
106
+ rule: dict[str, object] = {
107
+ "AllowedHeaders": ["*"],
108
+ "AllowedMethods": methods or ["GET", "HEAD"],
109
+ "AllowedOrigins": origins or ["*"],
110
+ "MaxAgeSeconds": max_age_seconds,
96
111
  }
97
- self.client.put_bucket_cors(Bucket=bucket_name, CORSConfiguration=cors)
98
- logger.info("CORS configured on %s", bucket_name)
112
+ if expose_headers:
113
+ rule["ExposeHeaders"] = expose_headers
114
+ self.client.put_bucket_cors(
115
+ Bucket=bucket_name, CORSConfiguration={"CORSRules": [rule]}
116
+ )
117
+ logger.info(
118
+ "CORS configured on %s (methods=%s, origins=%s)",
119
+ bucket_name,
120
+ rule["AllowedMethods"],
121
+ rule["AllowedOrigins"],
122
+ )
123
+
124
+ def get_cors_config(self, bucket_name: str) -> list[dict] | None:
125
+ """Return the bucket's CORS rules, or None when it has no policy.
126
+
127
+ The distinction matters: an empty list is a policy that allows
128
+ nothing, while None means the store has never been told anything.
129
+ """
130
+ try:
131
+ return self.client.get_bucket_cors(Bucket=bucket_name).get("CORSRules", [])
132
+ except ClientError as exc:
133
+ if exc.response.get("Error", {}).get("Code") == "NoSuchCORSConfiguration":
134
+ return None
135
+ raise
99
136
 
100
137
  def upload_file(
101
138
  self,
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "granny-devops"
3
- version = "0.19.3"
3
+ version = "0.20.0"
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