quoll-cli 0.2.0__py3-none-any.whl

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 (191) hide show
  1. quoll/__init__.py +0 -0
  2. quoll/cd/__init__.py +1 -0
  3. quoll/cd/config.py +75 -0
  4. quoll/ci/__init__.py +0 -0
  5. quoll/ci/config.py +213 -0
  6. quoll/ci/custom.py +32 -0
  7. quoll/ci/detect.py +139 -0
  8. quoll/ci/steps.py +85 -0
  9. quoll/ci/tools.py +104 -0
  10. quoll/commands/__init__.py +0 -0
  11. quoll/commands/_common.py +145 -0
  12. quoll/commands/_workspace.py +83 -0
  13. quoll/commands/add.py +104 -0
  14. quoll/commands/add_secret.py +75 -0
  15. quoll/commands/cd.py +214 -0
  16. quoll/commands/ci.py +521 -0
  17. quoll/commands/docker.py +244 -0
  18. quoll/commands/k8s.py +211 -0
  19. quoll/commands/new.py +337 -0
  20. quoll/commands/remove.py +79 -0
  21. quoll/config/__init__.py +0 -0
  22. quoll/config/quoll_toml.py +196 -0
  23. quoll/config/settings.py +22 -0
  24. quoll/config/store.py +39 -0
  25. quoll/console.py +3 -0
  26. quoll/docker/__init__.py +1 -0
  27. quoll/docker/config.py +64 -0
  28. quoll/docker/detect.py +41 -0
  29. quoll/docker/render.py +162 -0
  30. quoll/files.py +64 -0
  31. quoll/k8s/__init__.py +0 -0
  32. quoll/k8s/chart/templates/_helpers.tpl +38 -0
  33. quoll/k8s/chart/templates/deployment.yaml +93 -0
  34. quoll/k8s/chart/templates/hpa.yaml +24 -0
  35. quoll/k8s/chart/templates/ingress.yaml +35 -0
  36. quoll/k8s/chart/templates/pdb.yaml +16 -0
  37. quoll/k8s/chart/templates/service.yaml +18 -0
  38. quoll/k8s/chart/templates/serviceaccount.yaml +10 -0
  39. quoll/k8s/ci.py +39 -0
  40. quoll/k8s/config.py +97 -0
  41. quoll/k8s/deploy.py +22 -0
  42. quoll/k8s/detect.py +32 -0
  43. quoll/k8s/render.py +94 -0
  44. quoll/languages/__init__.py +121 -0
  45. quoll/languages/base.py +350 -0
  46. quoll/languages/cpp/Dockerfile.j2 +37 -0
  47. quoll/languages/cpp/__init__.py +17 -0
  48. quoll/languages/cpp/ci.py +192 -0
  49. quoll/languages/cpp/detect.py +84 -0
  50. quoll/languages/cpp/docker.py +47 -0
  51. quoll/languages/dotnet/Dockerfile.j2 +21 -0
  52. quoll/languages/dotnet/__init__.py +17 -0
  53. quoll/languages/dotnet/ci.py +88 -0
  54. quoll/languages/dotnet/detect.py +53 -0
  55. quoll/languages/dotnet/docker.py +47 -0
  56. quoll/languages/go/Dockerfile.j2 +25 -0
  57. quoll/languages/go/__init__.py +17 -0
  58. quoll/languages/go/ci.py +126 -0
  59. quoll/languages/go/detect.py +30 -0
  60. quoll/languages/go/docker.py +40 -0
  61. quoll/languages/java/Dockerfile.j2 +28 -0
  62. quoll/languages/java/__init__.py +20 -0
  63. quoll/languages/java/ci.py +163 -0
  64. quoll/languages/java/detect.py +77 -0
  65. quoll/languages/java/docker.py +45 -0
  66. quoll/languages/java/frameworks/__init__.py +1 -0
  67. quoll/languages/java/frameworks/spring_boot/Dockerfile.j2 +18 -0
  68. quoll/languages/java/frameworks/spring_boot/__init__.py +10 -0
  69. quoll/languages/java/frameworks/spring_boot/detect.py +9 -0
  70. quoll/languages/java/frameworks/spring_boot/docker.py +19 -0
  71. quoll/languages/java/frameworks/spring_boot/services.py +24 -0
  72. quoll/languages/node/Dockerfile.j2 +98 -0
  73. quoll/languages/node/__init__.py +29 -0
  74. quoll/languages/node/ci.py +200 -0
  75. quoll/languages/node/detect.py +164 -0
  76. quoll/languages/node/docker.py +141 -0
  77. quoll/languages/node/frameworks/__init__.py +1 -0
  78. quoll/languages/node/frameworks/angular/__init__.py +18 -0
  79. quoll/languages/node/frameworks/angular/ci.py +34 -0
  80. quoll/languages/node/frameworks/angular/detect.py +20 -0
  81. quoll/languages/node/frameworks/angular/docker.py +31 -0
  82. quoll/languages/node/frameworks/angular/starter.py +74 -0
  83. quoll/languages/node/frameworks/astro/__init__.py +10 -0
  84. quoll/languages/node/frameworks/astro/ci.py +17 -0
  85. quoll/languages/node/frameworks/astro/detect.py +14 -0
  86. quoll/languages/node/frameworks/astro/docker.py +17 -0
  87. quoll/languages/node/frameworks/nestjs/__init__.py +7 -0
  88. quoll/languages/node/frameworks/nestjs/detect.py +9 -0
  89. quoll/languages/node/frameworks/nestjs/docker.py +10 -0
  90. quoll/languages/node/frameworks/nextjs/Dockerfile.j2 +14 -0
  91. quoll/languages/node/frameworks/nextjs/__init__.py +8 -0
  92. quoll/languages/node/frameworks/nextjs/ci.py +29 -0
  93. quoll/languages/node/frameworks/nextjs/detect.py +13 -0
  94. quoll/languages/node/frameworks/nextjs/docker.py +41 -0
  95. quoll/languages/node/frameworks/nuxt/__init__.py +10 -0
  96. quoll/languages/node/frameworks/nuxt/ci.py +17 -0
  97. quoll/languages/node/frameworks/nuxt/detect.py +17 -0
  98. quoll/languages/node/frameworks/nuxt/docker.py +24 -0
  99. quoll/languages/node/frameworks/sveltekit/__init__.py +15 -0
  100. quoll/languages/node/frameworks/sveltekit/ci.py +15 -0
  101. quoll/languages/node/frameworks/sveltekit/detect.py +18 -0
  102. quoll/languages/node/frameworks/sveltekit/docker.py +18 -0
  103. quoll/languages/node/starter.py +42 -0
  104. quoll/languages/node/templates/express/README.md +11 -0
  105. quoll/languages/node/templates/express/env.example +2 -0
  106. quoll/languages/node/templates/express/eslint.config.js +15 -0
  107. quoll/languages/node/templates/express/gitignore +4 -0
  108. quoll/languages/node/templates/express/nvmrc +1 -0
  109. quoll/languages/node/templates/express/package.json +16 -0
  110. quoll/languages/node/templates/express/prettierignore +6 -0
  111. quoll/languages/node/templates/express/src/app.ts +27 -0
  112. quoll/languages/node/templates/express/src/server.ts +11 -0
  113. quoll/languages/node/templates/express/test/app.test.ts +29 -0
  114. quoll/languages/node/templates/express/tsconfig.build.json +8 -0
  115. quoll/languages/node/templates/express/tsconfig.json +14 -0
  116. quoll/languages/node/templates/express/vitest.config.ts +12 -0
  117. quoll/languages/php/Dockerfile.j2 +40 -0
  118. quoll/languages/php/__init__.py +19 -0
  119. quoll/languages/php/ci.py +147 -0
  120. quoll/languages/php/detect.py +75 -0
  121. quoll/languages/php/docker.py +59 -0
  122. quoll/languages/php/frameworks/__init__.py +1 -0
  123. quoll/languages/php/frameworks/laravel/__init__.py +17 -0
  124. quoll/languages/php/frameworks/laravel/ci.py +24 -0
  125. quoll/languages/php/frameworks/laravel/detect.py +13 -0
  126. quoll/languages/php/frameworks/laravel/docker.py +29 -0
  127. quoll/languages/php/frameworks/laravel/services.py +25 -0
  128. quoll/languages/php/frameworks/symfony/__init__.py +15 -0
  129. quoll/languages/php/frameworks/symfony/ci.py +22 -0
  130. quoll/languages/php/frameworks/symfony/detect.py +17 -0
  131. quoll/languages/php/frameworks/symfony/docker.py +25 -0
  132. quoll/languages/python/Dockerfile.j2 +61 -0
  133. quoll/languages/python/__init__.py +19 -0
  134. quoll/languages/python/ci.py +157 -0
  135. quoll/languages/python/detect.py +175 -0
  136. quoll/languages/python/docker.py +80 -0
  137. quoll/languages/python/frameworks/__init__.py +1 -0
  138. quoll/languages/python/frameworks/django/__init__.py +16 -0
  139. quoll/languages/python/frameworks/django/ci.py +43 -0
  140. quoll/languages/python/frameworks/django/detect.py +17 -0
  141. quoll/languages/python/frameworks/django/docker.py +20 -0
  142. quoll/languages/python/frameworks/fastapi/__init__.py +7 -0
  143. quoll/languages/python/frameworks/fastapi/detect.py +9 -0
  144. quoll/languages/python/frameworks/fastapi/docker.py +21 -0
  145. quoll/languages/python/frameworks/flask/__init__.py +7 -0
  146. quoll/languages/python/frameworks/flask/detect.py +9 -0
  147. quoll/languages/python/frameworks/flask/docker.py +22 -0
  148. quoll/languages/ruby/Dockerfile.j2 +45 -0
  149. quoll/languages/ruby/__init__.py +19 -0
  150. quoll/languages/ruby/ci.py +110 -0
  151. quoll/languages/ruby/detect.py +72 -0
  152. quoll/languages/ruby/docker.py +63 -0
  153. quoll/languages/ruby/frameworks/__init__.py +1 -0
  154. quoll/languages/ruby/frameworks/rails/Dockerfile.j2 +16 -0
  155. quoll/languages/ruby/frameworks/rails/__init__.py +18 -0
  156. quoll/languages/ruby/frameworks/rails/ci.py +26 -0
  157. quoll/languages/ruby/frameworks/rails/detect.py +15 -0
  158. quoll/languages/ruby/frameworks/rails/docker.py +62 -0
  159. quoll/languages/ruby/frameworks/rails/services.py +10 -0
  160. quoll/languages/rust/Dockerfile.j2 +23 -0
  161. quoll/languages/rust/__init__.py +17 -0
  162. quoll/languages/rust/ci.py +125 -0
  163. quoll/languages/rust/detect.py +46 -0
  164. quoll/languages/rust/docker.py +38 -0
  165. quoll/main.py +50 -0
  166. quoll/platforms/__init__.py +62 -0
  167. quoll/platforms/base.py +103 -0
  168. quoll/platforms/github/__init__.py +4 -0
  169. quoll/platforms/github/actions.py +78 -0
  170. quoll/platforms/github/api.py +124 -0
  171. quoll/platforms/github/cd.py +265 -0
  172. quoll/platforms/github/ci.py +497 -0
  173. quoll/platforms/github/dependabot.yml.j2 +49 -0
  174. quoll/platforms/github/platform.py +52 -0
  175. quoll/platforms/github/workflow.py +74 -0
  176. quoll/platforms/gitlab/__init__.py +4 -0
  177. quoll/platforms/gitlab/api.py +158 -0
  178. quoll/platforms/gitlab/cd.py +226 -0
  179. quoll/platforms/gitlab/ci.py +400 -0
  180. quoll/platforms/gitlab/pipeline.py +136 -0
  181. quoll/platforms/gitlab/platform.py +54 -0
  182. quoll/project.py +60 -0
  183. quoll/services.py +117 -0
  184. quoll/starters.py +117 -0
  185. quoll/workspace.py +232 -0
  186. quoll/yaml_dump.py +137 -0
  187. quoll_cli-0.2.0.dist-info/METADATA +641 -0
  188. quoll_cli-0.2.0.dist-info/RECORD +191 -0
  189. quoll_cli-0.2.0.dist-info/WHEEL +4 -0
  190. quoll_cli-0.2.0.dist-info/entry_points.txt +2 -0
  191. quoll_cli-0.2.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,244 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import List, Optional
