nyl 0.0.2__tar.gz → 0.0.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. {nyl-0.0.2 → nyl-0.0.3}/PKG-INFO +9 -6
  2. {nyl-0.0.2 → nyl-0.0.3}/pyproject.toml +17 -11
  3. nyl-0.0.3/src/nyl/commands/__init__.py +43 -0
  4. nyl-0.0.3/src/nyl/commands/crds.py +14 -0
  5. nyl-0.0.3/src/nyl/commands/new.py +91 -0
  6. {nyl-0.0.2 → nyl-0.0.3}/src/nyl/commands/profile.py +2 -5
  7. {nyl-0.0.2 → nyl-0.0.3}/src/nyl/commands/secrets.py +2 -2
  8. nyl-0.0.3/src/nyl/commands/template.py +311 -0
  9. {nyl-0.0.2 → nyl-0.0.3}/src/nyl/commands/tun.py +7 -8
  10. nyl-0.0.3/src/nyl/generator/__init__.py +66 -0
  11. nyl-0.0.3/src/nyl/generator/dispatch.py +76 -0
  12. nyl-0.0.3/src/nyl/generator/dispatch_test.py +45 -0
  13. nyl-0.0.3/src/nyl/generator/helmchart.py +154 -0
  14. nyl-0.0.3/src/nyl/generator/statefulsecret.py +26 -0
  15. {nyl-0.0.2 → nyl-0.0.3}/src/nyl/profiles/__init__.py +26 -3
  16. {nyl-0.0.2 → nyl-0.0.3}/src/nyl/profiles/config.py +20 -40
  17. {nyl-0.0.2 → nyl-0.0.3}/src/nyl/profiles/kubeconfig.py +3 -1
  18. nyl-0.0.3/src/nyl/project/config.py +58 -0
  19. nyl-0.0.3/src/nyl/resources/__init__.py +118 -0
  20. nyl-0.0.3/src/nyl/resources/applyset.py +225 -0
  21. nyl-0.0.3/src/nyl/resources/applyset_test.py +39 -0
  22. {nyl-0.0.2 → nyl-0.0.3}/src/nyl/resources/helmchart.py +4 -10
  23. {nyl-0.0.2 → nyl-0.0.3}/src/nyl/resources/statefulsecret.py +2 -2
  24. {nyl-0.0.2 → nyl-0.0.3}/src/nyl/secrets/config.py +17 -22
  25. nyl-0.0.3/src/nyl/secrets/null.py +14 -0
  26. nyl-0.0.3/src/nyl/tools/fs.py +29 -0
  27. {nyl-0.0.2 → nyl-0.0.3}/src/nyl/tools/kubectl.py +19 -10
  28. nyl-0.0.3/src/nyl/tools/types.py +8 -0
  29. nyl-0.0.2/src/nyl/appmanager/__init__.py +0 -3
  30. nyl-0.0.2/src/nyl/appmanager/appmanager.py +0 -150
  31. nyl-0.0.2/src/nyl/appmanager/crd.py +0 -107
  32. nyl-0.0.2/src/nyl/commands/__init__.py +0 -19
  33. nyl-0.0.2/src/nyl/commands/template.py +0 -274
  34. nyl-0.0.2/src/nyl/packages/__init__.py +0 -3
  35. nyl-0.0.2/src/nyl/packages/metadata.py +0 -7
  36. nyl-0.0.2/src/nyl/packages/reference.py +0 -91
  37. nyl-0.0.2/src/nyl/packages/reference_test.py +0 -12
  38. nyl-0.0.2/src/nyl/packages/templating.py +0 -52
  39. nyl-0.0.2/src/nyl/resources/__init__.py +0 -62
  40. nyl-0.0.2/src/nyl/resources/package.py +0 -17
  41. {nyl-0.0.2 → nyl-0.0.3}/README.md +0 -0
  42. {nyl-0.0.2 → nyl-0.0.3}/src/nyl/__init__.py +0 -0
  43. {nyl-0.0.2 → nyl-0.0.3}/src/nyl/profiles/tunnel.py +0 -0
  44. {nyl-0.0.2 → nyl-0.0.3}/src/nyl/secrets/__init__.py +0 -0
  45. {nyl-0.0.2 → nyl-0.0.3}/src/nyl/secrets/sops.py +0 -0
  46. {nyl-0.0.2 → nyl-0.0.3}/src/nyl/secrets/sops_test.py +0 -0
  47. {nyl-0.0.2 → nyl-0.0.3}/src/nyl/tools/kvstore.py +0 -0
  48. {nyl-0.0.2 → nyl-0.0.3}/src/nyl/tools/typer.py +0 -0
  49. {nyl-0.0.2 → nyl-0.0.3}/tests/__init__.py +0 -0
