nyl 0.1.0__tar.gz → 0.2.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 (103) hide show
  1. {nyl-0.1.0 → nyl-0.2.0}/PKG-INFO +1 -1
  2. {nyl-0.1.0 → nyl-0.2.0}/pyproject.toml +1 -1
  3. {nyl-0.1.0 → nyl-0.2.0}/src/nyl/commands/__init__.py +2 -0
  4. {nyl-0.1.0 → nyl-0.2.0}/src/nyl/commands/crds.py +3 -0
  5. nyl-0.2.0/src/nyl/commands/secrets.py +61 -0
  6. {nyl-0.1.0 → nyl-0.2.0}/src/nyl/commands/template.py +59 -35
  7. nyl-0.2.0/src/nyl/commands/tools/__init__.py +13 -0
  8. nyl-0.2.0/src/nyl/commands/tools/bcrypt.py +17 -0
  9. nyl-0.1.0/src/nyl/commands/secrets.py → nyl-0.2.0/src/nyl/commands/tools/sops.py +44 -60
  10. {nyl-0.1.0 → nyl-0.2.0}/src/nyl/generator/__init__.py +2 -2
  11. {nyl-0.1.0 → nyl-0.2.0}/src/nyl/profiles/kubeconfig.py +7 -3
  12. {nyl-0.1.0 → nyl-0.2.0}/src/nyl/project/config.py +6 -0
  13. nyl-0.2.0/src/nyl/resources/placeholder.py +110 -0
  14. {nyl-0.1.0 → nyl-0.2.0}/src/nyl/secrets/sops.py +17 -0
  15. nyl-0.2.0/src/nyl/templating.py +173 -0
  16. nyl-0.2.0/src/nyl/templating_test.py +9 -0
  17. nyl-0.1.0/src/nyl/templating.py +0 -130
  18. {nyl-0.1.0 → nyl-0.2.0}/.envrc +0 -0
  19. {nyl-0.1.0 → nyl-0.2.0}/.github/workflows/docker.yaml +0 -0
  20. {nyl-0.1.0 → nyl-0.2.0}/.github/workflows/docs.yaml +0 -0
  21. {nyl-0.1.0 → nyl-0.2.0}/.github/workflows/python.yaml +0 -0
  22. {nyl-0.1.0 → nyl-0.2.0}/.gitignore +0 -0
  23. {nyl-0.1.0 → nyl-0.2.0}/.kraken.py +0 -0
  24. {nyl-0.1.0 → nyl-0.2.0}/README.md +0 -0
  25. {nyl-0.1.0 → nyl-0.2.0}/argocd-cmp/Dockerfile +0 -0
  26. {nyl-0.1.0 → nyl-0.2.0}/argocd-cmp/README.md +0 -0
  27. {nyl-0.1.0 → nyl-0.2.0}/argocd-cmp/plugin.yaml +0 -0
  28. {nyl-0.1.0 → nyl-0.2.0}/argocd-with-nyl/.envrc +0 -0
  29. {nyl-0.1.0 → nyl-0.2.0}/argocd-with-nyl/.secrets.yaml +0 -0
  30. {nyl-0.1.0 → nyl-0.2.0}/argocd-with-nyl/.sops.yaml +0 -0
  31. {nyl-0.1.0 → nyl-0.2.0}/argocd-with-nyl/README.md +0 -0
  32. {nyl-0.1.0 → nyl-0.2.0}/argocd-with-nyl/argocd.yaml +0 -0
  33. {nyl-0.1.0 → nyl-0.2.0}/argocd-with-nyl/nyl-project.yaml +0 -0
  34. {nyl-0.1.0 → nyl-0.2.0}/argocd-with-nyl/nyl-secrets.yaml +0 -0
  35. {nyl-0.1.0 → nyl-0.2.0}/docs/content/index.md +0 -0
  36. {nyl-0.1.0 → nyl-0.2.0}/docs/content/reference/applysets.md +0 -0
  37. {nyl-0.1.0 → nyl-0.2.0}/docs/content/reference/argocd-plugin.md +0 -0
  38. {nyl-0.1.0 → nyl-0.2.0}/docs/content/reference/cluster-connectivity.md +0 -0
  39. {nyl-0.1.0 → nyl-0.2.0}/docs/content/reference/configuration/environment.md +0 -0
  40. {nyl-0.1.0 → nyl-0.2.0}/docs/content/reference/configuration/profiles.md +0 -0
  41. {nyl-0.1.0 → nyl-0.2.0}/docs/content/reference/configuration/projects.md +0 -0
  42. {nyl-0.1.0 → nyl-0.2.0}/docs/content/reference/configuration/secrets.md +0 -0
  43. {nyl-0.1.0 → nyl-0.2.0}/docs/content/reference/configuration-api/profiles.md +0 -0
  44. {nyl-0.1.0 → nyl-0.2.0}/docs/content/reference/configuration-api/secrets.md +0 -0
  45. {nyl-0.1.0 → nyl-0.2.0}/docs/content/reference/templating/basics.md +0 -0
  46. {nyl-0.1.0 → nyl-0.2.0}/docs/content/reference/templating/components.md +0 -0
  47. {nyl-0.1.0 → nyl-0.2.0}/docs/content/reference/templating/inlining.md +0 -0
  48. {nyl-0.1.0 → nyl-0.2.0}/docs/content/reference/templating/secrets.md +0 -0
  49. {nyl-0.1.0 → nyl-0.2.0}/docs/mkdocs.yml +0 -0
  50. {nyl-0.1.0 → nyl-0.2.0}/docs/pyproject.toml +0 -0
  51. {nyl-0.1.0 → nyl-0.2.0}/docs/uv.lock +0 -0
  52. {nyl-0.1.0 → nyl-0.2.0}/example/manifests/postgres.yaml +0 -0
  53. {nyl-0.1.0 → nyl-0.2.0}/example/nyl-profiles.yaml +0 -0
  54. {nyl-0.1.0 → nyl-0.2.0}/example/nyl-secrets.yaml +0 -0
  55. {nyl-0.1.0 → nyl-0.2.0}/example/secrets.yaml +0 -0
  56. {nyl-0.1.0 → nyl-0.2.0}/src/kubernetes/README.md +0 -0
  57. {nyl-0.1.0 → nyl-0.2.0}/src/kubernetes/__init__.pyi +0 -0
  58. {nyl-0.1.0 → nyl-0.2.0}/src/kubernetes/dynamic/__init__.pyi +0 -0
  59. {nyl-0.1.0 → nyl-0.2.0}/src/kubernetes/dynamic/client.pyi +0 -0
  60. {nyl-0.1.0 → nyl-0.2.0}/src/kubernetes/dynamic/discovery.pyi +0 -0
  61. {nyl-0.1.0 → nyl-0.2.0}/src/kubernetes/dynamic/exceptions.pyi +0 -0
  62. {nyl-0.1.0 → nyl-0.2.0}/src/kubernetes/dynamic/resource.pyi +0 -0
  63. {nyl-0.1.0 → nyl-0.2.0}/src/kubernetes/watch/__init__.pyi +0 -0
  64. {nyl-0.1.0 → nyl-0.2.0}/src/kubernetes/watch/watch.pyi +0 -0
  65. {nyl-0.1.0 → nyl-0.2.0}/src/nyl/__init__.py +0 -0
  66. {nyl-0.1.0 → nyl-0.2.0}/src/nyl/commands/argocd.py +0 -0
  67. {nyl-0.1.0 → nyl-0.2.0}/src/nyl/commands/new.py +0 -0
  68. {nyl-0.1.0 → nyl-0.2.0}/src/nyl/commands/profile.py +0 -0
  69. {nyl-0.1.0 → nyl-0.2.0}/src/nyl/commands/run.py +0 -0
  70. {nyl-0.1.0 → nyl-0.2.0}/src/nyl/commands/tun.py +0 -0
  71. {nyl-0.1.0 → nyl-0.2.0}/src/nyl/generator/components.py +0 -0
  72. {nyl-0.1.0 → nyl-0.2.0}/src/nyl/generator/dispatch.py +0 -0
  73. {nyl-0.1.0 → nyl-0.2.0}/src/nyl/generator/dispatch_test.py +0 -0
  74. {nyl-0.1.0 → nyl-0.2.0}/src/nyl/generator/helmchart.py +0 -0
  75. {nyl-0.1.0 → nyl-0.2.0}/src/nyl/generator/statefulsecret.py +0 -0
  76. {nyl-0.1.0 → nyl-0.2.0}/src/nyl/profiles/__init__.py +0 -0
  77. {nyl-0.1.0 → nyl-0.2.0}/src/nyl/profiles/config.py +0 -0
  78. {nyl-0.1.0 → nyl-0.2.0}/src/nyl/profiles/config_test.py +0 -0
  79. {nyl-0.1.0 → nyl-0.2.0}/src/nyl/profiles/tunnel.py +0 -0
  80. {nyl-0.1.0 → nyl-0.2.0}/src/nyl/resources/__init__.py +0 -0
  81. {nyl-0.1.0 → nyl-0.2.0}/src/nyl/resources/applyset.py +0 -0
  82. {nyl-0.1.0 → nyl-0.2.0}/src/nyl/resources/applyset_test.py +0 -0
  83. {nyl-0.1.0 → nyl-0.2.0}/src/nyl/resources/helmchart.py +0 -0
  84. {nyl-0.1.0 → nyl-0.2.0}/src/nyl/resources/statefulsecret.py +0 -0
  85. {nyl-0.1.0 → nyl-0.2.0}/src/nyl/secrets/__init__.py +0 -0
  86. {nyl-0.1.0 → nyl-0.2.0}/src/nyl/secrets/config.py +0 -0
  87. {nyl-0.1.0 → nyl-0.2.0}/src/nyl/secrets/config_test.py +0 -0
  88. {nyl-0.1.0 → nyl-0.2.0}/src/nyl/secrets/null.py +0 -0
  89. {nyl-0.1.0 → nyl-0.2.0}/src/nyl/secrets/sops_test.py +0 -0
  90. {nyl-0.1.0 → nyl-0.2.0}/src/nyl/tools/fs.py +0 -0
  91. {nyl-0.1.0 → nyl-0.2.0}/src/nyl/tools/fs_test.py +0 -0
  92. {nyl-0.1.0 → nyl-0.2.0}/src/nyl/tools/kubectl.py +0 -0
  93. {nyl-0.1.0 → nyl-0.2.0}/src/nyl/tools/kubernetes.py +0 -0
  94. {nyl-0.1.0 → nyl-0.2.0}/src/nyl/tools/kvstore.py +0 -0
  95. {nyl-0.1.0 → nyl-0.2.0}/src/nyl/tools/loads.py +0 -0
  96. {nyl-0.1.0 → nyl-0.2.0}/src/nyl/tools/logging.py +0 -0
  97. {nyl-0.1.0 → nyl-0.2.0}/src/nyl/tools/shell.py +0 -0
  98. {nyl-0.1.0 → nyl-0.2.0}/src/nyl/tools/testing.py +0 -0
  99. {nyl-0.1.0 → nyl-0.2.0}/src/nyl/tools/typer.py +0 -0
  100. {nyl-0.1.0 → nyl-0.2.0}/src/nyl/tools/types.py +0 -0
  101. {nyl-0.1.0 → nyl-0.2.0}/tests/__init__.py +0 -0
  102. {nyl-0.1.0 → nyl-0.2.0}/tests/conftest.py +0 -0
  103. {nyl-0.1.0 → nyl-0.2.0}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: nyl
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: Advanced templating engine for Kubernetes and GitOps.
5
5
  Author-email: Niklas Rosenstein <rosensteinniklas@gmail.com>