5
+
6
+ import questionary
7
+ import typer
8
+ from rich.table import Table
9
+
10
+ from quoll import languages
11
+ from quoll.ci.config import ConfigError as CIConfigError
12
+ from quoll.ci.config import load_config as load_ci_config
13
+ from quoll.ci.detect import Detection, detect
14
+ from quoll.commands._common import (
15
+ DirOption,
16
+ ask,
17
+ check_files,
18
+ confirm_overwrite,
19
+ resolve_dir,
20
+ supported_hint,
21
+ update_files,
22
+ )
23
+ from quoll.config import quoll_toml
24
+ from quoll.console import console
25
+ from quoll.docker import render
26
+ from quoll.docker.config import CONFIG_FILE, ConfigError, DockerConfig, load_config, save_config
27
+ from quoll.docker.detect import default_config, image_defaults
28
+ from quoll.files import write
29
+ from quoll.languages.base import DockerDefaults
30
+ from quoll.workspace import find_root, load_workspace, member_name
31
+
32
+ app = typer.Typer(help="Generate and maintain the Dockerfile of a project.", no_args_is_help=True)
33
+
34
+
35
+ @app.command()
36
+ def init(
37
+ project_dir: Path = DirOption,
38
+ yes: bool = typer.Option(False, "--yes", "-y", help="Accept detected defaults without asking."),
39
+ force: bool = typer.Option(False, "--force", help="Overwrite a Dockerfile that was not generated by quoll."),
40
+ command: str = typer.Option("", "--command", help="What the container runs, when quoll can't infer it."),
41
+ ) -> None:
42
+ """Detect the project, write quoll.toml and generate a Dockerfile, .dockerignore and compose.yaml.
43
+
44
+ At the root of a workspace: for each of its projects."""
45
+ project_dir = resolve_dir(project_dir)
46
+ members = _members(project_dir)
47
+ if members is None:
48
+ _init(project_dir, yes=yes, force=force, command=command)
49
+ return
50
+ for member in members:
51
+ if force or "docker" not in _read(project_dir / member):
52
+ _init(project_dir / member, yes=yes, force=force, command=command, label=member)
53
+ files = _workspace_files(project_dir)
54
+ if files and confirm_overwrite(files, yes=yes, force=force):
55
+ write(project_dir, files)
56
+ console.print(f"\n[green]✓[/green] {files[0].path} (the whole workspace)")
57
+ console.print("\n[bold green]Done![/bold green] Run every project and its services:")
58
+ console.print(" docker compose up --build")
59
+
60
+
61
+ def _init(project_dir: Path, *, yes: bool, force: bool, command: str, label: str = "") -> None:
62
+ console.print(f"\n[bold]quoll docker[/bold] — {label or project_dir.name}\n")
63
+
64
+ if "docker" in _read(project_dir) and not force:
65
+ if yes or not ask(questionary.confirm(
66
+ f"{CONFIG_FILE} already configures Docker. Detect again and replace it?", default=False
67
+ )):
68
+ console.print("Nothing changed. Use [bold]quoll docker update[/bold] to regenerate from quoll.toml.")
69
+ raise typer.Exit(0)
70
+
71
+ det = detect(project_dir)
72
+ if det.stack is None:
73
+ console.print(f"[red]Error:[/red] no supported project found.\n{supported_hint()}")
74
+ raise typer.Exit(1)
75
+ try:
76
+ defaults = image_defaults(project_dir, det)
77
+ except ValueError as e:
78
+ console.print(f"[red]Error:[/red] {e}")
79
+ raise typer.Exit(1)
80
+ if command:
81
+ defaults.command = command
82
+ _print_defaults(det, defaults)
83
+
84
+ if not yes:
85
+ _customize(defaults, det)
86
+ elif _needs_command(det, defaults) and not defaults.command:
87
+ console.print("[red]Error:[/red] quoll couldn't infer what the container runs. Pass [bold]--command[/bold].")
88
+ raise typer.Exit(1)
89
+
90
+ cfg = default_config(det, defaults)[0]
91
+ files = render.plan(project_dir, cfg)
92
+ if not confirm_overwrite(files, yes=yes, force=force):
93
+ raise typer.Exit(1)
94
+
95
+ save_config(project_dir, cfg)
96
+ write(project_dir, files)
97
+ console.print()
98
+ console.print(f"[green]✓[/green] {CONFIG_FILE}")
99
+ for f in files:
100
+ console.print(f"[green]✓[/green] {f.path}")
101
+
102
+ console.print("\n[bold green]Done![/bold green] Run the app" + (" and its services:" if cfg.services else ":"))
103
+ console.print(" docker compose up --build")
104
+ _suggest_ci_build(project_dir)
105
+
106
+
107
+ @app.command()
108
+ def update(
109
+ project_dir: Path = DirOption,
110
+ yes: bool = typer.Option(False, "--yes", "-y", help="Write changes without asking."),
111
+ force: bool = typer.Option(False, "--force", help="Overwrite a Dockerfile that was not generated by quoll."),
112
+ ) -> None:
113
+ """Regenerate the Dockerfile, .dockerignore and compose.yaml from quoll.toml (each project's, in a
114
+ workspace)."""
115
+ project_dir = resolve_dir(project_dir)
116
+ for directory in _targets(project_dir):
117
+ files = render.plan(directory, _load(directory))
118
+ update_files(directory, files, yes=yes, force=force, what=f"Docker files of {directory.name} are")
119
+ root = find_root(project_dir)
120
+ files = _workspace_files(root)
121
+ if files:
122
+ update_files(root, files, yes=yes, force=force, what="The workspace's compose.yaml is")
123
+
124
+
125
+ @app.command()
126
+ def check(project_dir: Path = DirOption) -> None:
127
+ """Fail if the Docker files differ from what quoll.toml describes (each project's, in a workspace)."""
128
+ project_dir = resolve_dir(project_dir)
129
+ for directory in _targets(project_dir):
130
+ check_files(render.plan(directory, _load(directory)), "docker")
131
+ root = find_root(project_dir)
132
+ files = _workspace_files(root)
133
+ if files:
134
+ check_files(files, "docker")
135
+
136
+
137
+ def _workspace_files(root: Path) -> list:
138
+ """The compose.yaml running a whole workspace (its projects with a [docker] section), if `root` is one."""
139
+ members = _members(root)
140
+ if not members:
141
+ return []
142
+ configs = [(member_name(m), m, _load(root / m)) for m in members if "docker" in _read(root / m)]
143
+ return render.plan_workspace(root, configs) if configs else []
144
+
145
+
146
+ def _members(project_dir: Path) -> Optional[List[str]]:
147
+ """The projects of the workspace whose root is `project_dir`, or None."""
148
+ try:
149
+ workspace = load_workspace(project_dir)
150
+ except ConfigError as e:
151
+ console.print(f"[red]Error:[/red] {e}")
152
+ raise typer.Exit(1)
153
+ return workspace.members if workspace else None
154
+
155
+
156
+ def _targets(project_dir: Path) -> List[Path]:
157
+ """`project_dir`, or the workspace projects with a [docker] section when it is a workspace root."""
158
+ members = _members(project_dir)
159
+ if members is None:
160
+ return [project_dir]
161
+ targets = [project_dir / m for m in members if "docker" in _read(project_dir / m)]
162
+ if not targets:
163
+ console.print("[red]Error:[/red] no project of the workspace has a [docker] section. Run "
164
+ "[bold]quoll docker init[/bold] first.")
165
+ raise typer.Exit(1)
166
+ return targets
167
+
168
+
169
+ # --------------------------------------------------------------------------- interactive
170
+
171
+
172
+ def _customize(defaults: DockerDefaults, det: Detection) -> None:
173
+ port = ask(questionary.text(
174
+ "Port the app listens on",
175
+ default=str(defaults.port),
176
+ validate=lambda v: v.isdigit() and 0 < int(v) < 65536 or "Enter a port number",
177
+ ))
178
+ defaults.port = int(port)
179
+ if _needs_command(det, defaults):
180
+ defaults.command = ask(questionary.text(
181
+ "Command the container runs",
182
+ default=defaults.command,
183
+ validate=lambda v: bool(v.strip()) or "The container needs a command",
184
+ )).strip()
185
+
186
+
187
+ def _needs_command(det: Detection, defaults: DockerDefaults) -> bool:
188
+ if defaults.static:
189
+ return False
190
+ image = languages.docker_image(det.stack, det.framework)
191
+ return image is not None and image.requires_command
192
+
193
+
194
+ # --------------------------------------------------------------------------- output
195
+
196
+
197
+ def _print_defaults(det: Detection, defaults: DockerDefaults) -> None:
198
+ table = Table(show_header=False, box=None, padding=(0, 2))
199
+ table.add_column(style="dim")
200
+ table.add_column(style="bold")
201
+ table.add_row("Stack", det.stack + (f" / {det.framework}" if det.framework else ""))
202
+ table.add_row("Version", det.version)
203
+ table.add_row("Build", defaults.build_command or "-")
204
+ if defaults.static:
205
+ table.add_row("Serves", f"{defaults.output_dir}/ (static, nginx)")
206
+ else:
207
+ table.add_row("Runs", defaults.command or "[yellow]?[/yellow]")
208
+ table.add_row("Port", str(defaults.port))
209
+ table.add_row("Services", ", ".join(det.services) or "-")
210
+ table.add_row("Existing Dockerfile", "yes" if det.has_dockerfile else "no")
211
+ console.print(table)
212
+ for warning in [*det.warnings, *defaults.warnings]:
213
+ console.print(f"[yellow]![/yellow] {warning}")
214
+ console.print()
215
+
216
+
217
+ def _suggest_ci_build(project_dir: Path) -> None:
218
+ try:
219
+ ci = load_ci_config(project_dir)
220
+ except CIConfigError:
221
+ return
222
+ if not ci.build.docker:
223
+ console.print(
224
+ "\n[dim]To build the image in CI: set [bold]build.docker = true[/bold] in [ci], "
225
+ "then run [bold]quoll ci update[/bold].[/dim]"
226
+ )
227
+
228
+
229
+ # --------------------------------------------------------------------------- helpers
230
+
231
+
232
+ def _read(project_dir: Path) -> dict:
233
+ try:
234
+ return quoll_toml.read(project_dir)
235
+ except ConfigError:
236
+ return {}
237
+
238
+
239
+ def _load(project_dir: Path) -> DockerConfig:
240
+ try:
241
+ return load_config(project_dir)
242
+ except ConfigError as e:
243
+ console.print(f"[red]Error:[/red] {e}")
244
+ raise typer.Exit(1)
quoll/commands/k8s.py ADDED
@@ -0,0 +1,211 @@
1
+ """`quoll k8s`: a Helm chart per project, deployed by the delivery pipeline to each environment."""
2
+ from __future__ import annotations
3
+
4
+ from pathlib import Path
5
+ from typing import Dict, List, Optional
6
+
7
+ import questionary
8
+ import typer
9
+
10
+ from quoll.cd.config import load_config as load_cd_config
11
+ from quoll.ci.config import load_config as load_ci_config
12
+ from quoll.ci.config import save_config as save_ci_config
13
+ from quoll.ci.detect import configured_platform
14
+ from quoll.commands._common import DirOption, ask, check_files, confirm_overwrite, resolve_dir, update_files
15
+ from quoll.commands._workspace import refresh, snapshot
16
+ from quoll.config import quoll_toml
17
+ from quoll.config.quoll_toml import ConfigError
18
+ from quoll.console import console
19
+ from quoll.docker.config import load_config as load_docker_config
20
+ from quoll.files import PlannedFile, write
21
+ from quoll.k8s import render
22
+ from quoll.k8s.config import (
23
+ Environment,
24
+ K8sConfig,
25
+ default_environments,
26
+ kubernetes_name,
27
+ load_config,
28
+ save_config,
29
+ )
30
+ from quoll.k8s.detect import health_path
31
+ from quoll.workspace import find_root, load_workspace, member_name
32
+
33
+ app = typer.Typer(help="Generate and maintain the Helm chart deploying a project to Kubernetes.", no_args_is_help=True)
34
+
35
+
36
+ @app.command()
37
+ def init(
38
+ project_dir: Path = DirOption,
39
+ yes: bool = typer.Option(False, "--yes", "-y", help="Accept detected defaults without asking."),
40
+ force: bool = typer.Option(False, "--force", help="Overwrite a chart that was not generated by quoll."),
41
+ ) -> None:
42
+ """Write [k8s] in quoll.toml and generate the project's Helm chart (each project's, in a workspace)."""
43
+ project_dir = resolve_dir(project_dir)
44
+ root = find_root(project_dir)
45
+ targets = _targets(project_dir, configured=False)
46
+ platform = configured_platform(root)
47
+ # Without a pipeline yet, nothing else to update.
48
+ before = snapshot(root, platform) if platform and "ci" in _read(root) else None
49
+ for directory in targets:
50
+ _init(root, directory, yes=yes, force=force)
51
+ # The other projects' URLs changed with the new ones.
52
+ for directory in _targets(root, configured=True):
53
+ if directory not in targets:
54
+ write(directory, [f for f in _plan(root, directory) if f.changed and f.managed])
55
+ if before is not None:
56
+ # The CI checks the charts; the delivery deploys them.
57
+ console.print()
58
+ refresh(root, platform, before, "add the chart to it yourself", members_changed=False)
59
+ console.print("\n[bold green]Done![/bold green] Install it on a cluster:")
60
+ first = targets[0]
61
+ cfg = load_config(first)
62
+ console.print(f" helm upgrade --install {cfg.name} {_relative(first, cfg.chart)} "
63
+ f"-f {_relative(first, cfg.chart)}/values-staging.yaml --set image.tag=<tag>")
64
+
65
+
66
+ def _init(root: Path, directory: Path, *, yes: bool, force: bool, like: Optional[K8sConfig] = None) -> None:
67
+ """[k8s] and the chart of the project in `directory`; `like`: another project of the workspace, whose
68
+ environments (namespaces, triggers) and Ingress settings it shares."""
69
+ label = directory.relative_to(root).as_posix() if directory != root else directory.name
70
+ console.print(f"\n[bold]quoll k8s[/bold] — {label}\n")
71
+ if "k8s" in _read(directory) and not force:
72
+ if yes or not ask(questionary.confirm("quoll.toml already has a chart. Replace its settings?", default=False)):
73
+ console.print("Nothing changed. Use [bold]quoll k8s update[/bold] to regenerate from quoll.toml.")
74
+ return
75
+
76
+ workspace = directory != root
77
+ name = member_name(label) if workspace else kubernetes_name(directory.name)
78
+ project = kubernetes_name(root.name)
79
+ cfg = K8sConfig(name=kubernetes_name(name), health_path=health_path(directory),
80
+ environments=default_environments(project))
81
+ if like is not None:
82
+ cfg.ingress_class, cfg.tls, cfg.cluster_issuer = like.ingress_class, like.tls, like.cluster_issuer
83
+ cfg.environments = {
84
+ env_name: Environment(namespace=env.namespace, deploy_on=env.deploy_on, replicas=env.replicas)
85
+ for env_name, env in like.environments.items()
86
+ }
87
+ if not yes:
88
+ cfg.health_path = ask(questionary.text(
89
+ "Health check path (empty: TCP checks on the port)", default=cfg.health_path
90
+ )).strip()
91
+ for env_name, environment in cfg.environments.items():
92
+ environment.host = ask(questionary.text(
93
+ f"Public host in {env_name} (empty: no Ingress)", default=environment.host
94
+ )).strip()
95
+
96
+ save_config(directory, cfg)
97
+ if "ci" in _read(directory) and "workspace" not in _read(directory):
98
+ ci = load_ci_config(directory)
99
+ ci.build.chart = cfg.chart
100
+ save_ci_config(directory, ci)
101
+ files = _plan(root, directory)
102
+ if not confirm_overwrite(files, yes=yes, force=force):
103
+ raise typer.Exit(1)
104
+ write(directory, files)
105
+ console.print("[green]✓[/green] quoll.toml")
106
+ console.print(f"[green]✓[/green] {cfg.chart}/ (Helm chart: values.yaml, "
107
+ + ", ".join(f"values-{e}.yaml" for e in cfg.environments) + ")")
108
+ docker = load_docker_config(directory)
109
+ if docker.services:
110
+ console.print(f"[yellow]![/yellow] {name} uses {', '.join(docker.services)}: put their URLs "
111
+ f"(DATABASE_URL...) in the Secret {cfg.secret_name} of each namespace.")
112
+
113
+
114
+ @app.command()
115
+ def update(
116
+ project_dir: Path = DirOption,
117
+ yes: bool = typer.Option(False, "--yes", "-y", help="Write changes without asking."),
118
+ force: bool = typer.Option(False, "--force", help="Overwrite a chart that was not generated by quoll."),
119
+ ) -> None:
120
+ """Regenerate the Helm chart from quoll.toml (each project's, in a workspace)."""
121
+ project_dir = resolve_dir(project_dir)
122
+ root = find_root(project_dir)
123
+ for directory in _targets(project_dir, configured=True):
124
+ update_files(directory, _plan(root, directory), yes=yes, force=force, what=f"The chart of {directory.name} is")
125
+
126
+
127
+ @app.command()
128
+ def check(project_dir: Path = DirOption) -> None:
129
+ """Fail if the Helm chart differs from what quoll.toml describes (each project's, in a workspace)."""
130
+ project_dir = resolve_dir(project_dir)
131
+ root = find_root(project_dir)
132
+ for directory in _targets(project_dir, configured=True):
133
+ check_files(_plan(root, directory), "k8s")
134
+
135
+
136
+ def chart_files(root: Path) -> List[PlannedFile]:
137
+ """The charts of `root` (or of its workspace's projects), paths from `root`."""
138
+ files = []
139
+ for directory in _targets(root, configured=True, required=False):
140
+ prefix = directory.relative_to(root)
141
+ for f in _plan(root, directory):
142
+ f.path = prefix / f.path
143
+ files.append(f)
144
+ return files
145
+
146
+
147
+ def _plan(root: Path, directory: Path) -> List[PlannedFile]:
148
+ try:
149
+ cfg = load_config(directory)
150
+ docker = load_docker_config(directory)
151
+ except ConfigError as e:
152
+ console.print(f"[red]Error:[/red] {e}")
153
+ raise typer.Exit(1)
154
+ image = load_cd_config(directory).image if "cd" in _read(directory) else ""
155
+ return render.plan(directory, cfg, docker, image, _peers(root, directory))
156
+
157
+
158
+ def _peers(root: Path, directory: Path) -> Dict[str, str]:
159
+ """<NAME>_URL of the workspace's other projects, at their Service: http://<name>:<port>, as in compose.yaml."""
160
+ workspace = _workspace(root)
161
+ if workspace is None:
162
+ return {}
163
+ peers = {}
164
+ for member in workspace:
165
+ other = root / member
166
+ if other == directory or "docker" not in _read(other):
167
+ continue
168
+ name = load_config(other).name if "k8s" in _read(other) else member_name(member)
169
+ peers[f"{member_name(member).upper().replace('-', '_')}_URL"] = f"http://{name}:{load_docker_config(other).port}"
170
+ return peers
171
+
172
+
173
+ def _targets(project_dir: Path, *, configured: bool, required: bool = True) -> List[Path]:
174
+ """`project_dir`, or the workspace's projects when it is a workspace root: with a [docker] section (init), or
175
+ with a [k8s] one."""
176
+ section = "k8s" if configured else "docker"
177
+ members = _workspace(project_dir)
178
+ candidates = [project_dir] if members is None else [project_dir / m for m in members]
179
+ targets = [d for d in candidates if section in _read(d)]
180
+ if not targets and required:
181
+ if configured:
182
+ console.print("[red]Error:[/red] no chart configured. Run [bold]quoll k8s init[/bold] first.")
183
+ else:
184
+ console.print("[red]Error:[/red] the chart deploys the project's image: run "
185
+ "[bold]quoll docker init[/bold] first.")
186
+ raise typer.Exit(1)
187
+ return targets
188
+
189
+
190
+ def _workspace(project_dir: Path) -> Optional[List[str]]:
191
+ try:
192
+ workspace = load_workspace(project_dir)
193
+ except ConfigError as e:
194
+ console.print(f"[red]Error:[/red] {e}")
195
+ raise typer.Exit(1)
196
+ return workspace.members if workspace else None
197
+
198
+
199
+ def _read(directory: Path) -> dict:
200
+ try:
201
+ return quoll_toml.read(directory)
202
+ except ConfigError:
203
+ return {}
204
+
205
+
206
+ def _relative(directory: Path, chart: str) -> str:
207
+ cwd = Path.cwd()
208
+ try:
209
+ return str((directory / chart).relative_to(cwd))
210
+ except ValueError:
211
+ return str(directory / chart)