@@ -1,20 +1,23 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: nyl
3
- Version: 0.0.2
3
+ Version: 0.0.3
4
4
  Summary: Default template for PDM package
5
5
  Author-Email: Niklas Rosenstein <rosensteinniklas@gmail.com>
6
6
  License: MIT
7
7
  Requires-Python: >=3.10
8
+ Requires-Dist: bcrypt>=4.2.0
8
9
  Requires-Dist: databind>=4.5.2
9
- Requires-Dist: pyyaml>=6.0.1
10
+ Requires-Dist: filelock>=3.15.4
10
11
  Requires-Dist: jinja2>=3.1.4
11
- Requires-Dist: loguru>=0.7.2
12
12
  Requires-Dist: kubernetes>=30.1.0
13
- Requires-Dist: typer>=0.12.3
14
- Requires-Dist: filelock>=3.15.4
15
- Requires-Dist: stablehash>=0.2.0
13
+ Requires-Dist: loguru>=0.7.2
16
14
  Requires-Dist: nr-stream>=1.1.5
15
+ Requires-Dist: pyyaml>=6.0.1
17
16
  Requires-Dist: requests>=2.32.3
17
+ Requires-Dist: stablehash>=0.2.0
18
+ Requires-Dist: structured-templates<0.1.0,>=0.0.5
19
+ Requires-Dist: typer>=0.12.3
20
+ Requires-Dist: typing-extensions>=4.12.2
18
21
  Description-Content-Type: text/markdown
19
22
 
20
23
  # nyl
@@ -1,21 +1,24 @@
1
1
  [project]
2
2
  name = "nyl"
3
- version = "0.0.2"
3
+ version = "0.0.3"
4
4
  description = "Default template for PDM package"
5
5
  authors = [
6
6
  { name = "Niklas Rosenstein", email = "rosensteinniklas@gmail.com" },
7
7
  ]
8
8
  dependencies = [
9
+ "bcrypt>=4.2.0",
9
10
  "databind>=4.5.2",
10
- "pyyaml>=6.0.1",
11
+ "filelock>=3.15.4",
11
12
  "jinja2>=3.1.4",
12
- "loguru>=0.7.2",
13
13
  "kubernetes>=30.1.0",
14
- "typer>=0.12.3",
15
- "filelock>=3.15.4",
16
- "stablehash>=0.2.0",
14
+ "loguru>=0.7.2",
17
15
  "nr-stream>=1.1.5",
16
+ "pyyaml>=6.0.1",
18
17
  "requests>=2.32.3",
18
+ "stablehash>=0.2.0",
19
+ "structured-templates>=0.0.5,<0.1.0",
20
+ "typer>=0.12.3",
21
+ "typing-extensions>=4.12.2",
19
22
  ]
20
23
  requires-python = ">=3.10"
21
24
  readme = "README.md"
@@ -35,16 +38,19 @@ build-backend = "pdm.backend"
35
38
  [tool.pdm]
36
39
  distribution = true
37
40
 
41
+ [tool.pdm.scripts]
42
+ lint = "bash -c 'ruff format && ruff check --fix && mypy src' && pytest"
43
+
38
44
  [tool.pdm.dev-dependencies]
39
45
  dev = [
40
- "ruff>=0.4.8",
41
- "mypy>=1.10.0",
42
- "types-pyyaml>=6.0.12.20240311",
43
- "mkdocs>=1.6.0",
46
+ "kubernetes-stubs>=22.6.0.post1",
44
47
  "mkdocs-material>=9.5.28",
48
+ "mkdocs>=1.6.0",
45
49
  "mkdocstrings[python]>=0.25.1",
50
+ "mypy>=1.10.0",
46
51
  "pytest>=8.2.2",
47
- "kubernetes-stubs>=22.6.0.post1",
52
+ "ruff>=0.4.8",
53
+ "types-pyyaml>=6.0.12.20240311",
48
54
  "types-requests>=2.32.0.20240712",
49
55
  ]
50
56
 
