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/ci.py
ADDED
|
@@ -0,0 +1,521 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Dict, List, Optional, Sequence, Tuple, Union
|
|
5
|
+
|
|
6
|
+
import questionary
|
|
7
|
+
import typer
|
|
8
|
+
from pydantic import ValidationError
|
|
9
|
+
from rich.table import Table
|
|
10
|
+
|
|
11
|
+
from quoll import platforms
|
|
12
|
+
from quoll.ci.config import CONFIG_FILE, EXTENSIBLE_JOBS, CIConfig, ConfigError, CustomStep, load_config, save_config
|
|
13
|
+
from quoll.ci.detect import Detection, configured_platform, default_branch, default_config, detect
|
|
14
|
+
from quoll.commands._common import (
|
|
15
|
+
DirOption,
|
|
16
|
+
ask,
|
|
17
|
+
check_files,
|
|
18
|
+
check_platform,
|
|
19
|
+
confirm_overwrite,
|
|
20
|
+
ensure_token,
|
|
21
|
+
platform_option,
|
|
22
|
+
resolve_dir,
|
|
23
|
+
supported_hint,
|
|
24
|
+
update_files,
|
|
25
|
+
)
|
|
26
|
+
from quoll.config import quoll_toml
|
|
27
|
+
from quoll.console import console
|
|
28
|
+
from quoll.files import eject as eject_text
|
|
29
|
+
from quoll.files import write
|
|
30
|
+
from quoll.platforms import PlatformError
|
|
31
|
+
from quoll.platforms.base import PlatformAPI
|
|
32
|
+
from quoll.workspace import Member, Repo, RepoCIConfig, discover, find_root, load_repo, member_name, save_workspace
|
|
33
|
+
|
|
34
|
+
app = typer.Typer(help="Generate and maintain the CI pipeline (GitHub Actions or GitLab CI).", no_args_is_help=True)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@app.command()
|
|
38
|
+
def init(
|
|
39
|
+
project_dir: Path = DirOption,
|
|
40
|
+
yes: bool = typer.Option(False, "--yes", "-y", help="Accept detected defaults without asking."),
|
|
41
|
+
force: bool = typer.Option(False, "--force", help="Overwrite a pipeline that was not generated by quoll."),
|
|
42
|
+
platform: Optional[str] = platform_option(),
|
|
43
|
+
local: bool = typer.Option(
|
|
44
|
+
False, "--local", "--no-github", help="Only write files: skip branch protection and secrets."
|
|
45
|
+
),
|
|
46
|
+
) -> None:
|
|
47
|
+
"""Detect the project (or the projects of a monorepo), write quoll.toml and generate the CI pipeline."""
|
|
48
|
+
project_dir = resolve_dir(project_dir)
|
|
49
|
+
check_platform(platform)
|
|
50
|
+
console.print(f"\n[bold]quoll ci[/bold] — {project_dir.name}\n")
|
|
51
|
+
|
|
52
|
+
if _configured(project_dir) and not force:
|
|
53
|
+
if yes or not ask(questionary.confirm(
|
|
54
|
+
f"{CONFIG_FILE} already configures CI. Detect again and replace it?", default=False
|
|
55
|
+
)):
|
|
56
|
+
console.print("Nothing changed. Use [bold]quoll ci update[/bold] to regenerate from quoll.toml.")
|
|
57
|
+
raise typer.Exit(0)
|
|
58
|
+
|
|
59
|
+
members = discover(project_dir)
|
|
60
|
+
if "." in members and len(members) > 1:
|
|
61
|
+
# The root's quoll.toml can't be both a project's and the workspace's.
|
|
62
|
+
others = ", ".join(m for m in members if m != ".")
|
|
63
|
+
console.print(f"[yellow]![/yellow] Also found {others}: a workspace needs each project in a directory of its "
|
|
64
|
+
"own (move the root's into one). Setting up the root project alone.\n")
|
|
65
|
+
elif members and members != ["."]: # the root alone: a single project
|
|
66
|
+
_init_workspace(project_dir, members, yes=yes, force=force, platform=platform, local=local)
|
|
67
|
+
return
|
|
68
|
+
|
|
69
|
+
det = detect(project_dir)
|
|
70
|
+
if det.stack is None:
|
|
71
|
+
console.print(f"[red]Error:[/red] no supported project found.\n{supported_hint()}")
|
|
72
|
+
raise typer.Exit(1)
|
|
73
|
+
if platform:
|
|
74
|
+
det.platform = platform
|
|
75
|
+
_print_detection(det)
|
|
76
|
+
|
|
77
|
+
cfg = default_config(det)
|
|
78
|
+
if not yes:
|
|
79
|
+
cfg = _customize(cfg, det)
|
|
80
|
+
|
|
81
|
+
files = _plan(project_dir, Repo.single(cfg))
|
|
82
|
+
if not confirm_overwrite(files, yes=yes, force=force):
|
|
83
|
+
raise typer.Exit(1)
|
|
84
|
+
|
|
85
|
+
save_config(project_dir, cfg)
|
|
86
|
+
write(project_dir, files)
|
|
87
|
+
console.print()
|
|
88
|
+
console.print(f"[green]✓[/green] {CONFIG_FILE}")
|
|
89
|
+
for f in files:
|
|
90
|
+
console.print(f"[green]✓[/green] {f.path}")
|
|
91
|
+
|
|
92
|
+
repo = Repo.single(cfg)
|
|
93
|
+
_finish(project_dir, repo, [cfg], det, files, yes=yes, local=local)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _init_workspace(
|
|
97
|
+
root: Path, found: List[str], *, yes: bool, force: bool, platform: Optional[str], local: bool
|
|
98
|
+
) -> None:
|
|
99
|
+
"""A monorepo: one quoll.toml per project, the workspace's at the root, one pipeline for all."""
|
|
100
|
+
remote = platforms.remote(root)
|
|
101
|
+
name = platform or configured_platform(root) or platforms.detect(root, remote)
|
|
102
|
+
table = Table(title="Projects found", box=None, padding=(0, 2), title_justify="left")
|
|
103
|
+
for column in ("Directory", "Stack", "Framework", "Tests"):
|
|
104
|
+
table.add_column(column, style="bold" if column == "Directory" else "")
|
|
105
|
+
detections = {d: detect(root / d) for d in found}
|
|
106
|
+
for directory, det in detections.items():
|
|
107
|
+
table.add_row(directory, det.stack or "-", det.framework or "-", det.test_runner or "-")
|
|
108
|
+
console.print(table)
|
|
109
|
+
console.print()
|
|
110
|
+
members = found if yes else ask(questionary.checkbox(
|
|
111
|
+
"Projects of the workspace", choices=[questionary.Choice(d, d, checked=True) for d in found]
|
|
112
|
+
))
|
|
113
|
+
if not members:
|
|
114
|
+
raise typer.Exit(1)
|
|
115
|
+
|
|
116
|
+
configs: List[CIConfig] = []
|
|
117
|
+
for directory in members:
|
|
118
|
+
det = detections[directory]
|
|
119
|
+
det.platform = name
|
|
120
|
+
console.print(f"\n[bold]{directory}[/bold]")
|
|
121
|
+
_print_detection(det)
|
|
122
|
+
cfg = default_config(det)
|
|
123
|
+
if not yes:
|
|
124
|
+
cfg = _customize(cfg, det, workspace=True)
|
|
125
|
+
configs.append(cfg.in_directory(directory))
|
|
126
|
+
|
|
127
|
+
shared = RepoCIConfig(platform=name, branches=[default_branch(root)])
|
|
128
|
+
repo = Repo(
|
|
129
|
+
platform=name,
|
|
130
|
+
branches=list(shared.branches),
|
|
131
|
+
dependency_updates=shared.dependency_updates,
|
|
132
|
+
schedule=shared.schedule,
|
|
133
|
+
secrets=shared.security.secrets,
|
|
134
|
+
sast=shared.security.sast,
|
|
135
|
+
members=[Member(member_name(c.directory), c) for c in configs],
|
|
136
|
+
)
|
|
137
|
+
files = _plan(root, repo)
|
|
138
|
+
if not confirm_overwrite(files, yes=yes, force=force):
|
|
139
|
+
raise typer.Exit(1)
|
|
140
|
+
for cfg in configs:
|
|
141
|
+
save_config(root / cfg.directory, cfg)
|
|
142
|
+
save_workspace(root, members, shared)
|
|
143
|
+
write(root, files)
|
|
144
|
+
console.print()
|
|
145
|
+
console.print(f"[green]✓[/green] {CONFIG_FILE} (workspace: {', '.join(members)})")
|
|
146
|
+
for cfg in configs:
|
|
147
|
+
console.print(f"[green]✓[/green] {cfg.path(CONFIG_FILE)}")
|
|
148
|
+
for f in files:
|
|
149
|
+
console.print(f"[green]✓[/green] {f.path}")
|
|
150
|
+
det = detections[members[0]]
|
|
151
|
+
det.env_vars = {k: v for d in members for k, v in detections[d].env_vars.items()}
|
|
152
|
+
_finish(root, repo, configs, det, [], yes=yes, local=local, extra=[cfg.path(CONFIG_FILE) for cfg in configs])
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _finish(
|
|
156
|
+
root: Path,
|
|
157
|
+
repo: Repo,
|
|
158
|
+
configs: List[CIConfig],
|
|
159
|
+
det: Detection,
|
|
160
|
+
files: list,
|
|
161
|
+
*,
|
|
162
|
+
yes: bool,
|
|
163
|
+
local: bool,
|
|
164
|
+
extra: Sequence[str] = (),
|
|
165
|
+
) -> None:
|
|
166
|
+
platform = platforms.get(repo.platform)
|
|
167
|
+
if not yes and not local:
|
|
168
|
+
_remote_setup(repo, configs, det)
|
|
169
|
+
elif repo.schedule.enabled and platform.schedules_via_api:
|
|
170
|
+
console.print("\n[yellow]![/yellow] The weekly security scans need a pipeline schedule: run "
|
|
171
|
+
"[bold]quoll ci schedule[/bold] once pushed.")
|
|
172
|
+
|
|
173
|
+
planned_files = files or _plan(root, repo)
|
|
174
|
+
paths = sorted({f.path.parts[0] + ("/" if len(f.path.parts) > 1 else "") for f in planned_files})
|
|
175
|
+
console.print("\n[bold green]Done![/bold green] Commit and push to run the pipeline:")
|
|
176
|
+
console.print(f" git add {' '.join([CONFIG_FILE, *extra, *paths])}")
|
|
177
|
+
console.print(' git commit -m "ci: add quoll pipeline"')
|
|
178
|
+
console.print(" git push")
|
|
179
|
+
console.print(f"\n[dim]{platform.merge_gate()}[/dim]")
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
@app.command()
|
|
183
|
+
def update(
|
|
184
|
+
project_dir: Path = DirOption,
|
|
185
|
+
yes: bool = typer.Option(False, "--yes", "-y", help="Write changes without asking."),
|
|
186
|
+
force: bool = typer.Option(False, "--force", help="Overwrite a pipeline that was not generated by quoll."),
|
|
187
|
+
) -> None:
|
|
188
|
+
"""Regenerate the pipeline from quoll.toml (the workspace's, from any of its directories)."""
|
|
189
|
+
root, repo = _repo(resolve_dir(project_dir))
|
|
190
|
+
update_files(root, _plan(root, repo), yes=yes, force=force, what="CI is")
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
@app.command()
|
|
194
|
+
def check(project_dir: Path = DirOption) -> None:
|
|
195
|
+
"""Fail if the pipeline differs from what quoll.toml describes (use it in CI or pre-commit)."""
|
|
196
|
+
root, repo = _repo(resolve_dir(project_dir))
|
|
197
|
+
check_files([f for f in _plan(root, repo) if not f.create_only], "ci")
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
@app.command()
|
|
201
|
+
def eject(
|
|
202
|
+
project_dir: Path = DirOption,
|
|
203
|
+
yes: bool = typer.Option(False, "--yes", "-y", help="Don't ask for confirmation."),
|
|
204
|
+
) -> None:
|
|
205
|
+
"""Stop managing the CI: its files become yours, as they are, and [ci] leaves quoll.toml."""
|
|
206
|
+
root, repo = _repo(resolve_dir(project_dir))
|
|
207
|
+
files = [f for f in _plan(root, repo) if not f.create_only and f.existing and f.managed]
|
|
208
|
+
if not yes and not ask(questionary.confirm(
|
|
209
|
+
f"quoll will no longer update {', '.join(str(f.path) for f in files)}. Eject?", default=False
|
|
210
|
+
)):
|
|
211
|
+
raise typer.Exit(1)
|
|
212
|
+
for f in files:
|
|
213
|
+
(root / f.path).write_text(eject_text(f.existing, "ci"))
|
|
214
|
+
console.print(f"[green]✓[/green] {f.path} is yours")
|
|
215
|
+
for directory in sorted({".", *(m.directory for m in repo.members)}):
|
|
216
|
+
quoll_toml.remove_section(root / directory, "ci")
|
|
217
|
+
console.print(f"[green]✓[/green] [ci] removed from {Path(directory, CONFIG_FILE).as_posix()}")
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
@app.command()
|
|
221
|
+
def schedule(project_dir: Path = DirOption) -> None:
|
|
222
|
+
"""Create or update the schedule of the security scans ([ci.schedule]) where it lives outside the pipeline."""
|
|
223
|
+
root, repo = _repo(resolve_dir(project_dir))
|
|
224
|
+
platform = platforms.get(repo.platform)
|
|
225
|
+
if not repo.schedule.enabled:
|
|
226
|
+
console.print("Scheduled scans are off ([ci.schedule] enabled = false).")
|
|
227
|
+
return
|
|
228
|
+
if not platform.schedules_via_api:
|
|
229
|
+
console.print(f"[green]✓[/green] On {platform.label} the schedule ({repo.schedule.cron}) is in the pipeline "
|
|
230
|
+
"itself: `quoll ci update` keeps it current.")
|
|
231
|
+
return
|
|
232
|
+
remote = platforms.remote(root)
|
|
233
|
+
if remote is None or platforms.hosting(remote) != platform.name:
|
|
234
|
+
console.print(f"[red]Error:[/red] no {platform.label} remote (origin) found.")
|
|
235
|
+
raise typer.Exit(1)
|
|
236
|
+
if not ensure_token(platform.name, remote.host):
|
|
237
|
+
raise typer.Exit(1)
|
|
238
|
+
_schedule(platform.api(remote.host), remote.path, repo.branches[0], repo.schedule.cron)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
@app.command(name="add-step")
|
|
242
|
+
def add_step(
|
|
243
|
+
project_dir: Path = DirOption,
|
|
244
|
+
job: Optional[str] = typer.Option(None, help=f"Job to extend: {', '.join(EXTENSIBLE_JOBS)}."),
|
|
245
|
+
when: Optional[str] = typer.Option(None, help="before (once dependencies are installed) or after (at the end)."),
|
|
246
|
+
name: Optional[str] = typer.Option(None, help="Step name, shown in the CI."),
|
|
247
|
+
run: Optional[str] = typer.Option(None, help="Shell command, run in the project's directory."),
|
|
248
|
+
uses: Optional[str] = typer.Option(None, help="A GitHub Action instead (GitHub only), e.g. owner/action@v1."),
|
|
249
|
+
with_: List[str] = typer.Option([], "--with", help="Action input KEY=VALUE (repeatable)."),
|
|
250
|
+
secret: List[str] = typer.Option([], "--secret", help="CI secret / variable the step reads (repeatable)."),
|
|
251
|
+
) -> None:
|
|
252
|
+
"""Add a step to a generated job ([[ci.steps]] in quoll.toml), then regenerate the pipeline."""
|
|
253
|
+
project_dir = resolve_dir(project_dir)
|
|
254
|
+
if "workspace" in _read(project_dir):
|
|
255
|
+
members = load_repo(project_dir).members
|
|
256
|
+
console.print(f"Steps belong to a project of the workspace: run with -d <project> "
|
|
257
|
+
f"({', '.join(m.cfg.directory for m in members)}).")
|
|
258
|
+
raise typer.Exit(1)
|
|
259
|
+
try:
|
|
260
|
+
cfg = load_config(project_dir)
|
|
261
|
+
except ConfigError as e:
|
|
262
|
+
console.print(f"[red]Error:[/red] {e}")
|
|
263
|
+
raise typer.Exit(1)
|
|
264
|
+
|
|
265
|
+
job = job or ask(questionary.select("Job", choices=list(EXTENSIBLE_JOBS)))
|
|
266
|
+
when = when or ask(questionary.select("When", choices=[
|
|
267
|
+
questionary.Choice("before its own commands (dependencies installed)", "before"),
|
|
268
|
+
questionary.Choice("at the end", "after"),
|
|
269
|
+
]))
|
|
270
|
+
name = name or ask(questionary.text("Step name", validate=lambda v: bool(v.strip()) or "Required"))
|
|
271
|
+
if not run and not uses:
|
|
272
|
+
run = ask(questionary.text("Command", validate=lambda v: bool(v.strip()) or "Required"))
|
|
273
|
+
if uses and cfg.platform != "github":
|
|
274
|
+
console.print("[red]Error:[/red] actions (--uses) are GitHub's: give a command with --run.")
|
|
275
|
+
raise typer.Exit(1)
|
|
276
|
+
inputs = dict(item.split("=", 1) for item in with_ if "=" in item)
|
|
277
|
+
try:
|
|
278
|
+
added = CustomStep.model_validate({
|
|
279
|
+
"job": job, "when": when, "name": name, "run": run or "", "uses": uses or "", "with": inputs,
|
|
280
|
+
"secrets": list(secret),
|
|
281
|
+
})
|
|
282
|
+
except ValidationError as e:
|
|
283
|
+
console.print(f"[red]Error:[/red] {e.errors()[0]['msg']}")
|
|
284
|
+
raise typer.Exit(1)
|
|
285
|
+
cfg.steps.append(added)
|
|
286
|
+
save_config(project_dir, cfg)
|
|
287
|
+
console.print(f"[green]✓[/green] {CONFIG_FILE}: step '{name}' added to the {job} job")
|
|
288
|
+
root, repo = _repo(project_dir)
|
|
289
|
+
files = [f for f in _plan(root, repo) if f.changed]
|
|
290
|
+
if any(not f.managed for f in files):
|
|
291
|
+
console.print("[yellow]![/yellow] The pipeline was written by hand: run [bold]quoll ci update --force[/bold] "
|
|
292
|
+
"to regenerate it.")
|
|
293
|
+
raise typer.Exit(1)
|
|
294
|
+
write(root, files)
|
|
295
|
+
for f in files:
|
|
296
|
+
console.print(f"[green]✓[/green] {f.path} updated")
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _read(project_dir: Path) -> dict:
|
|
300
|
+
try:
|
|
301
|
+
return quoll_toml.read(project_dir)
|
|
302
|
+
except ConfigError:
|
|
303
|
+
return {}
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
@app.command(name="detect")
|
|
307
|
+
def detect_cmd(project_dir: Path = DirOption) -> None:
|
|
308
|
+
"""Show what quoll detects in the project (or each project of a monorepo), without writing anything."""
|
|
309
|
+
project_dir = resolve_dir(project_dir)
|
|
310
|
+
members = discover(project_dir)
|
|
311
|
+
if members and members != ["."]:
|
|
312
|
+
for directory in members:
|
|
313
|
+
console.print(f"[bold]{directory}[/bold]")
|
|
314
|
+
_print_detection(detect(project_dir / directory))
|
|
315
|
+
return
|
|
316
|
+
det = detect(project_dir)
|
|
317
|
+
if det.stack is None:
|
|
318
|
+
console.print(f"[yellow]No supported project found.[/yellow]\n{supported_hint()}")
|
|
319
|
+
raise typer.Exit(1)
|
|
320
|
+
_print_detection(det)
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
# --------------------------------------------------------------------------- interactive
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _customize(cfg: CIConfig, det: Detection, workspace: bool = False) -> CIConfig:
|
|
327
|
+
data = cfg.model_dump(by_alias=True)
|
|
328
|
+
# A workspace scans secrets and code once, for the whole repository (its root quoll.toml).
|
|
329
|
+
repository_scans = [] if workspace else [
|
|
330
|
+
questionary.Choice("Secret scanning (gitleaks)", "secrets", checked=True),
|
|
331
|
+
questionary.Choice("Static analysis (Semgrep)", "sast", checked=True),
|
|
332
|
+
]
|
|
333
|
+
jobs = [
|
|
334
|
+
questionary.Choice("Lint", "lint", checked=cfg.lint.enabled),
|
|
335
|
+
questionary.Choice(f"Tests ({cfg.test.runner or 'none detected'})", "test", checked=cfg.test.enabled),
|
|
336
|
+
questionary.Choice("Dependency audit", "audit", checked=cfg.security.audit),
|
|
337
|
+
*repository_scans,
|
|
338
|
+
questionary.Choice(f"Build ({cfg.build.command or 'none detected'})", "build", checked=cfg.build.enabled),
|
|
339
|
+
questionary.Choice("Docker image (hadolint, build, Trivy scan)", "docker", checked=det.has_dockerfile),
|
|
340
|
+
*(
|
|
341
|
+
[questionary.Choice("CodeQL (needs GitHub Advanced Security on private repos)", "codeql", checked=False)]
|
|
342
|
+
if cfg.platform == "github" and cfg.language.ci.codeql_language
|
|
343
|
+
else []
|
|
344
|
+
),
|
|
345
|
+
questionary.Choice("SonarCloud", "sonar", checked=False),
|
|
346
|
+
]
|
|
347
|
+
selected = ask(questionary.checkbox("Jobs", choices=jobs))
|
|
348
|
+
|
|
349
|
+
data["lint"]["enabled"] = "lint" in selected
|
|
350
|
+
if data["lint"]["enabled"]:
|
|
351
|
+
known = cfg.lint_tools
|
|
352
|
+
options = [t for t in cfg.lint.tools if t not in known] + known
|
|
353
|
+
data["lint"]["tools"] = ask(questionary.checkbox(
|
|
354
|
+
"Lint tools",
|
|
355
|
+
choices=[questionary.Choice(t, t, checked=t in cfg.lint.tools) for t in options],
|
|
356
|
+
))
|
|
357
|
+
data["lint"]["enabled"] = bool(data["lint"]["tools"])
|
|
358
|
+
|
|
359
|
+
data["test"]["enabled"] = "test" in selected and bool(cfg.test.runner)
|
|
360
|
+
if "test" in selected and not cfg.test.runner:
|
|
361
|
+
console.print(" [yellow]No test runner detected — set test.runner in quoll.toml to enable tests.[/yellow]")
|
|
362
|
+
if data["test"]["enabled"]:
|
|
363
|
+
if cfg.test.coverage:
|
|
364
|
+
threshold = ask(questionary.text(
|
|
365
|
+
"Minimum coverage (%)",
|
|
366
|
+
default=str(cfg.test.coverage_threshold),
|
|
367
|
+
validate=lambda v: v.isdigit() and 0 <= int(v) <= 100 or "Enter a number between 0 and 100",
|
|
368
|
+
))
|
|
369
|
+
data["test"]["coverage_threshold"] = int(threshold)
|
|
370
|
+
data["test"]["codecov"] = ask(questionary.confirm("Upload coverage to Codecov?", default=False))
|
|
371
|
+
if len(det.matrix_versions) > 1:
|
|
372
|
+
versions = ", ".join(det.matrix_versions)
|
|
373
|
+
if ask(questionary.confirm(f"Test against several versions ({versions})?", default=False)):
|
|
374
|
+
data["versions"] = list(det.matrix_versions)
|
|
375
|
+
if det.services:
|
|
376
|
+
data["services"] = ask(questionary.checkbox(
|
|
377
|
+
"Start these services for the tests",
|
|
378
|
+
choices=[questionary.Choice(s, s, checked=True) for s in det.services],
|
|
379
|
+
))
|
|
380
|
+
if det.env_vars:
|
|
381
|
+
platform = platforms.get(cfg.platform)
|
|
382
|
+
data["test"]["env_secrets"] = ask(questionary.checkbox(
|
|
383
|
+
f"Expose these .env.example variables to tests (as {platform.label} {platform.secrets_label})",
|
|
384
|
+
choices=[questionary.Choice(k, k, checked=True) for k in det.env_vars],
|
|
385
|
+
))
|
|
386
|
+
|
|
387
|
+
data["security"]["audit"] = "audit" in selected
|
|
388
|
+
if not workspace:
|
|
389
|
+
data["security"]["secrets"] = "secrets" in selected
|
|
390
|
+
data["security"]["sast"] = "sast" in selected
|
|
391
|
+
data["security"]["codeql"] = "codeql" in selected
|
|
392
|
+
data["security"]["sonar"] = "sonar" in selected
|
|
393
|
+
if data["security"]["sonar"]:
|
|
394
|
+
owner = det.remote.path.split("/")[0] if det.remote else ""
|
|
395
|
+
data["security"]["sonar_organization"] = ask(questionary.text("SonarCloud organization", default=owner))
|
|
396
|
+
if det.remote:
|
|
397
|
+
data["security"]["sonar_project_key"] = det.remote.path.replace("/", "_")
|
|
398
|
+
|
|
399
|
+
data["build"]["enabled"] = "build" in selected and bool(cfg.build.command)
|
|
400
|
+
data["build"]["docker"] = "docker" in selected
|
|
401
|
+
return CIConfig.model_validate(data)
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def _remote_setup(repo: Repo, configs: List[CIConfig], det: Detection) -> None:
|
|
405
|
+
platform = platforms.get(repo.platform)
|
|
406
|
+
remote = det.remote
|
|
407
|
+
if remote is None or platforms.hosting(remote) != platform.name:
|
|
408
|
+
console.print(f"\n[dim]No {platform.label} remote (origin) found — skipping branch protection and "
|
|
409
|
+
f"{platform.secrets_label}.[/dim]")
|
|
410
|
+
return
|
|
411
|
+
branch = repo.branches[0]
|
|
412
|
+
|
|
413
|
+
protect = ask(questionary.confirm(
|
|
414
|
+
f"Require the pipeline to pass before merging into {remote.path}@{branch}?", default=True
|
|
415
|
+
))
|
|
416
|
+
|
|
417
|
+
secrets: Dict[str, str] = {}
|
|
418
|
+
wanted: List[str] = list(dict.fromkeys(s for cfg in configs for s in cfg.test.env_secrets))
|
|
419
|
+
if any(cfg.security.sonar for cfg in configs):
|
|
420
|
+
wanted.append("SONAR_TOKEN")
|
|
421
|
+
if any(cfg.test.codecov for cfg in configs):
|
|
422
|
+
wanted.append("CODECOV_TOKEN")
|
|
423
|
+
if wanted and ask(questionary.confirm(
|
|
424
|
+
f"Set the {platform.label} {platform.secrets_label} now ({', '.join(wanted)})?", default=True
|
|
425
|
+
)):
|
|
426
|
+
for key in wanted:
|
|
427
|
+
default = det.env_vars.get(key, "")
|
|
428
|
+
value = ask(questionary.password(f"{key}" + (" (Enter = .env.example value)" if default else "")))
|
|
429
|
+
if value or default:
|
|
430
|
+
secrets[key] = value or default
|
|
431
|
+
|
|
432
|
+
schedule = repo.schedule.enabled and platform.schedules_via_api and ask(questionary.confirm(
|
|
433
|
+
f"Schedule the security scans ({repo.schedule.cron}, UTC) on {branch}?", default=True
|
|
434
|
+
))
|
|
435
|
+
|
|
436
|
+
if not protect and not secrets and not schedule:
|
|
437
|
+
return
|
|
438
|
+
if not ensure_token(platform.name, remote.host):
|
|
439
|
+
console.print(f"[yellow]No {platform.label} token — skipped.[/yellow]")
|
|
440
|
+
return
|
|
441
|
+
|
|
442
|
+
api = platform.api(remote.host)
|
|
443
|
+
if protect:
|
|
444
|
+
try:
|
|
445
|
+
for warning in api.protect_branch(remote.path, branch, required_checks=platform.required_checks):
|
|
446
|
+
console.print(f"[yellow]Warning:[/yellow] {warning}")
|
|
447
|
+
console.print(f"[green]✓[/green] {branch} is protected: merges require a green pipeline")
|
|
448
|
+
except PlatformError as e:
|
|
449
|
+
console.print(f"[yellow]Warning:[/yellow] branch protection failed: {e}")
|
|
450
|
+
if schedule:
|
|
451
|
+
_schedule(api, remote.path, branch, repo.schedule.cron)
|
|
452
|
+
if secrets:
|
|
453
|
+
try:
|
|
454
|
+
api.set_secrets(remote.path, secrets)
|
|
455
|
+
console.print(f"[green]✓[/green] {len(secrets)} {platform.secrets_label} set: {', '.join(secrets)}")
|
|
456
|
+
except Exception as e: # httpx errors, missing PyNaCl...
|
|
457
|
+
console.print(f"[yellow]Warning:[/yellow] setting {platform.secrets_label} failed: {e}")
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def _schedule(api: PlatformAPI, repo: str, branch: str, cron: str) -> None:
|
|
461
|
+
try:
|
|
462
|
+
api.ensure_schedule(repo, branch, cron)
|
|
463
|
+
console.print(f"[green]✓[/green] Security scans scheduled on {branch} ({cron}, UTC)")
|
|
464
|
+
except PlatformError as e:
|
|
465
|
+
console.print(f"[yellow]Warning:[/yellow] scheduling the security scans failed: {e}")
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
# --------------------------------------------------------------------------- output
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def _print_detection(det: Detection) -> None:
|
|
472
|
+
table = Table(show_header=False, box=None, padding=(0, 2))
|
|
473
|
+
table.add_column(style="dim")
|
|
474
|
+
table.add_column(style="bold")
|
|
475
|
+
version = f"{det.version} (from {det.version_file})" if det.version_file else det.version
|
|
476
|
+
table.add_row("Stack", det.stack or "-")
|
|
477
|
+
table.add_row("Framework", det.framework or "-")
|
|
478
|
+
table.add_row("Package manager", det.package_manager + (f" ({det.lockfile})" if det.lockfile else ""))
|
|
479
|
+
table.add_row("Version", version)
|
|
480
|
+
table.add_row("Lint", ", ".join(det.lint_tools) or "-")
|
|
481
|
+
table.add_row("Tests", det.test_runner + ("" if det.coverage or not det.test_runner else " (no coverage)") or "-")
|
|
482
|
+
table.add_row("Build", det.build_command.replace("\n", " && ") or "-")
|
|
483
|
+
table.add_row("Services", ", ".join(det.services) or "-")
|
|
484
|
+
table.add_row("Dockerfile", "yes" if det.has_dockerfile else "no")
|
|
485
|
+
table.add_row("Default branch", det.default_branch)
|
|
486
|
+
remote = f" ({det.remote.host}/{det.remote.path})" if det.remote else ""
|
|
487
|
+
table.add_row("Platform", platforms.get(det.platform).label + remote)
|
|
488
|
+
console.print(table)
|
|
489
|
+
for warning in det.warnings:
|
|
490
|
+
console.print(f"[yellow]![/yellow] {warning}")
|
|
491
|
+
console.print()
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
# --------------------------------------------------------------------------- helpers
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
def _plan(root: Path, target: Union[CIConfig, Repo]) -> list:
|
|
498
|
+
platform = target.platform
|
|
499
|
+
try:
|
|
500
|
+
return platforms.get(platform).ci_files(root, target)
|
|
501
|
+
except ValueError as e:
|
|
502
|
+
console.print(f"[red]Error:[/red] {e}")
|
|
503
|
+
raise typer.Exit(1)
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
def _repo(project_dir: Path) -> Tuple[Path, Repo]:
|
|
507
|
+
"""The repository `project_dir` belongs to (its workspace root, if any) and its pipeline settings."""
|
|
508
|
+
root = find_root(project_dir)
|
|
509
|
+
try:
|
|
510
|
+
return root, load_repo(root)
|
|
511
|
+
except ConfigError as e:
|
|
512
|
+
console.print(f"[red]Error:[/red] {e}")
|
|
513
|
+
raise typer.Exit(1)
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
def _configured(project_dir: Path) -> bool:
|
|
517
|
+
try:
|
|
518
|
+
data = quoll_toml.read(project_dir)
|
|
519
|
+
except ConfigError:
|
|
520
|
+
return True
|
|
521
|
+
return "ci" in data or "workspace" in data
|