6
6
  License: MIT
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "nyl"
7
- version = "0.1.0" # sed'ed on publish in CI
7
+ version = "0.2.0" # sed'ed on publish in CI
8
8
  description = "Advanced templating engine for Kubernetes and GitOps."
9
9
  authors = [
10
10
  {name = "Niklas Rosenstein", email = "rosensteinniklas@gmail.com"},
@@ -29,12 +29,14 @@ from . import profile # noqa: E402
29
29
  from . import run # noqa: F401, E402
30
30
  from . import secrets # noqa: E402
31
31
  from . import template # noqa: F401,E402
32
+ from . import tools # noqa: E402
32
33
  from . import tun # noqa: E402
33
34
 
34
35
  app.add_typer(argocd.app)
35
36
  app.add_typer(new.app)
36
37
  app.add_typer(profile.app)
37
38
  app.add_typer(secrets.app)
39
+ app.add_typer(tools.app)
38
40
  app.add_typer(tun.app)
39
41
 
40
42
 
@@ -1,6 +1,7 @@
1
1
  import yaml
2
2
 
3
3
  from nyl.resources.applyset import ApplySet
4
+ from nyl.resources.placeholder import Placeholder
4
5
  from . import app
5
6
 
6
7
 
@@ -12,3 +13,5 @@ def crds() -> None:
12
13
 
13
14
  print("---")
14
15
  print(yaml.safe_dump(ApplySet.CRD))
16
+ print("---")
17
+ print(yaml.safe_dump(Placeholder.CRD))
@@ -0,0 +1,61 @@
1
+ """
2
+ Interact with the secrets providers configured in `nyl-secrets.yaml`.
3
+ """
4
+
5
+ import json
6
+
7
+ from typer import Option
8
+ from nyl.secrets.config import SecretsConfig
9
+ from nyl.tools.typer import new_typer
10
+
11
+
12
+ app = new_typer(name="secrets", help=__doc__)
13
+
14
+ # Initialized from callback for access by subcommands.
15
+ provider: str
16
+
17
+
18
+ @app.callback()
19
+ def callback(
20
+ _provider: str = Option(
21
+ "default",
22
+ "--provider",
23
+ help="The name of the configured secrets provider to use.",
24
+ envvar="NYL_SECRETS",
25
+ ),
26
+ ) -> None:
27
+ """
28
+ Interact with the secrets providers configured in `nyl-secrets.yaml`.
29
+ """
30
+
31
+ global provider
32
+ provider = _provider
33
+
34
+
35
+ @app.command()
36
+ def list(
37
+ providers: bool = Option(
38
+ False, help="List the configured secrets providers instead of the current provider's available keys."
39
+ ),
40
+ ) -> None:
41
+ """
42
+ List the keys for all secrets in the provider.
43
+ """
44
+
45
+ secrets = SecretsConfig.load()
46
+ if providers:
47
+ for alias, impl in secrets.providers.items():
48
+ print(alias, impl)
49
+ else:
50
+ for key in secrets.providers[provider].keys():
51
+ print(key)
52
+
53
+
54
+ @app.command()
55
+ def get(key: str, pretty: bool = False) -> None:
56
+ """
57
+ Get the value of a secret as JSON.
58
+ """
59
+
60
+ secrets = SecretsConfig.load()
61
+ print(json.dumps(secrets.providers[provider].get(key), indent=4 if pretty else None))
@@ -3,7 +3,7 @@ from dataclasses import dataclass
3
3
  import os
4
4
  from pathlib import Path
5
5
  from textwrap import indent
6
- from typing import Optional, cast
6
+ from typing import Optional
7
7
  from loguru import logger
8
8
  from typer import Argument, Option
9
9
  import yaml
@@ -66,6 +66,18 @@ def template(
66
66
  "This option must be disabled when passing the generated manifests to `kubectl apply --applyset=...`, as it "
67
67
  "would otherwise cause an error due to the label being present on the input data.",
68
68
  ),
69
+ default_namespace: Optional[str] = Option(
70
+ None,
71
+ help="The name of the Kubernetes namespace to fill in to Kubernetes resource that don't have a namespace set. "
72
+ "If this is not specified as an argument or via environment variables, it will default to the stem of the "
73
+ "manifest source filename. Note that if a manifest defines a Namespace resource, that namespace is used "
74
+ "instead, regardless of the value of this option. If a manifest source file defines multiple namespaces and a "
75
+ "resource without namespace is encountered, this option is considered if it matches one of the namespaces "
76
+ "defined in the file. Note that this option is usually problematic when rendering multiple files, as often "
77
+ "a single file is intended to deploy into a single namespace.\n\n"
78
+ "Note that Nyl's detection for cluster-scoped vs. namespace-scoped resources is not very good, yet.",
79
+ envvar="ARGOCD_APP_NAMESPACE",
80
+ ),
69
81
  inline: bool = Option(True, help="Evaluate Nyl inlined resources."),
70
82
  state_dir: Optional[Path] = Option(
71
83
  None, help="The directory to store state in (such as kubeconfig files).", envvar="NYL_STATE_DIR"
@@ -142,7 +154,9 @@ def template(
142
154
  secrets = SecretsConfig.load()
143
155
  client = ApiClient()
144
156
 
145
- template_engine = NylTemplateEngine(secrets.providers[secrets_provider], client)
157
+ template_engine = NylTemplateEngine(
158
+ secrets.providers[secrets_provider], client, create_placeholders=project.config.settings.generate_placeholders
159
+ )
146
160
 
147
161
  generator = DispatchingGenerator.default(
148
162
  cache_dir=cache_dir,
@@ -157,19 +171,19 @@ def template(
157
171
  for source in load_manifests(paths):
158
172
  logger.opt(ansi=True).info("Rendering manifests from <blue>{}</>.", source.file)
159
173
 
160
- source.manifests = cast(Manifests, template_engine.evaluate(source.manifests))
174
+ source.manifests = template_engine.evaluate(source.manifests)
161
175
  if inline:
162
176
  source.manifests = reconcile_generator(
163
177
  generator,
164
178
  source.manifests,
165
- on_generated=lambda m: cast(Manifest, template_engine.evaluate(m)),
179
+ on_generated=lambda m: template_engine.evaluate(Manifests([m])),
166
180
  )
167
181
 
168
182
  # Find the namespaces that are defined in the file. If we find any manifests without a namespace, we will
169
- # inject that namespace name into them.
170
- # Also find the applyset defined in the file.
183
+ # inject that namespace name into them. Also find the applyset defined in the file.
171
184
  namespaces: set[str] = set()
172
185
  applyset: ApplySet | None = None
186
+
173
187
  for manifest in list(source.manifests):
174
188
  if is_namespace_resource(manifest):
175
189
  namespaces.add(manifest["metadata"]["name"])
@@ -183,23 +197,40 @@ def template(
183
197
  applyset = ApplySet.load(manifest)
184
198
  source.manifests.remove(manifest)
185
199
 
200
+ # Determine what namespace to fill in if we encounter resources without it.
201
+ current_default_namespace = default_namespace
202
+ if current_default_namespace is None:
203
+ if len(namespaces) == 1:
204
+ current_default_namespace = next(iter(namespaces))
205
+ elif len(namespaces) == 0:
206
+ current_default_namespace = source.file.stem
207
+ elif len(namespaces) > 2 and current_default_namespace not in namespaces:
208
+ logger.error(
209
+ "The specified --default-namespace='{}' is not defined in manifest source file '{}', despite it "
210
+ "defining {} namespaces. If your manifest source file defines multiple namespaces, the specified "
211
+ "--default-namespace must be one of the namespaces defined in it (the file current defines "
212
+ "the following namespaces: {})",
213
+ default_namespace,
214
+ source.file,
215
+ len(namespaces),
216
+ ", ".join(namespaces),
217
+ )
218
+ exit(1)
219
+
186
220
  if not applyset and project.config.settings.generate_applysets:
187
- if len(namespaces) > 1:
221
+ if not current_default_namespace:
188
222
  logger.opt(ansi=True).error(
189
- "Multiple namespaces defined in <yellow>{}</>, but automatic ApplySet generation is enabled. "
190
- "There can only be one namespace per source in this case.",
223
+ "No default namespace defined for <yellow>{}</>, but it is required for the automatically "
224
+ "generated nyl.io/v1/ApplySet resource (the ApplySet is named after the default namespace).",
191
225
  source.file,
192
226
  )
193
227
  exit(1)
194
- elif len(namespaces) == 1:
195
- applyset_name = next(iter(namespaces))
196
- else:
197
- applyset_name = source.file.stem
198
228
 
229
+ applyset_name = current_default_namespace
230
+ applyset = ApplySet.new(applyset_name)
199
231
  logger.opt(ansi=True).info(
200
232
  "Automatically creating ApplySet for <blue>{}</> (name: <magenta>{}</>).", source.file, applyset_name
201
233
  )
202
- applyset = ApplySet.new(applyset_name)
203
234
 
204
235
  if applyset is not None:
205
236
  applyset.set_group_kinds(source.manifests)
@@ -234,7 +265,7 @@ def template(
234
265
 
235
266
  if "metadata" not in manifest:
236
267
  logger.opt(ansi=True).error(
237
- "Manifest in <yellow>'{}'</> has no <cyan>metadata</> key:\n\n{}",
268
+ "A manifest in <yellow>'{}'</> has no <cyan>metadata</> key:\n\n{}",
238
269
  source.file,
239
270
  indent(yaml.safe_dump(manifest), " "),
240
271
  )
@@ -245,30 +276,23 @@ def template(
245
276
  labels[APPLYSET_LABEL_PART_OF] = applyset.id
246
277
 
247
278
  if not is_cluster_scoped_resource(manifest) and "namespace" not in manifest["metadata"]:
248
- if len(namespaces) > 1:
279
+ if not current_default_namespace:
249
280
  logger.error(
250
- "Multiple namespaces defined in '{}', but manifest {}/{} does not have a namespace.",
281
+ "A namespace-scoped manifest in '{}' has no namespace, and no default namespace is defined. "
282
+ "Try defining a single namespace in the manifest source file, naming the manifest source file "
283
+ "the same as that default namespace or passing the --default-namespace option.",
251
284
  source.file,
252
- manifest["kind"],
253
- manifest["metadata"]["name"],
254
285
  )
255
286
  exit(1)
256
- elif len(namespaces) == 0:
257
- logger.warning(
258
- "No namespaces defined in '{}', injecting 'default' into manifest {}/{}.",
259
- source.file,
260
- manifest["kind"],
261
- manifest["metadata"]["name"],
262
- )
263
- manifest["metadata"]["namespace"] = "default"
264
- else:
265
- logger.trace(
266
- "Injecting namespace '{}' into manifest {}/{}.",
267
- next(iter(namespaces)),
268
- manifest["kind"],
269
- manifest["metadata"]["name"],
270
- )
271
- manifest["metadata"]["namespace"] = next(iter(namespaces))
287
+
288
+ logger.trace(
289
+ "Injecting namespace '{}' into manifest {}/{}.",
290
+ current_default_namespace,
291
+ manifest["kind"],
292
+ manifest["metadata"]["name"],
293
+ )
294
+ manifest["metadata"]["namespace"] = current_default_namespace
295
+
272
296
  elif not is_cluster_scoped_resource(manifest) and manifest["metadata"]["namespace"] not in namespaces:
273
297
  logger.warning(
274
298
  "Resource '{}/{}' in '{}' references namespace '{}', which is not defined in the file.",
@@ -0,0 +1,13 @@
1
+ """
2
+ Swiss-army-knife for various common operations in conjunction with Nyl (Kubernetes, secrets management, etc).
3
+ """
4
+
5
+ from nyl.tools.typer import new_typer
6
+
7
+
8
+ app = new_typer(name="tools", help=__doc__)
9
+
10
+ from . import sops # noqa: E402
11
+ from . import bcrypt # noqa: F401,E402
12
+
13
+ app.add_typer(sops.app)
@@ -0,0 +1,17 @@
1
+ import sys
2
+ from typing import Optional
3
+
4
+ from bcrypt import gensalt, hashpw
5
+ from . import app
6
+
7
+
8
+ @app.command()
9
+ def bcrypt(text: Optional[str] = None) -> None:
10
+ """
11
+ Bcrypt-hash the specified text. If no argument is specified, read from stdin.
12
+ """
13
+
14
+ if text is None:
15
+ text = sys.stdin.read()
16
+
17
+ print(hashpw(text.encode("utf-8"), gensalt()).decode("utf-8"))
@@ -1,75 +1,32 @@
1
1
  """
2
- Interact with the secrets providers configured in `nyl-secrets.yaml`.
2
+ Utilities for SOPS files.
3
3
  """
4
4
 
5
- import json
6
5
  from pathlib import Path
6
+ from shlex import quote
7
7
  import sys
8
8
  from typing import Optional
9
9
 
10
10
  from loguru import logger
11
11
  from typer import Option
12
12
  from nyl.secrets.config import SecretsConfig
13
- from nyl.secrets.sops import SopsFile
13
+ from nyl.secrets.sops import SopsFile, detect_sops_format
14
14
  from nyl.tools.fs import shorter_path
15
15
  from nyl.tools.logging import lazy_str
16
16
  from nyl.tools.typer import new_typer
17
17
 
18
18
 
19
- app = new_typer(name="secrets", help=__doc__)
20
-
21
- sops = new_typer(name="sops", help=__doc__)
22
- app.add_typer(sops)
23
-
24
- # Initialized from callback for access by subcommands.
25
- provider: str
26
-
27
-
28
- @app.callback()
29
- def callback(
30
- _provider: str = Option(
31
- "default", "--provider", help="The name of the configured secrets provider to use.", envvar="NYL_SECRETS"
32
- ),
33
- ) -> None:
34
- """
35
- Interact with the secrets providers configured in `nyl-secrets.yaml`.
36
- """
37
-
38
- global provider
39
- provider = _provider
40
-
41
-
42
- @app.command()
43
- def list(
44
- providers: bool = Option(
45
- False, help="List the configured secrets providers instead of the current provider's available keys."
46
- ),
47
- ) -> None:
48
- """
49
- List the keys for all secrets in the provider.
50
- """
51
-
52
- secrets = SecretsConfig.load()
53
- if providers:
54
- for alias, impl in secrets.providers.items():
55
- print(alias, impl)
56
- else:
57
- for key in secrets.providers[provider].keys():
58
- print(key)
19
+ app = new_typer(name="sops", help=__doc__)
59
20
 
60
21
 
61
22
  @app.command()
62
- def get(key: str, pretty: bool = False) -> None:
63
- """
64
- Get the value of a secret as JSON.
65
- """
66
-
67
- secrets = SecretsConfig.load()
68
- print(json.dumps(secrets.providers[provider].get(key), indent=4 if pretty else None))
69
-
70
-
71
- @sops.command()
72
23
  def re_encrypt(
24
+ provider: str = Option(
25
+ "default",
26
+ "--provider",
27
+ help="The name of the configured secrets provider to use.",
28
+ envvar="NYL_SECRETS",
29
+ ),
73
30
  file: Optional[Path] = Option(
74
31
  None,
75
32
  help="The SOPS-file to re-encrypt. Defaults to the SOPS file mentioned in `nyl-secrets.yaml` "
@@ -99,16 +56,43 @@ def re_encrypt(
99
56
  logger.opt(ansi=True).info("re-encrypting file '<blue>{}</>'", lazy_str(lambda f: str(shorter_path(f)), file))
100
57
 
101
58
  if file_type is None:
102
- if file.suffix in (".yml", ".yaml"):
103
- file_type = "yaml"
104
- elif file.suffix in (".json", ".json5"):
105
- file_type = "json"
106
- elif file.suffix in (".sh", ".bash", ".env"):
107
- file_type = "dotenv"
108
- else:
59
+ file_type = detect_sops_format(file.suffix)
60
+ if not file_type:
109
61
  logger.error("could not determine SOPS input/output type from filename, specify with the --type option")
110
62
  sys.exit(1)
111
63
 
112
64
  sops = SopsFile(file)
113
65
  sops.load(file_type)
114
66
  sops.save(file_type)
67
+
68
+
69
+ @app.command()
70
+ def export_dotenv(
71
+ file: Path,
72
+ prefix: str = Option("", help="Only export keys with the given prefix, and strip the prefix."),
73
+ file_type: Optional[str] = Option(
74
+ None, "--type", help="The SOPS input type if it cannot be determined from the file name."
75
+ ),
76
+ ) -> None:
77
+ """
78
+ A utility function to export key-value pairs from a SOPS file in dotenv format. This is useful for exporting
79
+ environment variables from a SOPS file, e.g. using Direnv.
80
+
81
+ Note that only string values are exported.
82
+ """
83
+
84
+ if file_type is None:
85
+ file_type = detect_sops_format(file.suffix)
86
+ if not file_type:
87
+ logger.error("could not determine SOPS input type from filename, specify with the --type option")
88
+ sys.exit(1)
89
+
90
+ sops = SopsFile(file)
91
+ sops.load(file_type)
92
+
93
+ for key in sops.keys():
94
+ if not key.startswith(prefix):
95
+ continue
96
+ value = sops.get(key)
97
+ if isinstance(value, str):
98
+ print(f"export {key[len(prefix):]}={quote(value)}")
@@ -36,7 +36,7 @@ class Generator(ABC, Generic[T]):
36
36
  def reconcile_generator(
37
37
  generator: Generator[Manifest],
38
38
  manifests: Manifests,
39
- on_generated: Callable[[Manifest], Manifest],
39
+ on_generated: Callable[[Manifest], Manifests],
40
40
  ) -> Manifests:
41
41
  """
42
42
  Recursively reconcile all Nyl resources in the manifests using the given generator.
@@ -64,7 +64,7 @@ def reconcile_generator(
64
64
  else:
65
65
  seen.add(resource_hash)
66
66
  for manifest in generator.generate(resource):
67
- queue.append(on_generated(manifest))
67
+ queue.extend(on_generated(manifest))
68
68
  loops += 1
69
69
 
70
70
  return result
@@ -109,7 +109,7 @@ class KubeconfigManager:
109
109
  self, *, profile_name: str, path: Path, context: str, api_host: str, api_port: int
110
110
  ) -> Path:
111
111
  kubeconfig_data = yaml.safe_load(path.read_text())
112
- kubeconfig_data = _trim_to_context(kubeconfig_data, context)
112
+ kubeconfig_data = _trim_to_context(kubeconfig_data, context, rename_context=profile_name)
113
113
 
114
114
  # TODO: Do we need to support the Kubernetes API hosted on a subpath?
115
115
  kubeconfig_data["clusters"][0]["cluster"]["server"] = f"https://{api_host}:{api_port}"
@@ -122,7 +122,9 @@ class KubeconfigManager:
122
122
  return final_kubeconfig
123
123
 
124
124
 
125
- def _trim_to_context(kubeconfig: dict[str, Any], context: str | None) -> dict[str, Any]:
125
+ def _trim_to_context(
126
+ kubeconfig: dict[str, Any], context: str | None, rename_context: str | None = None
127
+ ) -> dict[str, Any]:
126
128
  """
127
129
  Trim a Kubeconfig down to a single context. If *context* is `None`, it will be trinmed to the current context.
128
130
  """
@@ -130,12 +132,14 @@ def _trim_to_context(kubeconfig: dict[str, Any], context: str | None) -> dict[st
130
132
  if context is None:
131
133
  context = kubeconfig["current-context"]
132
134
  else:
133
- kubeconfig["current-context"] = context
135
+ kubeconfig["current-context"] = rename_context or context
134
136
 
135
137
  kubeconfig["contexts"] = [c for c in kubeconfig["contexts"] if c["name"] == context]
136
138
  if not kubeconfig["contexts"]:
137
139
  raise ValueError(f"Context '{context}' not found in Kubeconfig file.")
138
140
  context_data = kubeconfig["contexts"][0]
141
+ if rename_context:
142
+ context_data["name"] = rename_context
139
143
 
140
144
  kubeconfig["clusters"] = [c for c in kubeconfig["clusters"] if c["name"] == context_data["context"]["cluster"]]
141
145
  if not kubeconfig["clusters"]:
@@ -24,6 +24,12 @@ class ProjectSettings:
24
24
  after the namespace.
25
25
  """
26
26
 
27
+ generate_placeholders: bool = False
28
+ """
29
+ If enabled, resources with templates that fail to evaluate due to unsatisfied conditions (e.g. lookup errors) will
30
+ be replaced with `nyl.io/v1/Placeholder` resources during templating. Otherwise, the error will be propagated.
31
+ """
32
+
27
33
  components_path: Path | None = None
28
34
  """
29
35
  Path to the directory that contains Nyl components.
@@ -0,0 +1,110 @@
1
+ import base64
2
+ from dataclasses import dataclass
3
+ import hashlib
4
+ from typing import Annotated, ClassVar
5
+ from nyl.resources import API_VERSION_K8S, NylResource, ObjectMetadata
6
+ from databind.core import SerializeDefaults
7
+
8
+
9
+ @dataclass(kw_only=True)
10
+ class Placeholder(NylResource, api_version=API_VERSION_K8S):
11
+ """
12
+ A Placeholder is a dummy custom resource that is used to represent a resource that failed to evaluate during
13
+ templating. This is useful when you want to define a manifest that has dependencies on other manifests based on
14
+ `lookup()` calls, but you want to avoid the evaluation of the manifest to fail if the lookup fails.
15
+
16
+ The placeholder can inform you that a component of your manifest is missing, and you can then decide how to handle
17
+ the situation.
18
+
19
+ Note that removal of Placeholder resources is not handled by Nyl: It will either be pruned as part of a
20
+ reconciliation by ArgoCD (if you're using that) or pruned with ApplySets. If you're not using either of those, you
21
+ will have to manually remove the Placeholder resources.
22
+
23
+ A placeholder can contain arbitrary `spec` data and usually contains fields to help you identify the missing
24
+ resource.
25
+ """
26
+
27
+ # HACK: Can't set it on the class level, see https://github.com/NiklasRosenstein/python-databind/issues/73.
28
+ metadata: Annotated[ObjectMetadata, SerializeDefaults(False)]
29
+
30
+ CRD: ClassVar = {
31
+ "apiVersion": "apiextensions.k8s.io/v1",
32
+ "kind": "CustomResourceDefinition",
33
+ "metadata": {
34
+ "name": f"placeholders.{API_VERSION_K8S.split('/')[0]}",
35
+ },
36
+ "spec": {
37
+ "group": API_VERSION_K8S.split("/")[0],
38
+ "names": {
39
+ "kind": "Placeholder",
40
+ "plural": "placeholders",
41
+ },
42
+ "scope": "Namespaced",
43
+ "versions": [
44
+ {
45
+ "name": "v1",
46
+ "served": True,
47
+ "storage": True,
48
+ "schema": {
49
+ "openAPIV3Schema": {
50
+ "type": "object",
51
+ "properties": {
52
+ "spec": {
53
+ "type": "object",
54
+ "properties": {
55
+ "message": {
56
+ "type": "string",
57
+ "description": "A message that describes the missing resource.",
58
+ },
59
+ "reason": {
60
+ "type": "string",
61
+ "description": "The reason why the resource is missing (e.g. 'NotFound').",
62
+ },
63
+ },
64
+ },
65
+ },
66
+ }
67
+ },
68
+ }
69
+ ],
70
+ },
71
+ }
72
+
73
+ @staticmethod
74
+ def new(name: str, namespace: str) -> "Placeholder":
75
+ return Placeholder(
76
+ metadata=ObjectMetadata(
77
+ name=name,
78
+ namespace=namespace,
79
+ )
80
+ )
81
+
82
+
83
+ def calculate_applyset_id(*, name: str, namespace: str = "", group: str) -> str:
84
+ """
85
+ Calculate the ID of a Kubernetes ApplySet with the specified name.
86
+ """
87
+
88
+ # reference: https://kubernetes.io/docs/reference/labels-annotations-taints/#applyset-kubernetes-io-id
89
+ hash = hashlib.sha256(f"{name}.{namespace}.ApplySet.{group}".encode()).digest()
90
+ uid = base64.b64encode(hash).decode().rstrip("=").replace("/", "_").replace("+", "-")
91
+ return f"applyset-{uid}-v1"
92
+
93
+
94
+ def get_canonical_resource_kind_name(api_version: str, kind: str) -> str:
95
+ """
96
+ Given the apiVersion and kind of a Kubernetes resource, return the canonical name of the resource. This name can
97
+ be used to identify the resource in an ApplySet's `applyset.kubernetes.io/contains-group-kinds` annotation.
98
+
99
+ Note that according to the [reference][1], the resource name should use the plural form, but it appears that the
100
+ resource kind name is also accepted. Deriving the plural form will be difficult without querying the Kubernetes
101
+ API.
102
+
103
+ [1]: https://kubernetes.io/docs/reference/labels-annotations-taints/#applyset-kubernetes-io-contains-group-kinds
104
+
105
+ Args:
106
+ api_version: The apiVersion of the resource.
107
+ kind: The kind of the resource.
108
+ """
109
+
110
+ return (f"{kind}." + (api_version.split("/")[0] if "/" in api_version else "")).rstrip(".")
@@ -108,3 +108,20 @@ class SopsFile(SecretProvider):
108
108
  raise KeyError(".".join(parts[: idx + 1]))
109
109
  value = value[part]
110
110
  return value
111
+
112
+
113
+ def detect_sops_format(suffix: str) -> str | None:
114
+ """
115
+ Tells the SOPS file format based on the file suffix. Never returns "binary".
116
+ Returns `None` if the format cannot be determined.
117
+ """
118
+
119
+ suffix = suffix.removeprefix(".")
120
+ if suffix in ("yml", "yaml"):
121
+ return "yaml"
122
+ elif suffix in ("json", "json5"):
123
+ return "json"
124
+ elif suffix in ("sh", "bash", "env"):
125
+ return "dotenv"
126
+ else:
127
+ return None