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,400 @@
1
+ """Turn a CIConfig into .gitlab-ci.yml (and a Renovate config)."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ from pathlib import Path
6
+ from typing import Any, Dict, List, Tuple, Union
7
+
8
+ from quoll.ci import custom
9
+ from quoll.ci.config import SECURITY_JOBS, CIConfig
10
+ from quoll.ci.steps import Item, Step, step
11
+ from quoll.ci.tools import (
12
+ GITLEAKS_IMAGE,
13
+ SBOM_FILE,
14
+ SEMGREP_IMAGE,
15
+ dependency_checks,
16
+ image_checks,
17
+ sast,
18
+ secret_scan,
19
+ )
20
+ from quoll.files import PlannedFile, header, planned
21
+ from quoll.k8s.ci import HELM_IMAGE, chart_checks
22
+ from quoll.languages.base import FrameworkCI
23
+ from quoll.platforms.gitlab.pipeline import branch_rule, docker_job, dump, job, registry_cache, variable_name
24
+ from quoll.services import SERVICES
25
+ from quoll.workspace import Member, Repo
26
+
27
+ PIPELINE_PATH = Path(".gitlab-ci.yml")
28
+ # quoll's jobs, included by .gitlab-ci.yml.
29
+ JOBS_PATH = ".gitlab/quoll-ci.yml"
30
+ # Template of the project's environment for your own jobs.
31
+ SETUP_TEMPLATE = ".quoll-setup"
32
+ SCHEDULED = '$CI_PIPELINE_SOURCE == "schedule"'
33
+ # Your own jobs and overrides of quoll's, included last when present: quoll never touches it.
34
+ CUSTOM_PATH = ".gitlab/custom-ci.yml"
35
+ # Written by `quoll cd`, included when present.
36
+ CD_PATH = ".gitlab/cd.yml"
37
+ RENOVATE_PATH = Path("renovate.json")
38
+ # Any of these means Renovate is already configured.
39
+ RENOVATE_CONFIGS = ("renovate.json", "renovate.json5", ".renovaterc", ".renovaterc.json", ".gitlab/renovate.json")
40
+ SONAR_IMAGE = "sonarsource/sonar-scanner-cli:12"
41
+ SAST_REPORT = "gl-sast-report.json"
42
+ _INFRASTRUCTURE_FAILURES = (
43
+ "runner_system_failure", "stuck_or_timeout_failure", "api_failure", "scheduler_failure", "data_integrity_failure"
44
+ )
45
+ CODECOV_IMAGE = "python:3"
46
+ # CI jobs run in `test`; the delivery publishes once they all passed.
47
+ STAGES = ["test", "publish", "deploy"]
48
+
49
+
50
+ def render_pipeline(target: Union[CIConfig, Repo]) -> str:
51
+ """.gitlab/quoll-ci.yml: the jobs of a project, or of every member of a workspace (run when it changed)."""
52
+ repo = target if isinstance(target, Repo) else Repo.single(target)
53
+ jobs: Dict[str, Any] = {}
54
+ templates: Dict[str, Any] = {}
55
+ for member in repo.members:
56
+ member_jobs, template = _project_jobs(member, repo)
57
+ if repo.workspace:
58
+ member_jobs = {name: _on_changes(j, member) for name, j in member_jobs.items()}
59
+ jobs.update(member_jobs)
60
+ templates[_template_name(member)] = template
61
+ if repo.workspace:
62
+ # Repository-wide scans, once.
63
+ jobs.update(_repo_jobs(repo))
64
+ if not jobs:
65
+ raise ValueError("every CI job is disabled — enable at least one in quoll.toml")
66
+
67
+ if repo.schedule.enabled:
68
+ # Scheduled pipelines (a GitLab pipeline schedule, see `quoll ci schedule`) only re-check security.
69
+ jobs = {name: j if _kind(name, repo) in SECURITY_JOBS else _skip_on_schedule(j) for name, j in jobs.items()}
70
+
71
+ doc: Dict[str, Any] = {}
72
+ if not repo.workspace:
73
+ env = _framework_env(repo.members[0].cfg)
74
+ if env:
75
+ doc["variables"] = env
76
+ doc["default"] = {"interruptible": True}
77
+ doc.update(templates)
78
+ doc.update(jobs)
79
+ return header("ci") + dump(doc)
80
+
81
+
82
+ def _project_jobs(member: Member, repo: Repo) -> Tuple[Dict[str, Any], Dict[str, Any]]:
83
+ """The jobs of one project and its setup template. Alone in the repository, it also carries the
84
+ repository-wide scans, in their historical place."""
85
+ cfg = member.cfg
86
+ ci = cfg.language.ci
87
+ framework = cfg.get_framework()
88
+ framework_ci = framework.ci if framework else FrameworkCI()
89
+ version = cfg.versions[-1]
90
+ image = ci.image(cfg, version)
91
+ setup = ci.image_setup(cfg)
92
+ cache = ci.cache(cfg)
93
+ caches = [cache] if cache else []
94
+ directory = cfg.directory
95
+ # In a workspace, each project's framework settings go to its own jobs.
96
+ env = _framework_env(cfg) if repo.workspace else {}
97
+ jobs: Dict[str, Any] = {}
98
+
99
+ def project_job(items: List[Item], **options: Any) -> Dict[str, Any]:
100
+ variables = {**env, **options.pop("variables", {})}
101
+ return job(
102
+ "test", options.pop("image", image), items, directory=directory, variables=variables or None, **options
103
+ )
104
+
105
+ if cfg.lint.enabled and cfg.lint.tools:
106
+ own = [t for t in cfg.lint.tools if cfg.language.is_lint_tool(t)]
107
+ items: List[Item] = list(ci.lint(cfg, own))
108
+ for tool in cfg.lint.tools:
109
+ if tool not in own:
110
+ items.extend(framework_ci.lint(cfg, tool))
111
+ items = custom.extend(cfg, "lint", items)
112
+ jobs[member.job("lint")] = project_job(items, setup=setup, caches=caches, timeout=10)
113
+
114
+ if cfg.test.enabled:
115
+ tests = custom.extend(cfg, "test", framework_ci.test(cfg) if cfg.is_framework_runner else ci.test(cfg))
116
+ test_setup = list(setup)
117
+ options: Dict[str, Any] = {}
118
+ if cfg.matrix:
119
+ # Each entry carries its resolved image: versions may be aliases (stable, lts...) of other tags.
120
+ variable = variable_name(ci.version_key)
121
+ options["image"] = "$IMAGE"
122
+ options["parallel"] = {"matrix": [{variable: v, "IMAGE": ci.image(cfg, v)} for v in cfg.versions]}
123
+ if cfg.services:
124
+ options["services"] = _services(cfg)
125
+ # Exported by the script rather than set as job variables, which project CI/CD variables of the same
126
+ # name would override: tests must never hit a real database.
127
+ test_setup.insert(0, _export_step(cfg.service_environment(lambda name: name)))
128
+ artifacts = _test_artifacts(cfg)
129
+ if artifacts:
130
+ options["artifacts"] = artifacts
131
+ test = project_job(tests, setup=test_setup, caches=caches, timeout=20, **options)
132
+ regex = ci.reports(cfg).coverage_regex if cfg.test.reports else ""
133
+ if regex:
134
+ # Total coverage parsed from the log: merge request widget and badge.
135
+ test["coverage"] = regex
136
+ jobs[member.job("test")] = test
137
+ if cfg.test.coverage and cfg.test.codecov:
138
+ jobs[member.job("codecov")] = job(
139
+ "test",
140
+ CODECOV_IMAGE,
141
+ [_codecov_step(cfg.path(ci.coverage_file(cfg)))],
142
+ needs=[{"job": member.job("test"), "artifacts": True}],
143
+ rules=[{"if": "$CODECOV_TOKEN", "when": "always"}],
144
+ timeout=5,
145
+ )
146
+
147
+ if cfg.security.audit:
148
+ audit = custom.extend(cfg, "audit", ci.audit(cfg))
149
+ jobs[member.job("audit")] = project_job(audit, setup=setup, caches=caches, timeout=10)
150
+
151
+ if not repo.workspace and repo.secrets:
152
+ jobs["secrets"] = _secrets_job()
153
+
154
+ if cfg.security.sbom or cfg.security.licenses:
155
+ sec = cfg.security
156
+ items = [*ci.install(cfg), *dependency_checks(sec.sbom, sec.licenses, sec.license_severity)]
157
+ items = custom.extend(cfg, "sbom", items)
158
+ sbom_job = project_job(items, setup=setup, caches=caches, timeout=15)
159
+ if sec.sbom:
160
+ # The dependency list of the project (GitLab Ultimate), and the file to download on every tier.
161
+ sbom = cfg.path(SBOM_FILE)
162
+ sbom_job["artifacts"] = {
163
+ "when": "always",
164
+ "paths": [sbom],
165
+ "reports": {"cyclonedx": [sbom]},
166
+ "expire_in": "1 week",
167
+ }
168
+ jobs[member.job("sbom")] = sbom_job
169
+
170
+ if not repo.workspace and repo.sast:
171
+ jobs["sast"] = _sast_job()
172
+
173
+ if cfg.security.sonar:
174
+ sonar = _tool_job(
175
+ SONAR_IMAGE,
176
+ [step("SonarCloud scan", run=_sonar_command(cfg))],
177
+ # Masked variables are not available to merge requests from forks.
178
+ rules=[{"if": "$SONAR_TOKEN"}],
179
+ variables={
180
+ "SONAR_HOST_URL": "https://sonarcloud.io",
181
+ "SONAR_USER_HOME": "$CI_PROJECT_DIR/.sonar",
182
+ "GIT_DEPTH": "0",
183
+ },
184
+ timeout=15,
185
+ )
186
+ sonar["cache"] = [{"key": "sonar", "paths": [".sonar/cache"]}]
187
+ jobs[member.job("sonar")] = sonar
188
+
189
+ if cfg.build.enabled:
190
+ items = custom.extend(cfg, "build", [*framework_ci.before_build(cfg), *ci.build(cfg)])
191
+ kept = {"paths": [cfg.path(p) for p in cfg.build.artifacts], "expire_in": "1 week"}
192
+ jobs[member.job("build")] = project_job(
193
+ items, setup=setup, caches=caches, timeout=15, artifacts=kept if cfg.build.artifacts else None
194
+ )
195
+
196
+ if cfg.build.docker:
197
+ project = member.name or "ci"
198
+ tag = f"{project}:$CI_COMMIT_SHA"
199
+ lint_image, _, scan = image_checks(tag)
200
+ build = registry_cache(project) + f'\ndocker buildx build --load $cache --tag "{tag}" .'
201
+ checks = [lint_image.run, build, scan.run]
202
+ jobs[member.job("docker")] = docker_job("test", checks, timeout=20, directory=directory)
203
+
204
+ if cfg.build.chart:
205
+ jobs[member.job("chart")] = _tool_job(HELM_IMAGE, list(chart_checks(cfg.build.chart)), timeout=10,
206
+ directory=directory)
207
+
208
+ return jobs, _setup_template(cfg, image, setup, caches, env)
209
+
210
+
211
+ def _repo_jobs(repo: Repo) -> Dict[str, Any]:
212
+ jobs: Dict[str, Any] = {}
213
+ if repo.secrets:
214
+ jobs["secrets"] = _secrets_job()
215
+ if repo.sast:
216
+ jobs["sast"] = _sast_job()
217
+ return jobs
218
+
219
+
220
+ def _secrets_job() -> Dict[str, Any]:
221
+ return _tool_job(
222
+ GITLEAKS_IMAGE,
223
+ # The runner's clone belongs to another user than the image's: let git read it.
224
+ [step("Trust the clone", run='git config --global --add safe.directory "$CI_PROJECT_DIR"'), secret_scan()],
225
+ variables={"GIT_DEPTH": "0"},
226
+ timeout=10,
227
+ )
228
+
229
+
230
+ def _sast_job() -> Dict[str, Any]:
231
+ sast_job = _tool_job(SEMGREP_IMAGE, [sast(report=SAST_REPORT)], timeout=15)
232
+ # Findings in the merge request's security widget (GitLab Ultimate); the job fails on them on every tier.
233
+ sast_job["artifacts"] = {"when": "always", "reports": {"sast": SAST_REPORT}}
234
+ return sast_job
235
+
236
+
237
+ def _on_changes(j: Dict[str, Any], member: Member) -> Dict[str, Any]:
238
+ """`j` only when the member's files (or what every member shares) changed. Tags, schedules and manual runs
239
+ count everything as changed."""
240
+ paths = {"paths": [f"{member.directory}/**/*", *_SHARED_PATHS]} if member.directory != "." else None
241
+ if paths is None:
242
+ return j
243
+ rules = [{**rule, "changes": paths} for rule in j.get("rules", [{"when": "on_success"}])]
244
+ rest = {k: v for k, v in j.items() if k not in ("stage", "image", "rules")}
245
+ return {"stage": j["stage"], "image": j["image"], "rules": rules, **rest}
246
+
247
+
248
+ # Whatever touches every member: the workspace's quoll.toml and the CI itself.
249
+ _SHARED_PATHS = ["quoll.toml", ".gitlab-ci.yml", ".gitlab/**/*"]
250
+
251
+
252
+ def _template_name(member: Member) -> str:
253
+ return f"{SETUP_TEMPLATE}-{member.name}" if member.name else SETUP_TEMPLATE
254
+
255
+
256
+ def _kind(job_name: str, repo: Repo) -> str:
257
+ """The generated job a (possibly prefixed) job name stands for: backend-test -> test."""
258
+ for member in repo.members:
259
+ if member.name and job_name.startswith(f"{member.name}-"):
260
+ return job_name[len(member.name) + 1 :]
261
+ return job_name
262
+
263
+
264
+ def _framework_env(cfg: CIConfig) -> Dict[str, str]:
265
+ framework = cfg.get_framework()
266
+ return (framework.ci if framework else FrameworkCI()).env(cfg)
267
+
268
+
269
+ def render_root(target: Union[CIConfig, Repo]) -> str:
270
+ """.gitlab-ci.yml: when pipelines run, and what they include. quoll's jobs come first, so that
271
+ .gitlab/custom-ci.yml, included last, can add jobs and override any key of quoll's."""
272
+ repo = target if isinstance(target, Repo) else Repo.single(target)
273
+ doc: Dict[str, Any] = {
274
+ "workflow": {
275
+ "rules": [
276
+ {"if": '$CI_PIPELINE_SOURCE == "merge_request_event"'},
277
+ *(branch_rule(b) for b in repo.branches),
278
+ {"if": "$CI_COMMIT_TAG"},
279
+ {"if": '$CI_PIPELINE_SOURCE == "web"'},
280
+ *([{"if": SCHEDULED}] if repo.schedule.enabled else []),
281
+ ],
282
+ # A new commit cancels the running pipeline of the same branch (the delivery is not interruptible).
283
+ "auto_cancel": {"on_new_commit": "interruptible"},
284
+ },
285
+ "stages": STAGES,
286
+ # A job lost to the infrastructure (runner, timeout, API) runs again; a failing test doesn't (a retry would
287
+ # hide a flaky one).
288
+ "default": {"retry": {"max": 2, "when": list(_INFRASTRUCTURE_FAILURES)}},
289
+ "include": [
290
+ {"local": JOBS_PATH},
291
+ *({"local": path, "rules": [{"exists": [path]}]} for path in (CD_PATH, CUSTOM_PATH)),
292
+ ],
293
+ }
294
+ return header("ci") + dump(doc)
295
+
296
+
297
+ def _setup_template(cfg: CIConfig, image: str, setup: List[Step], caches: list, env: Dict[str, str]) -> Dict[str, Any]:
298
+ """For your own jobs (`extends: .quoll-setup`): the project's image, caches and installed dependencies,
299
+ commands run in its directory."""
300
+ template = job(
301
+ "test",
302
+ image,
303
+ cfg.language.ci.install(cfg),
304
+ setup=setup,
305
+ caches=caches,
306
+ timeout=15,
307
+ variables=env or None,
308
+ directory=cfg.directory,
309
+ )
310
+ template["before_script"] = template.pop("script")
311
+ template.pop("timeout")
312
+ return template
313
+
314
+
315
+ def render_renovate() -> str:
316
+ config = {
317
+ "$schema": "https://docs.renovatebot.com/renovate-schema.json",
318
+ "extends": ["config:recommended"],
319
+ # quoll regenerates .gitlab-ci.yml from quoll.toml: bump the versions there.
320
+ "gitlabci": {"enabled": False},
321
+ }
322
+ return json.dumps(config, indent=2) + "\n"
323
+
324
+
325
+ def plan(project_dir: Path, target: Union[CIConfig, Repo]) -> List[PlannedFile]:
326
+ repo = target if isinstance(target, Repo) else Repo.single(target)
327
+ files = [
328
+ planned(project_dir, PIPELINE_PATH, render_root(repo)),
329
+ planned(project_dir, Path(JOBS_PATH), render_pipeline(repo)),
330
+ ]
331
+ # Renovate config is often customised by hand: only create it, never overwrite it.
332
+ if repo.dependency_updates and not any((project_dir / name).exists() for name in RENOVATE_CONFIGS):
333
+ files.append(planned(project_dir, RENOVATE_PATH, render_renovate(), create_only=True))
334
+ return files
335
+
336
+
337
+ def _test_artifacts(cfg: CIConfig) -> Dict[str, Any]:
338
+ """Test results and coverage for the merge request widgets; the report Codecov uploads."""
339
+ ci = cfg.language.ci
340
+ artifacts: Dict[str, Any] = {}
341
+ reports: Dict[str, Any] = {}
342
+ if cfg.test.reports:
343
+ found = ci.reports(cfg)
344
+ if found.junit:
345
+ reports["junit"] = cfg.path(found.junit)
346
+ if found.cobertura:
347
+ reports["coverage_report"] = {"coverage_format": "cobertura", "path": cfg.path(found.cobertura)}
348
+ if cfg.test.coverage and cfg.test.codecov:
349
+ artifacts["paths"] = [cfg.path(ci.coverage_file(cfg))]
350
+ if reports:
351
+ artifacts["reports"] = reports
352
+ if artifacts:
353
+ artifacts = {"when": "always", **artifacts, "expire_in": "1 week"}
354
+ return artifacts
355
+
356
+
357
+ def _skip_on_schedule(j: Dict[str, Any]) -> Dict[str, Any]:
358
+ rules = [{"if": SCHEDULED, "when": "never"}, *j.get("rules", [{"when": "on_success"}])]
359
+ rest = {k: v for k, v in j.items() if k not in ("stage", "image", "rules")}
360
+ return {"stage": j["stage"], "image": j["image"], "rules": rules, **rest}
361
+
362
+
363
+ def _tool_job(image: str, items: List[Item], **options: Any) -> Dict[str, Any]:
364
+ """A job running in a tool's image, whose entrypoint is the tool itself."""
365
+ j = job("test", image, items, **options)
366
+ j["image"] = {"name": image, "entrypoint": [""]}
367
+ return j
368
+
369
+
370
+ def _services(cfg: CIConfig) -> List[Dict[str, Any]]:
371
+ services = []
372
+ for name in cfg.services:
373
+ service = SERVICES[name]
374
+ entry: Dict[str, Any] = {"name": service.image, "alias": name}
375
+ if service.env:
376
+ entry["variables"] = service.env
377
+ services.append(entry)
378
+ return services
379
+
380
+
381
+ def _export_step(env: Dict[str, str]) -> Step:
382
+ # Services are reachable under their alias.
383
+ return step("Point the app at the services", run="\n".join(f'export {k}="{v}"' for k, v in env.items()))
384
+
385
+
386
+ def _codecov_step(coverage_file: str) -> Item:
387
+ command = 'codecovcli upload-process --fail-on-error --token "$CODECOV_TOKEN"'
388
+ # The CLI takes files, not globs: let it search for the report instead.
389
+ if "*" not in coverage_file:
390
+ command += f" --file {coverage_file}"
391
+ return step("Upload coverage to Codecov", run=f"pip install --quiet codecov-cli\n{command}")
392
+
393
+
394
+ def _sonar_command(cfg: CIConfig) -> str:
395
+ command = f"sonar-scanner -Dsonar.organization={cfg.security.sonar_organization}"
396
+ if cfg.security.sonar_project_key:
397
+ command += f" -Dsonar.projectKey={cfg.security.sonar_project_key}"
398
+ if cfg.directory != ".":
399
+ command += f" -Dsonar.projectBaseDir={cfg.directory}"
400
+ return command
@@ -0,0 +1,136 @@
1
+ """Building blocks of GitLab CI pipelines: neutral steps rendered as container jobs, emitted by quoll.yaml_dump."""
2
+ from __future__ import annotations
3
+
4
+ import re
5
+ from typing import Any, Dict, List, Optional, Sequence
6
+
7
+ from quoll import yaml_dump
8
+ from quoll.ci.steps import ROOT, Cache, Item, Step
9
+
10
+ PROJECT_DIR = "$CI_PROJECT_DIR"
11
+ # Docker-in-Docker, for the jobs building images.
12
+ DOCKER_IMAGE = "docker:29"
13
+ DOCKER_SERVICE = "docker:29-dind"
14
+
15
+
16
+ def job(
17
+ stage: str,
18
+ image: str,
19
+ items: Sequence[Item],
20
+ *,
21
+ setup: Sequence[Step] = (),
22
+ caches: Sequence[Cache] = (),
23
+ timeout: int,
24
+ variables: Optional[Dict[str, str]] = None,
25
+ services: Optional[List[Any]] = None,
26
+ parallel: Optional[Dict[str, Any]] = None,
27
+ needs: Optional[List[Any]] = None,
28
+ rules: Optional[List[Dict[str, Any]]] = None,
29
+ artifacts: Optional[Dict[str, Any]] = None,
30
+ directory: str = ".",
31
+ ) -> Dict[str, Any]:
32
+ """A job running `setup` then `items` in `image`, in the project's `directory`. Steps marked `always` go to
33
+ after_script."""
34
+ # after_script runs in a shell of its own: it moves to the directory too.
35
+ script: List[str] = [] if directory == "." else [f'cd "{directory}"']
36
+ after: List[str] = []
37
+ env: Dict[str, str] = dict(variables or {})
38
+ caches = list(caches)
39
+ for item in [*setup, *items]:
40
+ if isinstance(item, Cache):
41
+ caches.append(item)
42
+ continue
43
+ if item.summary:
44
+ continue
45
+ if not item.run:
46
+ raise ValueError(f"step '{item.name}' has no command to run on GitLab CI")
47
+ env.update(item.env)
48
+ command = item.run
49
+ if item.optional and not item.always:
50
+ # after_script failures never fail the job.
51
+ command = f"{{\n{command}\n}} || echo '{item.name} failed (ignored)'"
52
+ (after if item.always else script).append(command)
53
+
54
+ j: Dict[str, Any] = {"stage": stage, "image": image}
55
+ if rules:
56
+ j["rules"] = rules
57
+ if needs is not None:
58
+ j["needs"] = needs
59
+ j["timeout"] = f"{timeout} minutes"
60
+ if parallel:
61
+ j["parallel"] = parallel
62
+ if services:
63
+ j["services"] = services
64
+ root = PROJECT_DIR if directory == "." else f"{PROJECT_DIR}/{directory}"
65
+ for cache in caches:
66
+ env.update({k: v.replace(ROOT, root) for k, v in cache.env.items()})
67
+ if env:
68
+ j["variables"] = env
69
+ if caches:
70
+ j["cache"] = [_cache(c, directory) for c in caches]
71
+ j["script"] = script
72
+ if after:
73
+ j["after_script"] = ([f'cd "{directory}"'] if directory != "." else []) + after
74
+ if artifacts:
75
+ j["artifacts"] = artifacts
76
+ return j
77
+
78
+
79
+ def _cache(c: Cache, directory: str = ".") -> Dict[str, Any]:
80
+ def within(path: str) -> str:
81
+ return path if directory == "." else f"{directory}/{path}"
82
+
83
+ # Per job (and matrix entry): a virtualenv built for one interpreter must not leak into another.
84
+ prefix = f"{c.key}-$CI_JOB_NAME_SLUG"
85
+ if c.key_files:
86
+ if len(c.key_files) > 2:
87
+ raise ValueError(f"cache '{c.key}' has {len(c.key_files)} key files; GitLab allows two")
88
+ key: Any = {"files": [within(f) for f in c.key_files], "prefix": prefix}
89
+ else:
90
+ key = prefix
91
+ return {"key": key, "paths": [within(p) for p in c.paths]}
92
+
93
+
94
+ def docker_job(stage: str, script: List[str], *, timeout: int, directory: str = ".", **options: Any) -> Dict[str, Any]:
95
+ """A job with a Docker daemon (Docker-in-Docker, over TLS), building from `directory`."""
96
+ if directory != ".":
97
+ script = [f'cd "{directory}"', *script]
98
+ variables = {"DOCKER_TLS_CERTDIR": "/certs", **options.pop("variables", {})}
99
+ j: Dict[str, Any] = {"stage": stage, "image": DOCKER_IMAGE, "services": [DOCKER_SERVICE]}
100
+ j.update({k: v for k, v in options.items() if k in ("rules", "needs", "interruptible", "resource_group")})
101
+ j["timeout"] = f"{timeout} minutes"
102
+ j["variables"] = variables
103
+ j["script"] = script
104
+ return j
105
+
106
+
107
+ def branch_rule(branch: str) -> Dict[str, str]:
108
+ """Rule matching pushes to `branch`, a name or a GitHub-style pattern (release/*, feature/**)."""
109
+ if not any(c in branch for c in "*?["):
110
+ return {"if": f'$CI_COMMIT_BRANCH == "{branch}"'}
111
+ regex = re.escape(branch).replace(r"\*\*", ".*").replace(r"\*", "[^/]*").replace(r"\?", "[^/]")
112
+ regex = regex.replace("/", r"\/")
113
+ return {"if": f"$CI_COMMIT_BRANCH =~ /^{regex}$/"}
114
+
115
+
116
+ def variable_name(version_key: str) -> str:
117
+ """Matrix variable for a language's version key: python-version -> PYTHON_VERSION."""
118
+ return version_key.upper().replace("-", "_")
119
+
120
+
121
+ def dump(doc: Dict[str, Any]) -> str:
122
+ return yaml_dump.dump(doc, scripts=("script", "before_script", "after_script", "if"))
123
+
124
+
125
+ def registry_cache(project: str) -> str:
126
+ """Shell setting $cache to buildx's options caching layers in the project's registry, under cache:<project>,
127
+ between pipelines (and with the delivery). Without access to the registry (a fork, the registry turned off),
128
+ $cache is empty: the image builds from scratch."""
129
+ return f"""cache=""
130
+ if [ -n "${{CI_REGISTRY:-}}" ] && echo "$CI_REGISTRY_PASSWORD" \\
131
+ | docker login -u "$CI_REGISTRY_USER" --password-stdin "$CI_REGISTRY" >/dev/null 2>&1; then
132
+ ref="$CI_REGISTRY_IMAGE/cache:{project}"
133
+ cache="--cache-from type=registry,ref=$ref"
134
+ cache="$cache --cache-to type=registry,ref=$ref,mode=max,image-manifest=true,ignore-error=true"
135
+ fi
136
+ docker buildx create --use --driver docker-container >/dev/null"""
@@ -0,0 +1,54 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import TYPE_CHECKING, List, Optional, Union
5
+
6
+ from quoll.platforms.base import Platform, PlatformAPI, Remote
7
+
8
+ if TYPE_CHECKING:
9
+ from quoll.cd.config import CDConfig
10
+ from quoll.ci.config import CIConfig
11
+ from quoll.files import PlannedFile
12
+ from quoll.workspace import Delivery, Repo
13
+
14
+
15
+ class GitLab(Platform):
16
+ name = "gitlab"
17
+ label = "GitLab"
18
+ secrets_label = "CI/CD variables"
19
+ registries = ("gitlab", "dockerhub")
20
+ schedules_via_api = True
21
+
22
+ def ci_files(self, project_dir: Path, cfg: Union[CIConfig, Repo]) -> List[PlannedFile]:
23
+ from quoll.platforms.gitlab import ci
24
+
25
+ return ci.plan(project_dir, cfg)
26
+
27
+ def cd_files(self, project_dir: Path, cfg: Union[CDConfig, List[Delivery]]) -> List[PlannedFile]:
28
+ from quoll.platforms.gitlab import cd
29
+
30
+ return cd.plan(project_dir, cfg)
31
+
32
+ def default_image(self, remote: Optional[Remote], registry: str, fallback_name: str, member: str = "") -> str:
33
+ path = (remote.path if remote else f"your-group/{fallback_name}").lower()
34
+ if registry == "dockerhub":
35
+ # Docker Hub repositories have a single namespace level.
36
+ parts = path.split("/")
37
+ return f"{parts[0]}/{parts[-1]}" + (f"-{member}" if member else "")
38
+ if member:
39
+ # The project's registry holds one image per member: registry.gitlab.com/group/project/<member>.
40
+ path += f"/{member}"
41
+ # gitlab.com serves its registry on registry.gitlab.com; self-managed instances usually on registry.<host>.
42
+ host = remote.host if remote else "gitlab.com"
43
+ return f"registry.{host}/{path}"
44
+
45
+ def registry_secrets(self, registry: str) -> List[str]:
46
+ return ["DOCKERHUB_USERNAME", "DOCKERHUB_TOKEN"] if registry == "dockerhub" else []
47
+
48
+ def merge_gate(self) -> str:
49
+ return "Protect the branch with 'Pipelines must succeed' (Settings > Merge requests)"
50
+
51
+ def api(self, host: Optional[str] = None) -> PlatformAPI:
52
+ from quoll.platforms.gitlab.api import GitLabAPI
53
+
54
+ return GitLabAPI(f"https://{host}" if host else None)
quoll/project.py ADDED
@@ -0,0 +1,60 @@
1
+ """Read-only view of a project directory, shared by every language plugin."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ from pathlib import Path
6
+ from typing import Any, List
7
+
8
+ try:
9
+ import tomllib
10
+ except ImportError:
11
+ import tomli as tomllib # type: ignore[no-redef]
12
+
13
+
14
+ class Project:
15
+ """File helpers that never raise: missing files read as empty, unparsable ones add a warning."""
16
+
17
+ def __init__(self, root: Path) -> None:
18
+ self.root = root
19
+ self.warnings: List[str] = []
20
+
21
+ @property
22
+ def name(self) -> str:
23
+ return self.root.name
24
+
25
+ def exists(self, *names: str) -> bool:
26
+ return any((self.root / n).exists() for n in names)
27
+
28
+ def is_dir(self, name: str) -> bool:
29
+ return (self.root / name).is_dir()
30
+
31
+ def glob(self, *patterns: str) -> List[Path]:
32
+ return sorted(p for pattern in patterns for p in self.root.glob(pattern))
33
+
34
+ def glob_any(self, *patterns: str) -> bool:
35
+ return any(next(self.root.glob(p), None) is not None for p in patterns)
36
+
37
+ def read_text(self, name: str) -> str:
38
+ try:
39
+ return (self.root / name).read_text()
40
+ except OSError:
41
+ return ""
42
+
43
+ def read_toml(self, name: str) -> dict:
44
+ if not self.exists(name):
45
+ return {}
46
+ try:
47
+ with open(self.root / name, "rb") as f:
48
+ return tomllib.load(f)
49
+ except tomllib.TOMLDecodeError as e:
50
+ self.warnings.append(f"{name} is not valid TOML ({e}) — detection is partial.")
51
+ return {}
52
+
53
+ def read_json(self, name: str) -> Any:
54
+ if not self.exists(name):
55
+ return {}
56
+ try:
57
+ return json.loads(self.read_text(name))
58
+ except ValueError as e:
59
+ self.warnings.append(f"{name} could not be parsed ({e}) — detection is partial.")
60
+ return {}