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
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""Helpers shared by the commands (`quoll ci`, `quoll docker`, `quoll cd`...)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import shutil
|
|
5
|
+
import subprocess
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, List, Optional
|
|
8
|
+
|
|
9
|
+
import questionary
|
|
10
|
+
import typer
|
|
11
|
+
from rich.syntax import Syntax
|
|
12
|
+
|
|
13
|
+
from quoll import languages, platforms
|
|
14
|
+
from quoll.config.quoll_toml import CONFIG_FILE
|
|
15
|
+
from quoll.config.settings import settings
|
|
16
|
+
from quoll.config.store import load_stored_token, save_token
|
|
17
|
+
from quoll.console import console
|
|
18
|
+
from quoll.files import PlannedFile, write
|
|
19
|
+
|
|
20
|
+
DirOption = typer.Option(Path("."), "--dir", "-d", help="Project directory.")
|
|
21
|
+
|
|
22
|
+
# CLI of each platform that can hand over its token: `gh auth token`, `glab config get token`.
|
|
23
|
+
_CLI_TOKEN = {
|
|
24
|
+
"github": lambda host: ["gh", "auth", "token"],
|
|
25
|
+
"gitlab": lambda host: ["glab", "config", "get", "token", "--host", host or "gitlab.com"],
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def platform_option() -> Any:
|
|
30
|
+
return typer.Option(
|
|
31
|
+
None, "--platform", help=f"{' or '.join(platforms.NAMES)} (default: detected from the git remote)."
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def check_platform(name: Optional[str]) -> None:
|
|
36
|
+
if name is not None and name not in platforms.NAMES:
|
|
37
|
+
console.print(f"[red]Error:[/red] unknown platform '{name}' (expected one of: {', '.join(platforms.NAMES)}).")
|
|
38
|
+
raise typer.Exit(1)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def ensure_token(platform: str, host: Optional[str] = None) -> bool:
|
|
42
|
+
"""A token for `platform` in settings: from the environment, the platform's CLI (gh, glab: the session the user
|
|
43
|
+
keeps fresh), the one stored in ~/.config/quoll, or asked."""
|
|
44
|
+
attr = f"{platform}_token"
|
|
45
|
+
if getattr(settings, attr):
|
|
46
|
+
return True
|
|
47
|
+
token = _cli_token(platform, host) or load_stored_token(platform)
|
|
48
|
+
if not token:
|
|
49
|
+
label = platforms.get(platform).label
|
|
50
|
+
scope = "repo" if platform == "github" else "api"
|
|
51
|
+
token = ask(questionary.password(f"{label} token ({scope} scope)"))
|
|
52
|
+
if not token:
|
|
53
|
+
return False
|
|
54
|
+
save_token(token, platform)
|
|
55
|
+
console.print("[dim]Token saved to ~/.config/quoll/config.toml[/dim]")
|
|
56
|
+
setattr(settings, attr, token)
|
|
57
|
+
return True
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _cli_token(platform: str, host: Optional[str]) -> str:
|
|
61
|
+
command = _CLI_TOKEN[platform](host)
|
|
62
|
+
if not shutil.which(command[0]):
|
|
63
|
+
return ""
|
|
64
|
+
try:
|
|
65
|
+
result = subprocess.run(command, capture_output=True, text=True, timeout=5)
|
|
66
|
+
except (OSError, subprocess.SubprocessError):
|
|
67
|
+
return ""
|
|
68
|
+
return result.stdout.strip() if result.returncode == 0 else ""
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def resolve_dir(project_dir: Path) -> Path:
|
|
72
|
+
project_dir = project_dir.resolve()
|
|
73
|
+
if not project_dir.is_dir():
|
|
74
|
+
console.print(f"[red]Error:[/red] '{project_dir}' is not a directory.")
|
|
75
|
+
raise typer.Exit(1)
|
|
76
|
+
return project_dir
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def ask(question: Any) -> Any:
|
|
80
|
+
answer = question.ask()
|
|
81
|
+
if answer is None: # Ctrl-C
|
|
82
|
+
raise typer.Exit(130)
|
|
83
|
+
return answer
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def supported_hint() -> str:
|
|
87
|
+
lines = [f" {lang.name}: {', '.join(lang.markers)}" for lang in languages.available()]
|
|
88
|
+
return "Supported languages (detected from these files):\n" + "\n".join(lines)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def print_diff(f: PlannedFile) -> None:
|
|
92
|
+
diff = f.diff()
|
|
93
|
+
if diff:
|
|
94
|
+
console.print(Syntax(diff, "diff", theme="ansi_dark", background_color="default", word_wrap=True))
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def confirm_overwrite(files: List[PlannedFile], *, yes: bool, force: bool, already_shown: bool = False) -> bool:
|
|
98
|
+
"""Refuse to replace hand-written files unless --force or the user agrees."""
|
|
99
|
+
for f in files:
|
|
100
|
+
if f.existing is None or not f.changed:
|
|
101
|
+
continue
|
|
102
|
+
if not f.managed and not force:
|
|
103
|
+
console.print(f"\n[yellow]![/yellow] {f.path} exists and was not generated by quoll.")
|
|
104
|
+
if yes:
|
|
105
|
+
console.print(" Re-run with [bold]--force[/bold] to replace it.")
|
|
106
|
+
return False
|
|
107
|
+
if not already_shown:
|
|
108
|
+
print_diff(f)
|
|
109
|
+
if not ask(questionary.confirm(f"Replace {f.path}?", default=False)):
|
|
110
|
+
return False
|
|
111
|
+
return True
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def update_files(project_dir: Path, files: List[PlannedFile], *, yes: bool, force: bool, what: str) -> None:
|
|
115
|
+
"""Show the diff of the changed files, confirm, write them."""
|
|
116
|
+
changed = [f for f in files if f.changed]
|
|
117
|
+
if not changed:
|
|
118
|
+
console.print(f"[green]✓[/green] {what} up to date.")
|
|
119
|
+
return
|
|
120
|
+
for f in changed:
|
|
121
|
+
print_diff(f)
|
|
122
|
+
if not confirm_overwrite(changed, yes=yes, force=force, already_shown=True):
|
|
123
|
+
raise typer.Exit(1)
|
|
124
|
+
if not yes and not ask(questionary.confirm("Write these changes?", default=True)):
|
|
125
|
+
raise typer.Exit(1)
|
|
126
|
+
write(project_dir, changed)
|
|
127
|
+
for f in changed:
|
|
128
|
+
console.print(f"[green]✓[/green] {f.path} updated")
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def check_files(files: List[PlannedFile], command: str) -> None:
|
|
132
|
+
"""Exit 1 when a file is missing or differs from quoll.toml."""
|
|
133
|
+
stale = False
|
|
134
|
+
for f in files:
|
|
135
|
+
if f.existing is None:
|
|
136
|
+
console.print(f"[red]✗[/red] {f.path} is missing. Run [bold]quoll {command} update[/bold].")
|
|
137
|
+
stale = True
|
|
138
|
+
elif f.changed:
|
|
139
|
+
print_diff(f)
|
|
140
|
+
console.print(f"[red]✗[/red] {f.path} is out of date. Run [bold]quoll {command} update[/bold].")
|
|
141
|
+
stale = True
|
|
142
|
+
else:
|
|
143
|
+
console.print(f"[green]✓[/green] {f.path} matches {CONFIG_FILE}.")
|
|
144
|
+
if stale:
|
|
145
|
+
raise typer.Exit(1)
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Regenerating a workspace's shared files once its members changed (`quoll add service`, `quoll remove service`)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import List, NamedTuple, Optional, Set
|
|
6
|
+
|
|
7
|
+
from quoll import platforms
|
|
8
|
+
from quoll.commands import cd as cd_command
|
|
9
|
+
from quoll.commands import docker as docker_command
|
|
10
|
+
from quoll.config.quoll_toml import ConfigError
|
|
11
|
+
from quoll.console import console
|
|
12
|
+
from quoll.files import MARKER, PlannedFile, write
|
|
13
|
+
from quoll.platforms.github.ci import DEPENDABOT_PATH, render_dependabot
|
|
14
|
+
from quoll.workspace import load_deliveries, load_repo
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Snapshot(NamedTuple):
|
|
18
|
+
"""What quoll generated for the members before they changed."""
|
|
19
|
+
|
|
20
|
+
paths: Set[Path]
|
|
21
|
+
dependabot: Optional[str]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def generated(root: Path, platform: str) -> List[PlannedFile]:
|
|
25
|
+
"""Every file quoll generates from the members: the pipeline, the root compose.yaml, the deliveries, the charts
|
|
26
|
+
(which know the other members' URLs)."""
|
|
27
|
+
from quoll.commands.k8s import chart_files # it refreshes the workspace too
|
|
28
|
+
|
|
29
|
+
files = [*platforms.get(platform).ci_files(root, load_repo(root)), *docker_command._workspace_files(root)]
|
|
30
|
+
files += chart_files(root)
|
|
31
|
+
try:
|
|
32
|
+
files += cd_command._plan(root, load_deliveries(root))
|
|
33
|
+
except ConfigError: # no member publishes an image
|
|
34
|
+
pass
|
|
35
|
+
return files
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def snapshot(root: Path, platform: str) -> Snapshot:
|
|
39
|
+
return Snapshot({f.path for f in generated(root, platform)}, _dependabot(root, platform))
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def refresh(root: Path, platform: str, before: Snapshot, by_hand: str, members_changed: bool = True) -> None:
|
|
43
|
+
"""Write the files the change affects, delete the ones it made useless (a member's delivery...). Files written
|
|
44
|
+
by hand are left alone: `by_hand` says what to change in them. Dependabot's config only follows the members."""
|
|
45
|
+
files = generated(root, platform)
|
|
46
|
+
for f in files:
|
|
47
|
+
if f.changed and f.managed and not (f.create_only and f.existing is not None):
|
|
48
|
+
write(root, [f])
|
|
49
|
+
console.print(f"[green]✓[/green] {f.path}")
|
|
50
|
+
elif f.changed and not f.managed:
|
|
51
|
+
console.print(f"[yellow]![/yellow] {f.path} was written by hand: {by_hand}.")
|
|
52
|
+
for path in sorted(before.paths - {f.path for f in files}):
|
|
53
|
+
target = root / path
|
|
54
|
+
if target.exists() and MARKER in target.read_text():
|
|
55
|
+
target.unlink()
|
|
56
|
+
remove_empty_parents(root, target)
|
|
57
|
+
console.print(f"[green]✓[/green] {path} removed")
|
|
58
|
+
|
|
59
|
+
# Dependabot's config is the project's once created: updated only if nobody changed it since.
|
|
60
|
+
target = root / DEPENDABOT_PATH
|
|
61
|
+
if members_changed and before.dependabot is not None and target.exists():
|
|
62
|
+
current = _dependabot(root, platform)
|
|
63
|
+
if target.read_text() != before.dependabot:
|
|
64
|
+
console.print(f"[yellow]![/yellow] {DEPENDABOT_PATH} was edited: {by_hand}.")
|
|
65
|
+
elif current is not None and current != before.dependabot:
|
|
66
|
+
target.write_text(current)
|
|
67
|
+
console.print(f"[green]✓[/green] {DEPENDABOT_PATH}")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _dependabot(root: Path, platform: str) -> Optional[str]:
|
|
71
|
+
""".github/dependabot.yml as quoll writes it for the current members (GitHub only)."""
|
|
72
|
+
if platform != "github" or not (root / DEPENDABOT_PATH).exists():
|
|
73
|
+
return None
|
|
74
|
+
repo = load_repo(root)
|
|
75
|
+
docker = [m.directory for m in repo.members if (root / m.directory / "Dockerfile").exists()]
|
|
76
|
+
return render_dependabot(repo, docker)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def remove_empty_parents(root: Path, path: Path) -> None:
|
|
80
|
+
directory = path.parent
|
|
81
|
+
while directory != root and directory.is_dir() and not any(directory.iterdir()):
|
|
82
|
+
directory.rmdir()
|
|
83
|
+
directory = directory.parent
|
quoll/commands/add.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""`quoll add service`: a new project in an existing workspace, wired into its CI, compose.yaml and delivery."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
from rich.progress import Progress, SpinnerColumn, TextColumn
|
|
9
|
+
|
|
10
|
+
from quoll import platforms
|
|
11
|
+
from quoll.cd.config import load_config as load_cd_config
|
|
12
|
+
from quoll.ci.config import save_config
|
|
13
|
+
from quoll.ci.detect import configured_platform, default_config, detect
|
|
14
|
+
from quoll.commands import cd as cd_command
|
|
15
|
+
from quoll.commands import k8s as k8s_command
|
|
16
|
+
from quoll.commands._common import DirOption, resolve_dir
|
|
17
|
+
from quoll.commands._workspace import Snapshot, refresh, snapshot
|
|
18
|
+
from quoll.commands.new import SERVICES_DIRECTORY, Part, create_part, docker_init, parse_service, quiet
|
|
19
|
+
from quoll.config import quoll_toml
|
|
20
|
+
from quoll.config.quoll_toml import ConfigError
|
|
21
|
+
from quoll.console import console
|
|
22
|
+
from quoll.k8s.config import load_config as load_k8s_config
|
|
23
|
+
from quoll.workspace import (
|
|
24
|
+
RepoCIConfig,
|
|
25
|
+
WorkspaceConfig,
|
|
26
|
+
find_root,
|
|
27
|
+
load_workspace,
|
|
28
|
+
member_name,
|
|
29
|
+
save_workspace,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
app = typer.Typer(help="Add a project to a workspace.", no_args_is_help=True)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@app.command()
|
|
36
|
+
def service(
|
|
37
|
+
spec: str = typer.Argument(..., help="NAME=STARTER[@VERSION], e.g. orders=express or orders=express@4."),
|
|
38
|
+
project_dir: Path = DirOption,
|
|
39
|
+
within: str = typer.Option(SERVICES_DIRECTORY, "--in", help="Directory holding the services."),
|
|
40
|
+
node: Optional[str] = typer.Option(None, "--node", help="Node.js version, e.g. 20."),
|
|
41
|
+
) -> None:
|
|
42
|
+
"""Create a service in services/<name>, and add it to the workspace's CI, compose.yaml and delivery."""
|
|
43
|
+
root = find_root(resolve_dir(project_dir))
|
|
44
|
+
try:
|
|
45
|
+
workspace = load_workspace(root)
|
|
46
|
+
except ConfigError as e:
|
|
47
|
+
console.print(f"[red]Error:[/red] {e}")
|
|
48
|
+
raise typer.Exit(1)
|
|
49
|
+
data = quoll_toml.read(root)
|
|
50
|
+
if workspace is None and "project" in data and "stack" in data["project"]:
|
|
51
|
+
console.print(
|
|
52
|
+
"[red]Error:[/red] this repository is a single project: a workspace keeps each project in a directory "
|
|
53
|
+
"of its own (move it, e.g. to back/, then run `quoll ci init`)."
|
|
54
|
+
)
|
|
55
|
+
raise typer.Exit(1)
|
|
56
|
+
|
|
57
|
+
name, starter, version = parse_service(spec)
|
|
58
|
+
directory = f"{within.strip('/')}/{name}" if within.strip("/") else name
|
|
59
|
+
members = list(workspace.members) if workspace else []
|
|
60
|
+
if directory in members or member_name(directory) in {member_name(m) for m in members}:
|
|
61
|
+
console.print(f"[red]Error:[/red] the workspace already has a project named {name}.")
|
|
62
|
+
raise typer.Exit(1)
|
|
63
|
+
if (root / directory).exists() and any((root / directory).iterdir()):
|
|
64
|
+
console.print(f"[red]Error:[/red] {directory} exists and isn't empty.")
|
|
65
|
+
raise typer.Exit(1)
|
|
66
|
+
|
|
67
|
+
platform = configured_platform(root) or platforms.detect(root, platforms.remote(root))
|
|
68
|
+
before = snapshot(root, platform) if workspace else Snapshot(set(), None)
|
|
69
|
+
with Progress(SpinnerColumn(), TextColumn("{task.description}"), console=console) as progress:
|
|
70
|
+
notes = create_part(root, root.name, Part(directory, starter, version, name), {"node": node or ""}, progress)
|
|
71
|
+
quiet("Docker", docker_init(root / directory), progress)
|
|
72
|
+
|
|
73
|
+
task = progress.add_task("Adding it to the workspace...")
|
|
74
|
+
det = detect(root / directory)
|
|
75
|
+
det.platform = platform
|
|
76
|
+
save_config(root / directory, default_config(det))
|
|
77
|
+
if workspace is None:
|
|
78
|
+
save_workspace(root, [directory], RepoCIConfig(platform=platform))
|
|
79
|
+
else:
|
|
80
|
+
quoll_toml.save_section(root, "workspace", WorkspaceConfig(members=[*members, directory]))
|
|
81
|
+
progress.update(task, description=f"[green]✓[/green] {directory} added to the workspace")
|
|
82
|
+
|
|
83
|
+
# The delivery too, when the workspace already publishes images.
|
|
84
|
+
if any("cd" in quoll_toml.read(root / m) for m in members):
|
|
85
|
+
reference = next(load_cd_config(root / m) for m in members if "cd" in quoll_toml.read(root / m))
|
|
86
|
+
quiet("CD", lambda: cd_command._configure(
|
|
87
|
+
root / directory, root, member_name(directory), yes=True, force=False, platform=platform,
|
|
88
|
+
registry=reference.registry, image=None,
|
|
89
|
+
), progress)
|
|
90
|
+
|
|
91
|
+
# And its chart, deployed with the others: same namespaces.
|
|
92
|
+
charted = [m for m in members if "k8s" in quoll_toml.read(root / m)]
|
|
93
|
+
if charted:
|
|
94
|
+
like = load_k8s_config(root / charted[0])
|
|
95
|
+
quiet("Kubernetes", lambda: k8s_command._init(root, root / directory, yes=True, force=False, like=like),
|
|
96
|
+
progress)
|
|
97
|
+
|
|
98
|
+
refresh(root, platform, before, f"add {name} to it yourself")
|
|
99
|
+
|
|
100
|
+
for note in notes:
|
|
101
|
+
console.print(f"[yellow]![/yellow] {note}")
|
|
102
|
+
console.print(f"\n[bold green]✓ {name}[/bold green] is in {directory}/ — reachable from the others at "
|
|
103
|
+
f"[bold]{name.upper().replace('-', '_')}_URL[/bold]. Run everything:")
|
|
104
|
+
console.print(" docker compose up --build")
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
|
|
8
|
+
from quoll import platforms
|
|
9
|
+
from quoll.ci.detect import configured_platform
|
|
10
|
+
from quoll.commands._common import check_platform, ensure_token, platform_option
|
|
11
|
+
from quoll.console import console
|
|
12
|
+
from quoll.platforms import PlatformError
|
|
13
|
+
|
|
14
|
+
app = typer.Typer(help="Add a secret to the GitHub/GitLab repository and the local .env file.")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@app.callback(invoke_without_command=True)
|
|
18
|
+
def add_secret(
|
|
19
|
+
repo: Optional[str] = typer.Option(
|
|
20
|
+
None, "--repo", "-r", help="Repository: name or owner/name (default: the git remote)."
|
|
21
|
+
),
|
|
22
|
+
key: str = typer.Option(..., "--key", "-k", help="Secret name"),
|
|
23
|
+
value: str = typer.Option(..., "--value", "-v", help="Secret value", hide_input=True),
|
|
24
|
+
env_file: Path = typer.Option(Path(".env"), "--env-file", help="Path to local .env file"),
|
|
25
|
+
platform: Optional[str] = platform_option(),
|
|
26
|
+
local: bool = typer.Option(False, "--local", "--skip-github", help="Only update local .env"),
|
|
27
|
+
) -> None:
|
|
28
|
+
"""Add a secret to GitHub Actions secrets / GitLab CI/CD variables and the local .env file."""
|
|
29
|
+
check_platform(platform)
|
|
30
|
+
|
|
31
|
+
# --- Local .env ---
|
|
32
|
+
_write_env(env_file, key, value)
|
|
33
|
+
console.print(f"[green]✓[/green] Added {key} to {env_file}")
|
|
34
|
+
|
|
35
|
+
if local:
|
|
36
|
+
return
|
|
37
|
+
|
|
38
|
+
# --- Repository ---
|
|
39
|
+
project_dir = Path(".")
|
|
40
|
+
remote = platforms.remote(project_dir)
|
|
41
|
+
target = platforms.get(platform or configured_platform(project_dir) or platforms.detect(project_dir, remote))
|
|
42
|
+
repo = repo or (remote.path if remote else None)
|
|
43
|
+
if not repo:
|
|
44
|
+
console.print("[red]Error:[/red] no git remote found — pass --repo.")
|
|
45
|
+
raise typer.Exit(1)
|
|
46
|
+
host = remote.host if remote and platforms.hosting(remote) == target.name else None
|
|
47
|
+
if not ensure_token(target.name, host):
|
|
48
|
+
raise typer.Exit(1)
|
|
49
|
+
|
|
50
|
+
try:
|
|
51
|
+
target.api(host).set_secrets(repo, {key: value})
|
|
52
|
+
console.print(f"[green]✓[/green] Added {key} to {target.label} repo '{repo}'")
|
|
53
|
+
except PlatformError as e:
|
|
54
|
+
console.print(f"[red]{target.label} error:[/red] {e}")
|
|
55
|
+
raise typer.Exit(1)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _write_env(env_file: Path, key: str, value: str) -> None:
|
|
59
|
+
lines: list[str] = []
|
|
60
|
+
|
|
61
|
+
if env_file.exists():
|
|
62
|
+
lines = env_file.read_text().splitlines()
|
|
63
|
+
|
|
64
|
+
# Update existing key or append
|
|
65
|
+
key_found = False
|
|
66
|
+
for i, line in enumerate(lines):
|
|
67
|
+
if line.startswith(f"{key}=") or line.startswith(f"{key} ="):
|
|
68
|
+
lines[i] = f"{key}={value}"
|
|
69
|
+
key_found = True
|
|
70
|
+
break
|
|
71
|
+
|
|
72
|
+
if not key_found:
|
|
73
|
+
lines.append(f"{key}={value}")
|
|
74
|
+
|
|
75
|
+
env_file.write_text("\n".join(lines) + "\n")
|
quoll/commands/cd.py
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import List, Optional, Union
|
|
5
|
+
|
|
6
|
+
import questionary
|
|
7
|
+
import typer
|
|
8
|
+
from rich.table import Table
|
|
9
|
+
|
|
10
|
+
from quoll import platforms
|
|
11
|
+
from quoll.cd.config import CONFIG_FILE, CDConfig, ConfigError, save_config
|
|
12
|
+
from quoll.ci.detect import configured_platform, default_branch
|
|
13
|
+
from quoll.commands._common import (
|
|
14
|
+
DirOption,
|
|
15
|
+
ask,
|
|
16
|
+
check_files,
|
|
17
|
+
check_platform,
|
|
18
|
+
confirm_overwrite,
|
|
19
|
+
platform_option,
|
|
20
|
+
resolve_dir,
|
|
21
|
+
update_files,
|
|
22
|
+
)
|
|
23
|
+
from quoll.config import quoll_toml
|
|
24
|
+
from quoll.console import console
|
|
25
|
+
from quoll.files import PlannedFile, write
|
|
26
|
+
from quoll.platforms.gitlab.ci import CD_PATH, PIPELINE_PATH
|
|
27
|
+
from quoll.workspace import Delivery, find_root, load_deliveries, load_workspace, member_name
|
|
28
|
+
|
|
29
|
+
app = typer.Typer(help="Generate and maintain the delivery pipeline: build, scan, push.", no_args_is_help=True)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@app.command()
|
|
33
|
+
def init(
|
|
34
|
+
project_dir: Path = DirOption,
|
|
35
|
+
yes: bool = typer.Option(False, "--yes", "-y", help="Accept the defaults without asking."),
|
|
36
|
+
force: bool = typer.Option(False, "--force", help="Overwrite a pipeline that was not generated by quoll."),
|
|
37
|
+
platform: Optional[str] = platform_option(),
|
|
38
|
+
registry: Optional[str] = typer.Option(
|
|
39
|
+
None, help="ghcr (GitHub) or gitlab (GitLab) — the default —, or dockerhub."
|
|
40
|
+
),
|
|
41
|
+
image: Optional[str] = typer.Option(None, help="Image name, e.g. ghcr.io/acme/api."),
|
|
42
|
+
) -> None:
|
|
43
|
+
"""Write [cd] in quoll.toml and generate the delivery pipeline.
|
|
44
|
+
|
|
45
|
+
At the root of a workspace: for each of its projects with a Dockerfile; in one of its projects: for it."""
|
|
46
|
+
project_dir = resolve_dir(project_dir)
|
|
47
|
+
check_platform(platform)
|
|
48
|
+
root = find_root(project_dir)
|
|
49
|
+
try:
|
|
50
|
+
workspace = load_workspace(root)
|
|
51
|
+
except ConfigError as e:
|
|
52
|
+
console.print(f"[red]Error:[/red] {e}")
|
|
53
|
+
raise typer.Exit(1)
|
|
54
|
+
if workspace is None:
|
|
55
|
+
cfg = _configure(project_dir, root, "", yes=yes, force=force, platform=platform, registry=registry,
|
|
56
|
+
image=image)
|
|
57
|
+
if cfg is None:
|
|
58
|
+
raise typer.Exit(0)
|
|
59
|
+
deliveries: List[Delivery] = [Delivery("", ".", cfg)]
|
|
60
|
+
else:
|
|
61
|
+
if project_dir == root:
|
|
62
|
+
members = [m for m in workspace.members if (root / m / "Dockerfile").exists()]
|
|
63
|
+
for m in workspace.members:
|
|
64
|
+
if m not in members:
|
|
65
|
+
console.print(f"[yellow]![/yellow] {m}: no Dockerfile — run [bold]quoll docker init[/bold] first.")
|
|
66
|
+
else:
|
|
67
|
+
members = [project_dir.relative_to(root).as_posix()]
|
|
68
|
+
if image and len(members) > 1:
|
|
69
|
+
console.print("[red]Error:[/red] --image names one image: run `quoll cd init` in each project directory.")
|
|
70
|
+
raise typer.Exit(1)
|
|
71
|
+
for member in members:
|
|
72
|
+
_configure(root / member, root, member_name(member), yes=yes, force=force, platform=platform,
|
|
73
|
+
registry=registry, image=image)
|
|
74
|
+
deliveries = _deliveries(root)
|
|
75
|
+
|
|
76
|
+
cfg = deliveries[0].cfg
|
|
77
|
+
files = _plan(root, deliveries)
|
|
78
|
+
if not confirm_overwrite(files, yes=yes, force=force):
|
|
79
|
+
raise typer.Exit(1)
|
|
80
|
+
for delivery in deliveries:
|
|
81
|
+
save_config(root / delivery.directory, delivery.cfg)
|
|
82
|
+
write(root, files)
|
|
83
|
+
console.print()
|
|
84
|
+
for f in files:
|
|
85
|
+
console.print(f"[green]✓[/green] {f.path}")
|
|
86
|
+
|
|
87
|
+
for delivery in deliveries:
|
|
88
|
+
console.print(f"\n[bold green]Done![/bold green] Every push to {', '.join(delivery.cfg.branches)} and every v* "
|
|
89
|
+
f"tag now publishes {delivery.cfg.image}.")
|
|
90
|
+
target = platforms.get(cfg.platform)
|
|
91
|
+
secrets = sorted({s for d in deliveries for s in target.registry_secrets(d.cfg.registry)})
|
|
92
|
+
if secrets:
|
|
93
|
+
names = " and ".join(f"[bold]{s}[/bold]" for s in secrets)
|
|
94
|
+
console.print(f"Set the {names} {target.label} {target.secrets_label}.")
|
|
95
|
+
if cfg.platform == "gitlab":
|
|
96
|
+
_check_gitlab_include(root)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _configure(
|
|
100
|
+
project_dir: Path,
|
|
101
|
+
root: Path,
|
|
102
|
+
member: str,
|
|
103
|
+
*,
|
|
104
|
+
yes: bool,
|
|
105
|
+
force: bool,
|
|
106
|
+
platform: Optional[str],
|
|
107
|
+
registry: Optional[str],
|
|
108
|
+
image: Optional[str],
|
|
109
|
+
) -> Optional[CDConfig]:
|
|
110
|
+
"""The [cd] of one project, asked or defaulted; saved in its quoll.toml. None when kept as it was."""
|
|
111
|
+
console.print(f"\n[bold]quoll cd[/bold] — {member or project_dir.name}\n")
|
|
112
|
+
if "cd" in _read(project_dir) and not force:
|
|
113
|
+
if yes or not ask(questionary.confirm(f"{CONFIG_FILE} already configures CD. Replace it?", default=False)):
|
|
114
|
+
console.print("Kept. Use [bold]quoll cd update[/bold] to regenerate from quoll.toml.")
|
|
115
|
+
return None
|
|
116
|
+
if not (project_dir / "Dockerfile").exists():
|
|
117
|
+
console.print("[yellow]![/yellow] No Dockerfile yet — run [bold]quoll docker init[/bold] first.")
|
|
118
|
+
|
|
119
|
+
remote = platforms.remote(root)
|
|
120
|
+
name = platform or configured_platform(project_dir) or configured_platform(root) or platforms.detect(root, remote)
|
|
121
|
+
target = platforms.get(name)
|
|
122
|
+
registries = target.registries
|
|
123
|
+
if registry is None:
|
|
124
|
+
registry = registries[0] if yes else ask(
|
|
125
|
+
questionary.select("Registry", choices=list(registries), default=registries[0])
|
|
126
|
+
)
|
|
127
|
+
if registry not in registries:
|
|
128
|
+
console.print(
|
|
129
|
+
f"[red]Error:[/red] unknown registry '{registry}' for {target.label} "
|
|
130
|
+
f"(expected one of: {', '.join(registries)})."
|
|
131
|
+
)
|
|
132
|
+
raise typer.Exit(1)
|
|
133
|
+
default_image = target.default_image(remote, registry, root.name, member)
|
|
134
|
+
if image is None:
|
|
135
|
+
image = default_image if yes else ask(questionary.text("Image", default=default_image))
|
|
136
|
+
|
|
137
|
+
note = " (private GitHub repositories need GitHub Enterprise Cloud)" if name == "github" else ""
|
|
138
|
+
sign = True if yes else ask(questionary.confirm(f"Sign the image and its SBOM with Sigstore{note}?", default=True))
|
|
139
|
+
|
|
140
|
+
try:
|
|
141
|
+
cfg = CDConfig(
|
|
142
|
+
platform=name, registry=registry, image=image.lower(), branches=[default_branch(root)], sign=sign
|
|
143
|
+
)
|
|
144
|
+
except ValueError as e:
|
|
145
|
+
console.print(f"[red]Error:[/red] {e}")
|
|
146
|
+
raise typer.Exit(1)
|
|
147
|
+
_print_config(cfg)
|
|
148
|
+
if member:
|
|
149
|
+
save_config(project_dir, cfg)
|
|
150
|
+
return cfg
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
@app.command()
|
|
154
|
+
def update(
|
|
155
|
+
project_dir: Path = DirOption,
|
|
156
|
+
yes: bool = typer.Option(False, "--yes", "-y", help="Write changes without asking."),
|
|
157
|
+
force: bool = typer.Option(False, "--force", help="Overwrite a pipeline that was not generated by quoll."),
|
|
158
|
+
) -> None:
|
|
159
|
+
"""Regenerate the delivery workflow from quoll.toml (the workspace's, from any of its directories)."""
|
|
160
|
+
root = find_root(resolve_dir(project_dir))
|
|
161
|
+
update_files(root, _plan(root, _deliveries(root)), yes=yes, force=force, what="CD is")
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
@app.command()
|
|
165
|
+
def check(project_dir: Path = DirOption) -> None:
|
|
166
|
+
"""Fail if the delivery pipeline differs from what quoll.toml describes."""
|
|
167
|
+
root = find_root(resolve_dir(project_dir))
|
|
168
|
+
check_files(_plan(root, _deliveries(root)), "cd")
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _plan(root: Path, deliveries: List[Delivery]) -> List[PlannedFile]:
|
|
172
|
+
target: Union[CDConfig, List[Delivery]] = deliveries[0].cfg if not deliveries[0].name else deliveries
|
|
173
|
+
return platforms.get(deliveries[0].cfg.platform).cd_files(root, target)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _deliveries(root: Path) -> List[Delivery]:
|
|
177
|
+
try:
|
|
178
|
+
return load_deliveries(root)
|
|
179
|
+
except ConfigError as e:
|
|
180
|
+
console.print(f"[red]Error:[/red] {e}")
|
|
181
|
+
raise typer.Exit(1)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _check_gitlab_include(project_dir: Path) -> None:
|
|
185
|
+
""".gitlab/cd.yml only runs when .gitlab-ci.yml includes it, as quoll's does."""
|
|
186
|
+
pipeline = project_dir / PIPELINE_PATH
|
|
187
|
+
if pipeline.exists() and CD_PATH in pipeline.read_text():
|
|
188
|
+
return
|
|
189
|
+
if not pipeline.exists():
|
|
190
|
+
console.print("[yellow]![/yellow] No .gitlab-ci.yml yet — run [bold]quoll ci init[/bold], which includes "
|
|
191
|
+
f"{CD_PATH}.")
|
|
192
|
+
else:
|
|
193
|
+
console.print(f"[yellow]![/yellow] Add {CD_PATH} to your .gitlab-ci.yml (and a `publish` stage after the "
|
|
194
|
+
f"tests):\n include:\n - local: {CD_PATH}")
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _print_config(cfg: CDConfig) -> 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("Image", cfg.image)
|
|
202
|
+
table.add_row("Published on", ", ".join([*cfg.branches, *(["v* tags"] if cfg.release_tags else [])]))
|
|
203
|
+
table.add_row("Scan", f"Trivy, blocks on {', '.join(cfg.severity)}" if cfg.scan else "off")
|
|
204
|
+
table.add_row("Platforms", ", ".join(cfg.platforms))
|
|
205
|
+
table.add_row("SBOM", "artifact + release asset" if cfg.sbom else "off")
|
|
206
|
+
table.add_row("Signature", "Sigstore" if cfg.sign else "off")
|
|
207
|
+
console.print(table)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _read(project_dir: Path) -> dict:
|
|
211
|
+
try:
|
|
212
|
+
return quoll_toml.read(project_dir)
|
|
213
|
+
except ConfigError:
|
|
214
|
+
return {}
|