@@ -0,0 +1,43 @@
1
+ """
2
+ Nyl is a flexible configuration management tool for Kubernetes resources that can be used to generate and deploy
3
+ applications directly or integrate as an ArgoCD ConfigManagementPlugin.
4
+ """
5
+
6
+ from enum import Enum
7
+ import sys
8
+ from loguru import logger
9
+ from typer import Option
10
+ from nyl.tools.typer import new_typer
11
+
12
+
13
+ app = new_typer(help=__doc__)
14
+
15
+
16
+ from . import crds # noqa: F401,E402
17
+ from . import new # noqa: E402
18
+ from . import profile # noqa: E402
19
+ from . import secrets # noqa: E402
20
+ from . import template # noqa: F401,E402
21
+ from . import tun # noqa: E402
22
+
23
+ app.add_typer(new.app)
24
+ app.add_typer(profile.app)
25
+ app.add_typer(secrets.app)
26
+ app.add_typer(tun.app)
27
+
28
+
29
+ class LogLevel(str, Enum):
30
+ TRACE = "trace"
31
+ DEBUG = "debug"
32
+ INFO = "info"
33
+ WARNING = "warning"
34
+ ERROR = "error"
35
+ CRITICAL = "critical"
36
+
37
+
38
+ @app.callback()
39
+ def _callback(
40
+ log_level: LogLevel = Option(LogLevel.INFO, "--log-level", "-l", help="The log level to use."),
41
+ ) -> None:
42
+ logger.remove()
43
+ logger.add(sys.stderr, level=log_level.name)
@@ -0,0 +1,14 @@
1
+ import yaml
2
+
3
+ from nyl.resources.applyset import ApplySet
4
+ from . import app
5
+
6
+
7
+ @app.command()
8
+ def crds() -> None:
9
+ """
10
+ Print out the CRDs that need to be installed on a Kubernetes cluster before you can use all of Nyl's features.
11
+ """
12
+
13
+ print("---")
14
+ print(yaml.safe_dump(ApplySet.CRD))
@@ -0,0 +1,91 @@
1
+ """
2
+ Bootstrap files for a new Nyl project or Helm chart.
3
+ """
4
+
5
+ from pathlib import Path
6
+ from textwrap import dedent
7
+
8
+ from loguru import logger
9
+ from nyl.tools.typer import new_typer
10
+
11
+ app = new_typer(name="new", help=__doc__)
12
+
13
+
14
+ def _write_file_dedent(dir: Path, name: str, content: str) -> None:
15
+ path = dir.joinpath(name)
16
+ if path.exists():
17
+ logger.warning("File already exists: {}", path)
18
+ return
19
+ logger.info("Writing {}", path)
20
+ path.parent.mkdir(parents=True, exist_ok=True)
21
+ path.write_text(dedent(content).lstrip())
22
+
23
+
24
+ @app.command()
25
+ def chart(dir: Path) -> None:
26
+ """
27
+ Similar to `helm create`, but generates a much simpler template.
28
+ """
29
+
30
+ dir.mkdir(parents=True, exist_ok=True)
31
+
32
+ _write_file_dedent(
33
+ dir,
34
+ "Chart.yaml",
35
+ f"""
36
+ apiVersion: v2
37
+ name: {dir.name}
38
+ version: '0.0.0'
39
+ """,
40
+ )
41
+
42
+ _write_file_dedent(
43
+ dir,
44
+ "values.yaml",
45
+ """
46
+ image: my/image:tag
47
+ """,
48
+ )
49
+
50
+ _write_file_dedent(
51
+ dir,
52
+ "values.schema.json",
53
+ """
54
+ {
55
+ "$schema": "https://json-schema.org/draft/2020-12/output/schema",
56
+ "type": "object",
57
+ "required": [
58
+ "image"
59
+ ],
60
+ "properties": {
61
+ "image": {
62
+ "type": "string"
63
+ }
64
+ }
65
+ }
66
+ """,
67
+ )
68
+
69
+ _write_file_dedent(
70
+ dir,
71
+ "templates/deployment.yaml",
72
+ """
73
+ apiVersion: apps/v1
74
+ kind: Deployment
75
+ metadata:
76
+ name: {{ .Release.Name }}
77
+ spec:
78
+ replicas: 1
79
+ selector:
80
+ matchLabels:
81
+ app: {{ .Release.Name }}
82
+ template:
83
+ metadata:
84
+ labels:
85
+ app: {{ .Release.Name }}
86
+ spec:
87
+ containers:
88
+ - name: {{ .Release.Name }}
89
+ image: {{ .Values.image }}
90
+ """,
91
+ )
@@ -23,8 +23,5 @@ def activate(profile_name: str = Argument("default", envvar="NYL_PROFILE")) -> N
23
23
  with ProfileManager.load() as manager:
