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.
- quoll/__init__.py +0 -0
- quoll/cd/__init__.py +1 -0
- quoll/cd/config.py +75 -0
- quoll/ci/__init__.py +0 -0
- quoll/ci/config.py +213 -0
- quoll/ci/custom.py +32 -0
- quoll/ci/detect.py +139 -0
- quoll/ci/steps.py +85 -0
- quoll/ci/tools.py +104 -0
- quoll/commands/__init__.py +0 -0
- quoll/commands/_common.py +145 -0
- quoll/commands/_workspace.py +83 -0
- quoll/commands/add.py +104 -0
- quoll/commands/add_secret.py +75 -0
- quoll/commands/cd.py +214 -0
- quoll/commands/ci.py +521 -0
- quoll/commands/docker.py +244 -0
- quoll/commands/k8s.py +211 -0
- quoll/commands/new.py +337 -0
- quoll/commands/remove.py +79 -0
- quoll/config/__init__.py +0 -0
- quoll/config/quoll_toml.py +196 -0
- quoll/config/settings.py +22 -0
- quoll/config/store.py +39 -0
- quoll/console.py +3 -0
- quoll/docker/__init__.py +1 -0
- quoll/docker/config.py +64 -0
- quoll/docker/detect.py +41 -0
- quoll/docker/render.py +162 -0
- quoll/files.py +64 -0
- quoll/k8s/__init__.py +0 -0
- quoll/k8s/chart/templates/_helpers.tpl +38 -0
- quoll/k8s/chart/templates/deployment.yaml +93 -0
- quoll/k8s/chart/templates/hpa.yaml +24 -0
- quoll/k8s/chart/templates/ingress.yaml +35 -0
- quoll/k8s/chart/templates/pdb.yaml +16 -0
- quoll/k8s/chart/templates/service.yaml +18 -0
- quoll/k8s/chart/templates/serviceaccount.yaml +10 -0
- quoll/k8s/ci.py +39 -0
- quoll/k8s/config.py +97 -0
- quoll/k8s/deploy.py +22 -0
- quoll/k8s/detect.py +32 -0
- quoll/k8s/render.py +94 -0
- quoll/languages/__init__.py +121 -0
- quoll/languages/base.py +350 -0
- quoll/languages/cpp/Dockerfile.j2 +37 -0
- quoll/languages/cpp/__init__.py +17 -0
- quoll/languages/cpp/ci.py +192 -0
- quoll/languages/cpp/detect.py +84 -0
- quoll/languages/cpp/docker.py +47 -0
- quoll/languages/dotnet/Dockerfile.j2 +21 -0
- quoll/languages/dotnet/__init__.py +17 -0
- quoll/languages/dotnet/ci.py +88 -0
- quoll/languages/dotnet/detect.py +53 -0
- quoll/languages/dotnet/docker.py +47 -0
- quoll/languages/go/Dockerfile.j2 +25 -0
- quoll/languages/go/__init__.py +17 -0
- quoll/languages/go/ci.py +126 -0
- quoll/languages/go/detect.py +30 -0
- quoll/languages/go/docker.py +40 -0
- quoll/languages/java/Dockerfile.j2 +28 -0
- quoll/languages/java/__init__.py +20 -0
- quoll/languages/java/ci.py +163 -0
- quoll/languages/java/detect.py +77 -0
- quoll/languages/java/docker.py +45 -0
- quoll/languages/java/frameworks/__init__.py +1 -0
- quoll/languages/java/frameworks/spring_boot/Dockerfile.j2 +18 -0
- quoll/languages/java/frameworks/spring_boot/__init__.py +10 -0
- quoll/languages/java/frameworks/spring_boot/detect.py +9 -0
- quoll/languages/java/frameworks/spring_boot/docker.py +19 -0
- quoll/languages/java/frameworks/spring_boot/services.py +24 -0
- quoll/languages/node/Dockerfile.j2 +98 -0
- quoll/languages/node/__init__.py +29 -0
- quoll/languages/node/ci.py +200 -0
- quoll/languages/node/detect.py +164 -0
- quoll/languages/node/docker.py +141 -0
- quoll/languages/node/frameworks/__init__.py +1 -0
- quoll/languages/node/frameworks/angular/__init__.py +18 -0
- quoll/languages/node/frameworks/angular/ci.py +34 -0
- quoll/languages/node/frameworks/angular/detect.py +20 -0
- quoll/languages/node/frameworks/angular/docker.py +31 -0
- quoll/languages/node/frameworks/angular/starter.py +74 -0
- quoll/languages/node/frameworks/astro/__init__.py +10 -0
- quoll/languages/node/frameworks/astro/ci.py +17 -0
- quoll/languages/node/frameworks/astro/detect.py +14 -0
- quoll/languages/node/frameworks/astro/docker.py +17 -0
- quoll/languages/node/frameworks/nestjs/__init__.py +7 -0
- quoll/languages/node/frameworks/nestjs/detect.py +9 -0
- quoll/languages/node/frameworks/nestjs/docker.py +10 -0
- quoll/languages/node/frameworks/nextjs/Dockerfile.j2 +14 -0
- quoll/languages/node/frameworks/nextjs/__init__.py +8 -0
- quoll/languages/node/frameworks/nextjs/ci.py +29 -0
- quoll/languages/node/frameworks/nextjs/detect.py +13 -0
- quoll/languages/node/frameworks/nextjs/docker.py +41 -0
- quoll/languages/node/frameworks/nuxt/__init__.py +10 -0
- quoll/languages/node/frameworks/nuxt/ci.py +17 -0
- quoll/languages/node/frameworks/nuxt/detect.py +17 -0
- quoll/languages/node/frameworks/nuxt/docker.py +24 -0
- quoll/languages/node/frameworks/sveltekit/__init__.py +15 -0
- quoll/languages/node/frameworks/sveltekit/ci.py +15 -0
- quoll/languages/node/frameworks/sveltekit/detect.py +18 -0
- quoll/languages/node/frameworks/sveltekit/docker.py +18 -0
- quoll/languages/node/starter.py +42 -0
- quoll/languages/node/templates/express/README.md +11 -0
- quoll/languages/node/templates/express/env.example +2 -0
- quoll/languages/node/templates/express/eslint.config.js +15 -0
- quoll/languages/node/templates/express/gitignore +4 -0
- quoll/languages/node/templates/express/nvmrc +1 -0
- quoll/languages/node/templates/express/package.json +16 -0
- quoll/languages/node/templates/express/prettierignore +6 -0
- quoll/languages/node/templates/express/src/app.ts +27 -0
- quoll/languages/node/templates/express/src/server.ts +11 -0
- quoll/languages/node/templates/express/test/app.test.ts +29 -0
- quoll/languages/node/templates/express/tsconfig.build.json +8 -0
- quoll/languages/node/templates/express/tsconfig.json +14 -0
- quoll/languages/node/templates/express/vitest.config.ts +12 -0
- quoll/languages/php/Dockerfile.j2 +40 -0
- quoll/languages/php/__init__.py +19 -0
- quoll/languages/php/ci.py +147 -0
- quoll/languages/php/detect.py +75 -0
- quoll/languages/php/docker.py +59 -0
- quoll/languages/php/frameworks/__init__.py +1 -0
- quoll/languages/php/frameworks/laravel/__init__.py +17 -0
- quoll/languages/php/frameworks/laravel/ci.py +24 -0
- quoll/languages/php/frameworks/laravel/detect.py +13 -0
- quoll/languages/php/frameworks/laravel/docker.py +29 -0
- quoll/languages/php/frameworks/laravel/services.py +25 -0
- quoll/languages/php/frameworks/symfony/__init__.py +15 -0
- quoll/languages/php/frameworks/symfony/ci.py +22 -0
- quoll/languages/php/frameworks/symfony/detect.py +17 -0
- quoll/languages/php/frameworks/symfony/docker.py +25 -0
- quoll/languages/python/Dockerfile.j2 +61 -0
- quoll/languages/python/__init__.py +19 -0
- quoll/languages/python/ci.py +157 -0
- quoll/languages/python/detect.py +175 -0
- quoll/languages/python/docker.py +80 -0
- quoll/languages/python/frameworks/__init__.py +1 -0
- quoll/languages/python/frameworks/django/__init__.py +16 -0
- quoll/languages/python/frameworks/django/ci.py +43 -0
- quoll/languages/python/frameworks/django/detect.py +17 -0
- quoll/languages/python/frameworks/django/docker.py +20 -0
- quoll/languages/python/frameworks/fastapi/__init__.py +7 -0
- quoll/languages/python/frameworks/fastapi/detect.py +9 -0
- quoll/languages/python/frameworks/fastapi/docker.py +21 -0
- quoll/languages/python/frameworks/flask/__init__.py +7 -0
- quoll/languages/python/frameworks/flask/detect.py +9 -0
- quoll/languages/python/frameworks/flask/docker.py +22 -0
- quoll/languages/ruby/Dockerfile.j2 +45 -0
- quoll/languages/ruby/__init__.py +19 -0
- quoll/languages/ruby/ci.py +110 -0
- quoll/languages/ruby/detect.py +72 -0
- quoll/languages/ruby/docker.py +63 -0
- quoll/languages/ruby/frameworks/__init__.py +1 -0
- quoll/languages/ruby/frameworks/rails/Dockerfile.j2 +16 -0
- quoll/languages/ruby/frameworks/rails/__init__.py +18 -0
- quoll/languages/ruby/frameworks/rails/ci.py +26 -0
- quoll/languages/ruby/frameworks/rails/detect.py +15 -0
- quoll/languages/ruby/frameworks/rails/docker.py +62 -0
- quoll/languages/ruby/frameworks/rails/services.py +10 -0
- quoll/languages/rust/Dockerfile.j2 +23 -0
- quoll/languages/rust/__init__.py +17 -0
- quoll/languages/rust/ci.py +125 -0
- quoll/languages/rust/detect.py +46 -0
- quoll/languages/rust/docker.py +38 -0
- quoll/main.py +50 -0
- quoll/platforms/__init__.py +62 -0
- quoll/platforms/base.py +103 -0
- quoll/platforms/github/__init__.py +4 -0
- quoll/platforms/github/actions.py +78 -0
- quoll/platforms/github/api.py +124 -0
- quoll/platforms/github/cd.py +265 -0
- quoll/platforms/github/ci.py +497 -0
- quoll/platforms/github/dependabot.yml.j2 +49 -0
- quoll/platforms/github/platform.py +52 -0
- quoll/platforms/github/workflow.py +74 -0
- quoll/platforms/gitlab/__init__.py +4 -0
- quoll/platforms/gitlab/api.py +158 -0
- quoll/platforms/gitlab/cd.py +226 -0
- quoll/platforms/gitlab/ci.py +400 -0
- quoll/platforms/gitlab/pipeline.py +136 -0
- quoll/platforms/gitlab/platform.py +54 -0
- quoll/project.py +60 -0
- quoll/services.py +117 -0
- quoll/starters.py +117 -0
- quoll/workspace.py +232 -0
- quoll/yaml_dump.py +137 -0
- quoll_cli-0.2.0.dist-info/METADATA +641 -0
- quoll_cli-0.2.0.dist-info/RECORD +191 -0
- quoll_cli-0.2.0.dist-info/WHEEL +4 -0
- quoll_cli-0.2.0.dist-info/entry_points.txt +2 -0
- quoll_cli-0.2.0.dist-info/licenses/LICENSE +21 -0
quoll/commands/docker.py
ADDED
|
@@ -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)
|