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,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 {}