24
24
  profile = manager.activate_profile(profile_name)
25
25
 
26
- # For standard tooling, like kubectl.
27
- print(f"export KUBECONFIG={shlex.quote(str(profile.kubeconfig.absolute()))}")
28
-
29
- # Used by Terraform providers.
30
- print(f"export KUBE_CONFIG_PATH={shlex.quote(str(profile.kubeconfig.absolute()))}")
26
+ for key, value in profile.env.items():
27
+ print(f"export {key}={shlex.quote(value)}")
@@ -16,7 +16,7 @@ def list() -> None:
16
16
  List the keys for all secrets in the provider.
17
17
  """
18
18
 
19
- secrets = SecretsConfig.load(SecretsConfig.find_config_file())
19
+ secrets = SecretsConfig.load()
20
20
  for key in secrets.provider.keys():
21
21
  print(key)
22
22
 
@@ -27,5 +27,5 @@ def get(key: str, pretty: bool = False) -> None:
27
27
  Get the value of a secret as JSON.
28
28
  """
29
29
 
30
- secrets = SecretsConfig.load(SecretsConfig.find_config_file())
30
+ secrets = SecretsConfig.load()
31
31
  print(json.dumps(secrets.provider.get(key), indent=4 if pretty else None))
@@ -0,0 +1,311 @@
1
+ import atexit
2
+ from dataclasses import dataclass
3
+ from pathlib import Path
4
+ from typing import cast
5
+ from loguru import logger
6
+ from structured_templates import TemplateEngine
7
+ from typer import Argument, Option
8
+ import yaml
9
+ from nyl.generator import reconcile_generator
10
+ from nyl.generator.dispatch import DispatchingGenerator
11
+ from nyl.profiles import ProfileManager
12
+ from kubernetes.config.incluster_config import load_incluster_config
13
+ from kubernetes.config.kube_config import load_kube_config
14
+ from kubernetes.client.api_client import ApiClient
15
+ from nyl.project.config import ProjectConfig
16
+ from nyl.resources.applyset import APPLYSET_LABEL_PART_OF, ApplySet
17
+ from nyl.secrets.config import SecretsConfig
18
+ from nyl.tools.kubectl import Kubectl
19
+ from nyl.tools.types import Manifest, Manifests
20
+
21
+ from . import app
22
+
23
+
24
+ @dataclass
25
+ class ManifestsWithSource:
26
+ """
27
+ Represents a list of manifests loaded from a particular source file.
28
+ """
29
+
30
+ manifests: Manifests
31
+ file: Path
32
+
33
+
34
+ @app.command()
35
+ def template(
36
+ paths: list[Path] = Argument(..., help="The YAML file(s) to render. Can be a directory."),
37
+ profile: str = Option("default", envvar="NYL_PROFILE", help="The Nyl profile to use."),
38
+ in_cluster: bool = Option(
39
+ False, help="Use the in-cluster Kubernetes configuration. The --profile option is ignored."
40
+ ),
41
+ apply: bool = Option(
42
+ False,
43
+ help="Run `kubectl apply` on the rendered manifests, once for each source file. "
44
+ "Implies `--no-applyset-part-of`. When an ApplySet is defined in the source file, it will be applied "
45
+ "separately. Note that this option implies `kubectl --prune`.",
46
+ ),
47
+ applyset_part_of: bool = Option(
48
+ True,
49
+ help="Add the 'applyset.kubernetes.io/part-of' label to all resources belonging to an ApplySet (if declared). "
50
+ "This option must be disabled when passing the generated manifests to `kubectl apply --applyset=...`, as it "
51
+ "would otherwise cause an error due to the label being present on the input data.",
52
+ ),
53
+ ) -> None:
54
+ """
55
+ Render a package template into full Kubernetes resources.
56
+ """
57
+
58
+ if apply:
59
+ # When running with --apply, we must ensure that the --applyset-part-of option is disabled, as it would cause
60
+ # an error when passing the generated manifests to `kubectl apply --applyset=...`.
61
+ applyset_part_of = False
62
+
63
+ kubectl = Kubectl()
64
+ kubectl.env["KUBECTL_APPLYSET"] = "true"
65
+ atexit.register(kubectl.cleanup)
66
+
67
+ if in_cluster:
68
+ logger.info("Using in-cluster configuration.")
69
+ load_incluster_config()
70
+ else:
71
+ with ProfileManager.load() as profiles:
72
+ active = profiles.activate_profile(profile)
73
+ logger.info(f"Using profile '{profile}' with kubeconfig '{active.kubeconfig}'.")
74
+ load_kube_config(str(active.kubeconfig))
75
+
76
+ project = ProjectConfig.load()
77
+ if project.file:
78
+ state_dir = project.file.parent / ".nyl"
79
+ else:
80
+ state_dir = Path(".nyl")
81
+
82
+ secrets = SecretsConfig.load()
83
+
84
+ template_engine = TemplateEngine(
85
+ globals_={
86
+ "secrets": secrets.provider,
87
+ "random_password": _random_password,
88
+ "bcrypt": _bcrypt,
89
+ }
90
+ )
91
+
92
+ generator = DispatchingGenerator.default(
93
+ git_repo_cache_dir=state_dir / "repo-cache",
94
+ search_path=project.config.search_path,
95
+ working_dir=Path.cwd(),
96
+ client=ApiClient(),
97
+ )
98
+
99
+ for source in load_manifests(paths):
100
+ logger.info("Rendering manifests from {}", source.file)
101
+
102
+ source.manifests = cast(Manifests, template_engine.evaluate(source.manifests))
103
+ source.manifests = reconcile_generator(
104
+ generator,
105
+ source.manifests,
106
+ on_generated=lambda m: cast(Manifest, template_engine.evaluate(m)),
107
+ )
108
+
109
+ # Find the namespaces that are defined in the file. If we find any manifests without a namespace, we will
110
+ # inject that namespace name into them.
111
+ # Also find the applyset defined in the file.
112
+ namespaces: set[str] = set()
113
+ applyset: ApplySet | None = None
114
+ for manifest in list(source.manifests):
115
+ if is_namespace_resource(manifest):
116
+ namespaces.add(manifest["metadata"]["name"])
117
+ elif ApplySet.matches(manifest):
118
+ if applyset is not None:
119
+ logger.error(
120
+ "Multiple ApplySet resources defined in '{}', there can only be one per source.",
121
+ source.file,
122
+ )
123
+ exit(1)
124
+ applyset = ApplySet.load(manifest)
125
+ source.manifests.remove(manifest)
126
+
127
+ if applyset is not None:
128
+ applyset.set_group_kinds(source.manifests)
129
+ # HACK: Kubectl 1.30 can't create the custom resource, so we need to create it. But it will also reject
130
+ # using the custom resource unless it has the tooling label set appropriately. For more details, see
131
+ # https://github.com/NiklasRosenstein/nyl/issues/5.
132
+ applyset.tooling = "kubectl/1.30"
133
+ applyset.validate()
134
+
135
+ if apply:
136
+ # We need to ensure that ApplySet parent object exists before invoking `kubectl apply --applyset=...`.
137
+ logger.info("Kubectl-apply ApplySet resource '{}' from '{}'", applyset.reference, source.file)
138
+ kubectl.apply(Manifests([applyset.dump()]), force_conflicts=True)
139
+ else:
140
+ print("---")
141
+ print(yaml.safe_dump(applyset.dump()))
142
+
143
+ # Find all manifests without a namespace and inject the namespace name into them.
144
+ # If there is an applyset, ensure they are marked as part of the applyset.
145
+ for manifest in source.manifests:
146
+ if applyset is not None and applyset_part_of:
147
+ if APPLYSET_LABEL_PART_OF not in (labels := manifest["metadata"].setdefault("labels", {})):
148
+ labels[APPLYSET_LABEL_PART_OF] = applyset.id
149
+
150
+ if not is_cluster_scoped_resource(manifest) and "namespace" not in manifest["metadata"]:
151
+ if len(namespaces) > 1:
152
+ logger.error(
153
+ "Multiple namespaces defined in '{}', but manifest {}/{} does not have a namespace.",
154
+ source.file,
155
+ manifest["kind"],
156
+ manifest["metadata"]["name"],
157
+ )
158
+ exit(1)
159
+ elif len(namespaces) == 0:
160
+ logger.warning(
161
+ "No namespaces defined in '{}', injecting 'default' into manifest {}/{}.",
162
+ source.file,
163
+ manifest["kind"],
164
+ manifest["metadata"]["name"],
165
+ )
166
+ manifest["metadata"]["namespace"] = "default"
167
+ else:
168
+ logger.trace(
169
+ "Injecting namespace '{}' into manifest {}/{}.",
170
+ next(iter(namespaces)),
171
+ manifest["kind"],
172
+ manifest["metadata"]["name"],
173
+ )
174
+ manifest["metadata"]["namespace"] = next(iter(namespaces))
175
+ elif not is_cluster_scoped_resource(manifest) and manifest["metadata"]["namespace"] not in namespaces:
176
+ logger.warning(
177
+ "Resource '{}/{}' in '{}' references namespace '{}', which is not defined in the file.",
178
+ manifest["kind"],
179
+ manifest["metadata"]["name"],
180
+ source.file,
181
+ manifest["metadata"]["namespace"],
182
+ )
183
+
184
+ if not apply:
185
+ print("---")
186
+ print(yaml.safe_dump(manifest))
187
+
188
+ if apply:
189
+ logger.info("Kubectl-apply {} manifest(s) from '{}'", len(source.manifests), source.file)
190
+ kubectl.apply(
191
+ manifests=source.manifests,
192
+ applyset=applyset.reference if applyset else None,
193
+ prune=True if applyset else False,
194
+ force_conflicts=True,
195
+ )
196
+
197
+
198
+ def load_manifests(paths: list[Path]) -> list[ManifestsWithSource]:
199
+ """
200
+ Load all manifests from a directory.
201
+ """
202
+
203
+ logger.trace("Loading manifests from paths: {}", paths)
204
+
205
+ files = []
206
+ for path in paths:
207
+ if path.is_dir():
208
+ for item in path.iterdir():
209
+ if item.name.startswith("nyl-") or item.suffix != ".yaml" or not item.is_file():
210
+ continue
211
+ files.append(item)
212
+ else:
213
+ files.append(path)
214
+
215
+ logger.trace("Files to load: {}", files)
216
+
217
+ result = []
218
+ for file in files:
219
+ manifests = Manifests(list(map(Manifest, yaml.safe_load_all(file.read_text()))))
220
+ result.append(ManifestsWithSource(manifests, file))
221
+
222
+ return result
223
+
224
+ # # Check if the resource has any references and try to resolve them. If a reference cannot be resolved, the
225
+ # # manifest must be skipped. We emit a warning and continue with the next manifest.
226
+ # refs = list(Reference.collect(manifest))
227
+ # if refs:
228
+ # skip_resource = False
229
+ # resolves = {}
230
+ # for ref in refs:
231
+ # # TODO: Determine the namespace to fall back to.
232
+ # try:
233
+ # result = k8s.get(
234
+ # resource=k8s.resources.get(api_version="v1", kind=ref.kind, group=""),
235
+ # name=ref.name,
236
+ # namespace=ref.namespace, # TODO: Determine the namespace to backfill to.
237
+ # )
238
+
239
+ # value = result["data"][ref.key]
240
+ # if value is None:
241
+ # raise KeyError
242
+ # assert isinstance(value, str)
243
+ # resolves[str(ref)] = base64.b64decode(value.encode("ascii")).decode("utf-8")
244
+ # except NotFoundError:
245
+ # logger.warning(
246
+ # "Skipping resource {}/{} because its reference to {} could not be resolved.",
247
+ # manifest["apiVersion"],
248
+ # manifest["kind"],
249
+ # ref,
250
+ # )
251
+ # skip_resource = True
252
+ # break
253
+ # except KeyError:
254
+ # logger.warning(
255
+ # "Skipping resource {}/{} because its reference to {} could not be resolved (does not contain key {}).",
256
+ # manifest["apiVersion"],
257
+ # manifest["kind"],
258
+ # ref,
259
+ # ref.key,
260
+ # )
261
+ # skip_resource = True
262
+ # break
263
+ # if skip_resource:
264
+ # continue
265
+ # manifest = Reference.sub(manifest, lambda ref: resolves[str(ref)])
266
+
267
+
268
+ def _random_password(length: int = 32) -> str:
269
+ """
270
+ Generate a random password.
271
+ """
272
+
273
+ import secrets
274
+
275
+ return secrets.token_urlsafe(length)
276
+
277
+
278
+ def _bcrypt(password: str) -> str:
279
+ """
280
+ Hash a password using bcrypt.
281
+ """
282
+
283
+ import bcrypt
284
+
285
+ return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
286
+
287
+
288
+ def is_namespace_resource(manifest: Manifest) -> bool:
289
+ """
290
+ Check if a manifest is a namespace resource.
291
+ """
292
+
293
+ return manifest.get("apiVersion") == "v1" and manifest.get("kind") == "Namespace"
294
+
295
+
296
+ def is_cluster_scoped_resource(manifest: Manifest) -> bool:
297
+ """
298
+ Check if a manifest is a cluster scoped resource.
299
+ """
300
+
301
+ # HACK: We should probably just list the resources via the Kubectl API?
302
+ fqn = manifest.get("kind", "") + "." + manifest.get("apiVersion", "").split("/")[0]
303
+ return fqn in {
304
+ "ClusterRole.rbac.authorization.k8s.io",
305
+ "ClusterRoleBinding.rbac.authorization.k8s.io",
306
+ "CustomResourceDefinition.apiextensions.k8s.io",
307
+ "IngressClass.networking.k8s.io",
308
+ "Namespace.v1",
309
+ "StorageClass.storage.k8s.io",
310
+ "ValidatingWebhookConfiguration.admissionregistration.k8s.io",
311
+ }
@@ -2,8 +2,6 @@
2
2
  Access SSH tunnels globally managed by Nyl.
