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.
- {nyl-0.1.0 → nyl-0.2.0}/PKG-INFO +1 -1
- {nyl-0.1.0 → nyl-0.2.0}/pyproject.toml +1 -1
- {nyl-0.1.0 → nyl-0.2.0}/src/nyl/commands/__init__.py +2 -0
- {nyl-0.1.0 → nyl-0.2.0}/src/nyl/commands/crds.py +3 -0
- nyl-0.2.0/src/nyl/commands/secrets.py +61 -0
- {nyl-0.1.0 → nyl-0.2.0}/src/nyl/commands/template.py +59 -35
- nyl-0.2.0/src/nyl/commands/tools/__init__.py +13 -0
- nyl-0.2.0/src/nyl/commands/tools/bcrypt.py +17 -0
- nyl-0.1.0/src/nyl/commands/secrets.py → nyl-0.2.0/src/nyl/commands/tools/sops.py +44 -60
- {nyl-0.1.0 → nyl-0.2.0}/src/nyl/generator/__init__.py +2 -2
- {nyl-0.1.0 → nyl-0.2.0}/src/nyl/profiles/kubeconfig.py +7 -3
- {nyl-0.1.0 → nyl-0.2.0}/src/nyl/project/config.py +6 -0
- nyl-0.2.0/src/nyl/resources/placeholder.py +110 -0
- {nyl-0.1.0 → nyl-0.2.0}/src/nyl/secrets/sops.py +17 -0
- nyl-0.2.0/src/nyl/templating.py +173 -0
- nyl-0.2.0/src/nyl/templating_test.py +9 -0
- nyl-0.1.0/src/nyl/templating.py +0 -130
- {nyl-0.1.0 → nyl-0.2.0}/.envrc +0 -0
- {nyl-0.1.0 → nyl-0.2.0}/.github/workflows/docker.yaml +0 -0
- {nyl-0.1.0 → nyl-0.2.0}/.github/workflows/docs.yaml +0 -0
- {nyl-0.1.0 → nyl-0.2.0}/.github/workflows/python.yaml +0 -0
- {nyl-0.1.0 → nyl-0.2.0}/.gitignore +0 -0
- {nyl-0.1.0 → nyl-0.2.0}/.kraken.py +0 -0
- {nyl-0.1.0 → nyl-0.2.0}/README.md +0 -0
- {nyl-0.1.0 → nyl-0.2.0}/argocd-cmp/Dockerfile +0 -0
- {nyl-0.1.0 → nyl-0.2.0}/argocd-cmp/README.md +0 -0
- {nyl-0.1.0 → nyl-0.2.0}/argocd-cmp/plugin.yaml +0 -0
- {nyl-0.1.0 → nyl-0.2.0}/argocd-with-nyl/.envrc +0 -0
- {nyl-0.1.0 → nyl-0.2.0}/argocd-with-nyl/.secrets.yaml +0 -0
- {nyl-0.1.0 → nyl-0.2.0}/argocd-with-nyl/.sops.yaml +0 -0
- {nyl-0.1.0 → nyl-0.2.0}/argocd-with-nyl/README.md +0 -0
- {nyl-0.1.0 → nyl-0.2.0}/argocd-with-nyl/argocd.yaml +0 -0
- {nyl-0.1.0 → nyl-0.2.0}/argocd-with-nyl/nyl-project.yaml +0 -0
- {nyl-0.1.0 → nyl-0.2.0}/argocd-with-nyl/nyl-secrets.yaml +0 -0
- {nyl-0.1.0 → nyl-0.2.0}/docs/content/index.md +0 -0
- {nyl-0.1.0 → nyl-0.2.0}/docs/content/reference/applysets.md +0 -0
- {nyl-0.1.0 → nyl-0.2.0}/docs/content/reference/argocd-plugin.md +0 -0
- {nyl-0.1.0 → nyl-0.2.0}/docs/content/reference/cluster-connectivity.md +0 -0
- {nyl-0.1.0 → nyl-0.2.0}/docs/content/reference/configuration/environment.md +0 -0
- {nyl-0.1.0 → nyl-0.2.0}/docs/content/reference/configuration/profiles.md +0 -0
- {nyl-0.1.0 → nyl-0.2.0}/docs/content/reference/configuration/projects.md +0 -0
- {nyl-0.1.0 → nyl-0.2.0}/docs/content/reference/configuration/secrets.md +0 -0
- {nyl-0.1.0 → nyl-0.2.0}/docs/content/reference/configuration-api/profiles.md +0 -0
- {nyl-0.1.0 → nyl-0.2.0}/docs/content/reference/configuration-api/secrets.md +0 -0
- {nyl-0.1.0 → nyl-0.2.0}/docs/content/reference/templating/basics.md +0 -0
- {nyl-0.1.0 → nyl-0.2.0}/docs/content/reference/templating/components.md +0 -0
- {nyl-0.1.0 → nyl-0.2.0}/docs/content/reference/templating/inlining.md +0 -0
- {nyl-0.1.0 → nyl-0.2.0}/docs/content/reference/templating/secrets.md +0 -0
- {nyl-0.1.0 → nyl-0.2.0}/docs/mkdocs.yml +0 -0
- {nyl-0.1.0 → nyl-0.2.0}/docs/pyproject.toml +0 -0
- {nyl-0.1.0 → nyl-0.2.0}/docs/uv.lock +0 -0
- {nyl-0.1.0 → nyl-0.2.0}/example/manifests/postgres.yaml +0 -0
- {nyl-0.1.0 → nyl-0.2.0}/example/nyl-profiles.yaml +0 -0
- {nyl-0.1.0 → nyl-0.2.0}/example/nyl-secrets.yaml +0 -0
- {nyl-0.1.0 → nyl-0.2.0}/example/secrets.yaml +0 -0
- {nyl-0.1.0 → nyl-0.2.0}/src/kubernetes/README.md +0 -0
- {nyl-0.1.0 → nyl-0.2.0}/src/kubernetes/__init__.pyi +0 -0
- {nyl-0.1.0 → nyl-0.2.0}/src/kubernetes/dynamic/__init__.pyi +0 -0
- {nyl-0.1.0 → nyl-0.2.0}/src/kubernetes/dynamic/client.pyi +0 -0
- {nyl-0.1.0 → nyl-0.2.0}/src/kubernetes/dynamic/discovery.pyi +0 -0
- {nyl-0.1.0 → nyl-0.2.0}/src/kubernetes/dynamic/exceptions.pyi +0 -0
- {nyl-0.1.0 → nyl-0.2.0}/src/kubernetes/dynamic/resource.pyi +0 -0
- {nyl-0.1.0 → nyl-0.2.0}/src/kubernetes/watch/__init__.pyi +0 -0
- {nyl-0.1.0 → nyl-0.2.0}/src/kubernetes/watch/watch.pyi +0 -0
- {nyl-0.1.0 → nyl-0.2.0}/src/nyl/__init__.py +0 -0
- {nyl-0.1.0 → nyl-0.2.0}/src/nyl/commands/argocd.py +0 -0
- {nyl-0.1.0 → nyl-0.2.0}/src/nyl/commands/new.py +0 -0
- {nyl-0.1.0 → nyl-0.2.0}/src/nyl/commands/profile.py +0 -0
- {nyl-0.1.0 → nyl-0.2.0}/src/nyl/commands/run.py +0 -0
- {nyl-0.1.0 → nyl-0.2.0}/src/nyl/commands/tun.py +0 -0
- {nyl-0.1.0 → nyl-0.2.0}/src/nyl/generator/components.py +0 -0
- {nyl-0.1.0 → nyl-0.2.0}/src/nyl/generator/dispatch.py +0 -0
- {nyl-0.1.0 → nyl-0.2.0}/src/nyl/generator/dispatch_test.py +0 -0
- {nyl-0.1.0 → nyl-0.2.0}/src/nyl/generator/helmchart.py +0 -0
- {nyl-0.1.0 → nyl-0.2.0}/src/nyl/generator/statefulsecret.py +0 -0
- {nyl-0.1.0 → nyl-0.2.0}/src/nyl/profiles/__init__.py +0 -0
- {nyl-0.1.0 → nyl-0.2.0}/src/nyl/profiles/config.py +0 -0
- {nyl-0.1.0 → nyl-0.2.0}/src/nyl/profiles/config_test.py +0 -0
- {nyl-0.1.0 → nyl-0.2.0}/src/nyl/profiles/tunnel.py +0 -0
- {nyl-0.1.0 → nyl-0.2.0}/src/nyl/resources/__init__.py +0 -0
- {nyl-0.1.0 → nyl-0.2.0}/src/nyl/resources/applyset.py +0 -0
- {nyl-0.1.0 → nyl-0.2.0}/src/nyl/resources/applyset_test.py +0 -0
- {nyl-0.1.0 → nyl-0.2.0}/src/nyl/resources/helmchart.py +0 -0
- {nyl-0.1.0 → nyl-0.2.0}/src/nyl/resources/statefulsecret.py +0 -0
- {nyl-0.1.0 → nyl-0.2.0}/src/nyl/secrets/__init__.py +0 -0
- {nyl-0.1.0 → nyl-0.2.0}/src/nyl/secrets/config.py +0 -0
- {nyl-0.1.0 → nyl-0.2.0}/src/nyl/secrets/config_test.py +0 -0
- {nyl-0.1.0 → nyl-0.2.0}/src/nyl/secrets/null.py +0 -0
- {nyl-0.1.0 → nyl-0.2.0}/src/nyl/secrets/sops_test.py +0 -0
- {nyl-0.1.0 → nyl-0.2.0}/src/nyl/tools/fs.py +0 -0
- {nyl-0.1.0 → nyl-0.2.0}/src/nyl/tools/fs_test.py +0 -0
- {nyl-0.1.0 → nyl-0.2.0}/src/nyl/tools/kubectl.py +0 -0
- {nyl-0.1.0 → nyl-0.2.0}/src/nyl/tools/kubernetes.py +0 -0
- {nyl-0.1.0 → nyl-0.2.0}/src/nyl/tools/kvstore.py +0 -0
- {nyl-0.1.0 → nyl-0.2.0}/src/nyl/tools/loads.py +0 -0
- {nyl-0.1.0 → nyl-0.2.0}/src/nyl/tools/logging.py +0 -0
- {nyl-0.1.0 → nyl-0.2.0}/src/nyl/tools/shell.py +0 -0
- {nyl-0.1.0 → nyl-0.2.0}/src/nyl/tools/testing.py +0 -0
- {nyl-0.1.0 → nyl-0.2.0}/src/nyl/tools/typer.py +0 -0
- {nyl-0.1.0 → nyl-0.2.0}/src/nyl/tools/types.py +0 -0
- {nyl-0.1.0 → nyl-0.2.0}/tests/__init__.py +0 -0
- {nyl-0.1.0 → nyl-0.2.0}/tests/conftest.py +0 -0
- {nyl-0.1.0 → nyl-0.2.0}/uv.lock +0 -0
{nyl-0.1.0 → nyl-0.2.0}/PKG-INFO
RENAMED
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "nyl"
|
|
7
|
-
version = "0.
|
|
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
|
|
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(
|
|
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 =
|
|
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:
|
|
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
|
|
221
|
+
if not current_default_namespace:
|
|
188
222
|
logger.opt(ansi=True).error(
|
|
189
|
-
"
|
|
190
|
-
"
|
|
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
|
-
"
|
|
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
|
|
279
|
+
if not current_default_namespace:
|
|
249
280
|
logger.error(
|
|
250
|
-
"
|
|
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
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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
|
-
|
|
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="
|
|
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
|
-
|
|
103
|
-
|
|
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],
|
|
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.
|
|
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(
|
|
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
|