3
3
  """
4
4
 
5
- from typing import Optional
6
-
7
5
  from loguru import logger
8
6
  from typer import Argument
9
7
  from rich.console import Console
@@ -24,7 +22,7 @@ def status(all: bool = False) -> None:
24
22
  Show the status of all tunnels.
25
23
  """
26
24
 
27
- config = ProfileConfig.load(ProfileConfig.find_config_file(not_found_ok=True))
25
+ config = ProfileConfig.load(required=False)
28
26
 
29
27
  table = Table()
30
28
  table.add_column("Profile", justify="right", style="cyan")
@@ -48,6 +46,7 @@ def status(all: bool = False) -> None:
48
46
  # on the system, while there may be tunnel information available for them from previous runs.
49
47
  for profile_name, profile in config.profiles.items():
50
48
  if profile_name not in tunnel_by_profile and profile.tunnel:
49
+ assert config.file is not None, "Profile configuration file must be set."
51
50
  tunnels.append((get_tunnel_spec(config.file, profile_name, profile.tunnel), TunnelStatus.empty()))
52
51
 
53
52
  tunnels.sort(key=lambda x: (x[0].locator.profile, x[0].locator.config_file))
@@ -76,12 +75,12 @@ def status(all: bool = False) -> None:
76
75
 
77
76
 
78
77
  @app.command()
79
- def start(profile_name: str) -> None:
78
+ def start(profile_name: str = Argument("default", envvar="NYL_PROFILE")) -> None:
80
79
  """
81
80
  Open a tunnel to the cluster targeted by the profile.
82
81
  """
83
82
 
84
- config = ProfileConfig.load(ProfileConfig.find_config_file())
83
+ config = ProfileConfig.load()
85
84
 
86
85
  try:
87
86
  profile = config.profiles[profile_name]
@@ -106,17 +105,17 @@ def start(profile_name: str) -> None:
106
105
 
107
106
 
108
107
  @app.command()
109
- def stop(profile_name: Optional[str] = Argument(None)) -> None:
108
+ def stop(profile_name: str = Argument("default", envvar="NYL_PROFILE"), all: bool = False) -> None:
110
109
  """
111
110
  Close all tunnels or the tunnel for a specific profile.
112
111
  """
113
112
 
114
- if profile_name is None:
113
+ if all:
115
114
  with TunnelManager() as manager:
116
115
  for spec, _status in manager.get_tunnels():
117
116
  manager.close_tunnel(spec.locator)
118
117
  return
119
118
 
120
- config = ProfileConfig.load(ProfileConfig.find_config_file())
119
+ config = ProfileConfig.load()
121
120
  with TunnelManager() as manager:
122
121
  manager.close_tunnel(TunnelSpec.Locator(str(config.file), profile_name))