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,350 @@
1
+ """The contract every language and framework plugin implements.
2
+
3
+ A language is a `Language` instance that bundles:
4
+ - what identifies it (`markers`) and what quoll.toml may contain for it (package managers, tools);
5
+ - `detect`, which inspects a project and returns a `Detection`;
6
+ - `ci`, a `CISteps` implementation that turns the validated [ci] config into platform-neutral steps
7
+ (quoll.ci.steps), which each CI platform renders;
8
+ - its `frameworks`, tried in order once the language is known.
9
+
10
+ A framework (`Framework`) refines the language: it adjusts the detection (test runner, tools...) and
11
+ can add its own lint tools, test runner, environment and steps through a `FrameworkCI`.
12
+
13
+ Both can ship a `DockerImage`; a framework's replaces its language's, and `Starter`s creating new projects
14
+ (`quoll new`).
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import inspect
19
+ import json
20
+ import shlex
21
+ from abc import ABC, abstractmethod
22
+ from dataclasses import dataclass, field
23
+ from pathlib import Path
24
+ from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, Sequence, Tuple
25
+
26
+ from jinja2 import Environment, FileSystemLoader, PrefixLoader, StrictUndefined
27
+
28
+ from quoll.ci.steps import Cache, Item, Step
29
+ from quoll.platforms.base import Remote
30
+ from quoll.project import Project
31
+ from quoll.services import ServiceEnv, no_service_env
32
+
33
+ if TYPE_CHECKING:
34
+ from quoll.ci.config import CIConfig
35
+ from quoll.docker.config import DockerConfig
36
+
37
+ __all__ = ["Cache", "Item", "Step"]
38
+
39
+ # Languages with `scripts=True` also accept "script:<name>" as a lint tool or test runner.
40
+ SCRIPT_PREFIX = "script:"
41
+
42
+
43
+ @dataclass
44
+ class Detection:
45
+ """What quoll inferred about a project. Languages fill the toolchain fields; quoll fills the rest."""
46
+
47
+ stack: Optional[str] = None
48
+ framework: str = ""
49
+ package_manager: str = ""
50
+ package_manager_version: str = ""
51
+ lockfile: str = ""
52
+ install: str = ""
53
+ cache: bool = True
54
+ version: str = ""
55
+ version_file: str = ""
56
+ matrix_versions: List[str] = field(default_factory=list)
57
+ source_dir: str = "."
58
+ lint_tools: List[str] = field(default_factory=list)
59
+ test_runner: str = ""
60
+ coverage: bool = True
61
+ # The language can run its tests under sanitizers (C/C++).
62
+ sanitizers: bool = False
63
+ # A dependency audit exists for the project (e.g. a lock file the scanner reads).
64
+ audit: bool = True
65
+ build_command: str = ""
66
+ # Backing services the app uses (see quoll.services), from its client libraries.
67
+ services: List[str] = field(default_factory=list)
68
+ # Filled by quoll, not by languages.
69
+ has_dockerfile: bool = False
70
+ # Directory of the project's Helm chart ([k8s] in quoll.toml).
71
+ chart: str = ""
72
+ env_vars: Dict[str, str] = field(default_factory=dict)
73
+ default_branch: str = "main"
74
+ platform: str = "github"
75
+ remote: Optional[Remote] = None
76
+ other_stacks: List[str] = field(default_factory=list)
77
+ warnings: List[str] = field(default_factory=list)
78
+
79
+
80
+ class CISteps(ABC):
81
+ """CI steps for one language. `cfg` is the validated [ci] section of quoll.toml.
82
+
83
+ The toolchain comes from `setup` on GitHub Actions (setup actions on a bare runner) and from `image` plus
84
+ `image_setup` on GitLab CI (jobs run in containers). Every other method returns steps for both: a step that
85
+ uses a GitHub Action must also give the equivalent `run` command.
86
+ """
87
+
88
+ # Matrix variable, e.g. "python-version".
89
+ version_key: str = ""
90
+ # False when the audit action installs the toolchain itself (GitHub).
91
+ audit_needs_setup: bool = True
92
+ # False when the audit only reads the lockfile: the toolchain's dependency cache would have nothing to save.
93
+ audit_uses_cache: bool = True
94
+ # CodeQL language (https://codeql.github.com/docs/codeql-overview/supported-languages-and-frameworks/), empty
95
+ # when CodeQL doesn't support it; "autobuild" when the code must be compiled to be analyzed.
96
+ codeql_language: str = ""
97
+ codeql_build_mode: str = "none"
98
+
99
+ @abstractmethod
100
+ def setup(self, cfg: CIConfig, version: Optional[str]) -> List[Item]:
101
+ """GitHub Actions steps installing the toolchain. `version` is None when read from `cfg.version_file`."""
102
+
103
+ @abstractmethod
104
+ def image(self, cfg: CIConfig, version: str) -> str:
105
+ """Container image providing the toolchain at `version` (which may be a variable such as $NODE_VERSION)."""
106
+
107
+ def image_setup(self, cfg: CIConfig) -> List[Step]:
108
+ """Commands completing `image` before the job's steps (package manager, components...)."""
109
+ return []
110
+
111
+ def cache(self, cfg: CIConfig) -> Optional[Cache]:
112
+ """Dependency cache of container jobs; on GitHub, `setup` caches."""
113
+ return None
114
+
115
+ @abstractmethod
116
+ def install(self, cfg: CIConfig, packages: Sequence[str] = ()) -> List[Item]:
117
+ """Install the project dependencies, plus extra CI-only `packages` where the language supports it."""
118
+
119
+ def exec(self, cfg: CIConfig, command: str) -> str:
120
+ """`command` run with the project's installed tools (e.g. `uv run --no-sync`, `npx`)."""
121
+ return command
122
+
123
+ @abstractmethod
124
+ def lint(self, cfg: CIConfig, tools: List[str]) -> List[Item]:
125
+ """Install dependencies and run `tools`, a subset of the language's lint tools."""
126
+
127
+ @abstractmethod
128
+ def test(self, cfg: CIConfig) -> List[Item]:
129
+ """Install dependencies and run the tests, enforcing the coverage threshold when enabled."""
130
+
131
+ @abstractmethod
132
+ def coverage_file(self, cfg: CIConfig) -> str:
133
+ """Coverage report uploaded to Codecov (a glob for some languages)."""
134
+
135
+ def reports(self, cfg: CIConfig) -> TestReports:
136
+ """What the test steps leave for the platform to show, when `cfg.test.reports` is on."""
137
+ return TestReports()
138
+
139
+ @abstractmethod
140
+ def audit(self, cfg: CIConfig) -> List[Item]:
141
+ """Scan dependencies for known vulnerabilities."""
142
+
143
+ @abstractmethod
144
+ def build(self, cfg: CIConfig) -> List[Item]:
145
+ """Run `cfg.build.command`."""
146
+
147
+ def build_artifacts(self, det: Detection) -> List[str]:
148
+ """What the detected build command produces, for the build job to keep (nothing if not a distributable)."""
149
+ return []
150
+
151
+ @abstractmethod
152
+ def dependabot_ecosystem(self, cfg: CIConfig) -> str:
153
+ """Dependabot `package-ecosystem` for the project's dependencies."""
154
+
155
+
156
+ # Runtime images on Debian or Ubuntu: the security fixes released since the base image was built (the image scan
157
+ # fails on them otherwise, and rebuilding picks up the next ones).
158
+ OS_UPDATES = """# Security fixes released since the base image was built.
159
+ RUN apt-get update \\
160
+ && DEBIAN_FRONTEND=noninteractive apt-get upgrade -y \\
161
+ && rm -rf /var/lib/apt/lists/*"""
162
+
163
+ # The line printed by quoll's own coverage threshold checks: "Coverage 85.0% (threshold 80%)".
164
+ THRESHOLD_REGEX = r"/Coverage (\d+(?:\.\d+)?)% \(threshold/"
165
+
166
+
167
+ @dataclass(frozen=True)
168
+ class TestReports:
169
+ __test__ = False # not a pytest test class
170
+
171
+ # JUnit XML results (a glob).
172
+ junit: str = ""
173
+ # Cobertura XML coverage, shown on the diff of merge requests (GitLab).
174
+ cobertura: str = ""
175
+ # Regex extracting the total coverage from the job log (GitLab badge and MR widget).
176
+ coverage_regex: str = ""
177
+
178
+
179
+ class FrameworkCI:
180
+ """Optional framework hooks. The defaults add nothing, so a framework only overrides what it changes."""
181
+
182
+ def env(self, cfg: CIConfig) -> Dict[str, str]:
183
+ """Environment variables for the whole workflow."""
184
+ return {}
185
+
186
+ def lint(self, cfg: CIConfig, tool: str) -> List[Item]:
187
+ """Steps for one of the framework's lint tools, run after the language's (dependencies installed)."""
188
+ raise NotImplementedError(f"{type(self).__name__} declares lint tool '{tool}' but does not implement it")
189
+
190
+ def test(self, cfg: CIConfig) -> List[Item]:
191
+ """Full test steps when `cfg.test.runner` is one of the framework's test runners."""
192
+ raise NotImplementedError(f"{type(self).__name__} declares a test runner but does not implement it")
193
+
194
+ def before_build(self, cfg: CIConfig) -> List[Item]:
195
+ """Steps before the build (e.g. a framework build cache)."""
196
+ return []
197
+
198
+
199
+ @dataclass
200
+ class DockerDefaults:
201
+ """What an image infers from the project; becomes the [docker] section of quoll.toml."""
202
+
203
+ port: int
204
+ command: str = ""
205
+ build_command: str = ""
206
+ output_dir: str = ""
207
+ static: bool = False
208
+ warnings: List[str] = field(default_factory=list)
209
+
210
+
211
+ class DockerImage(ABC):
212
+ """A Dockerfile, rendered from the `Dockerfile.j2` next to the class (or its closest parent's).
213
+
214
+ Images inherit like their classes. A framework image subclasses its language's: it overrides `defaults`
215
+ (port, command) or `context`, and when the image itself differs, ships a Dockerfile.j2 that extends the
216
+ language's and overrides only the stages (blocks) that change:
217
+
218
+ {% extends "node/Dockerfile.j2" %}
219
+ {% block app_files %}...{% endblock %}
220
+
221
+ Templates are addressed as "<directory>/Dockerfile.j2", e.g. "node/Dockerfile.j2".
222
+ """
223
+
224
+ # False when the base image already runs a server (e.g. FrankenPHP).
225
+ requires_command: bool = True
226
+
227
+ @abstractmethod
228
+ def defaults(self, project: Project, det: Detection) -> DockerDefaults:
229
+ """Port, start command and build settings inferred from the project."""
230
+
231
+ def context(self, project: Project, cfg: DockerConfig) -> Dict[str, Any]:
232
+ """Template variables besides `cfg`."""
233
+ return {}
234
+
235
+ def dockerignore(self, cfg: DockerConfig) -> List[str]:
236
+ """Patterns to keep out of the build context, on top of the common ones."""
237
+ return []
238
+
239
+ def writable_paths(self, cfg: DockerConfig) -> Optional[List[str]]:
240
+ """Directories the app writes to: Kubernetes mounts them on a read-only root filesystem. None: the app
241
+ writes in its own tree, the root filesystem stays writable."""
242
+ return ["/tmp"]
243
+
244
+ def render(self, project: Project, cfg: DockerConfig) -> str:
245
+ dirs = self._template_dirs()
246
+ env = Environment(
247
+ loader=PrefixLoader({d.name: FileSystemLoader(str(d)) for d in dirs}),
248
+ keep_trailing_newline=True,
249
+ trim_blocks=True,
250
+ lstrip_blocks=True,
251
+ undefined=StrictUndefined,
252
+ )
253
+ env.filters["exec_form"] = exec_form
254
+ env.globals["os_updates"] = OS_UPDATES
255
+ return env.get_template(f"{dirs[0].name}/Dockerfile.j2").render(cfg=cfg, **self.context(project, cfg))
256
+
257
+ def _template_dirs(self) -> List[Path]:
258
+ """Directories holding a Dockerfile.j2, from this class up to its oldest ancestor."""
259
+ dirs: List[Path] = []
260
+ for cls in type(self).__mro__:
261
+ if cls in (DockerImage, ABC, object):
262
+ continue
263
+ directory = Path(inspect.getfile(cls)).parent
264
+ if (directory / "Dockerfile.j2").exists() and directory not in dirs:
265
+ dirs.append(directory)
266
+ return dirs
267
+
268
+
269
+ def exec_form(command: str) -> str:
270
+ """JSON (exec) form of a command, so the process receives signals; through `sh -c` if it needs a shell."""
271
+ if any(c in command for c in "$&|;<>*`") or "\n" in command:
272
+ return json.dumps(["sh", "-c", command])
273
+ return json.dumps(shlex.split(command))
274
+
275
+
276
+ @dataclass(frozen=True)
277
+ class StarterOptions:
278
+ # Package name of the new project.
279
+ name: str
280
+ # Version of the framework, e.g. "18" for Angular 18 (empty: the latest).
281
+ version: str = ""
282
+ # Version of the language's runtime, e.g. "20" for Node.js 20 (empty: the starter's default).
283
+ runtime: str = ""
284
+
285
+
286
+ @dataclass(frozen=True)
287
+ class Starter:
288
+ """Creates a new project in an empty directory (see quoll.starters for templates and generators)."""
289
+
290
+ # Key used on the command line: `quoll new --front angular` (`angular@18` for a version).
291
+ name: str
292
+ label: str
293
+ # Where it fits in a front-end + back-end repository: "front" or "back".
294
+ role: str
295
+ # (directory, options) -> warnings for the user (e.g. an unsupported version). The directory exists, empty.
296
+ create: Callable[[Path, StarterOptions], Optional[List[str]]]
297
+ # The runtime `runtime` options are for: `quoll new --node 20`.
298
+ runtime: str = ""
299
+ runtime_default: str = ""
300
+ # Front-ends: (directory, path, url) makes the dev server forward `path` (e.g. "/api") to the API at `url`, as
301
+ # nginx does in the image ([docker] proxy).
302
+ proxy: Optional[Callable[[Path, str, str], None]] = None
303
+
304
+
305
+ @dataclass(frozen=True)
306
+ class Framework:
307
+ name: str
308
+ # Name of the language it belongs to.
309
+ language: str
310
+ # Returns True when the framework is used, and may refine the detection (tools, test runner...).
311
+ detect: Callable[[Project, Detection], bool]
312
+ ci: FrameworkCI = field(default_factory=FrameworkCI)
313
+ lint_tools: Tuple[str, ...] = ()
314
+ test_runners: Tuple[str, ...] = ()
315
+ # Replaces the language's image when set.
316
+ docker: Optional[DockerImage] = None
317
+ # Extra variables pointing the app at a service, e.g. Laravel's DB_HOST.
318
+ service_env: ServiceEnv = no_service_env
319
+ starters: Tuple[Starter, ...] = ()
320
+
321
+
322
+ @dataclass(frozen=True)
323
+ class Language:
324
+ name: str
325
+ # Any of these files (or glob patterns) at the project root identifies the language.
326
+ markers: Tuple[str, ...]
327
+ package_managers: Tuple[str, ...]
328
+ lint_tools: Tuple[str, ...]
329
+ test_runners: Tuple[str, ...]
330
+ detect: Callable[[Project], Detection]
331
+ ci: CISteps
332
+ # Tried in order: the first whose detect() returns True is the project's framework.
333
+ frameworks: Tuple[Framework, ...] = ()
334
+ scripts: bool = False
335
+ docker: Optional[DockerImage] = None
336
+ # Extra variables pointing the app at a service, e.g. Spring's datasource URL.
337
+ service_env: ServiceEnv = no_service_env
338
+ starters: Tuple[Starter, ...] = ()
339
+
340
+ def matches(self, project: Project) -> bool:
341
+ return project.glob_any(*self.markers)
342
+
343
+ def is_lint_tool(self, value: str) -> bool:
344
+ return value in self.lint_tools or self._is_script(value)
345
+
346
+ def is_test_runner(self, value: str) -> bool:
347
+ return value in self.test_runners or self._is_script(value)
348
+
349
+ def _is_script(self, value: str) -> bool:
350
+ return self.scripts and value.startswith(SCRIPT_PREFIX) and len(value) > len(SCRIPT_PREFIX)
@@ -0,0 +1,37 @@
1
+ {% block build %}
2
+ FROM gcc:{{ gcc_tag }} AS build
3
+ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
4
+ --mount=type=cache,target=/var/lib/apt,sharing=locked \
5
+ apt-get update \
6
+ && apt-get install -y --no-install-recommends {{ packages }}
7
+ WORKDIR /src
8
+ COPY . .
9
+ {% if conan %}
10
+ # Conan resolves the dependencies; its toolchain file points CMake at them.
11
+ RUN --mount=type=cache,target=/root/.conan2 \
12
+ pipx run conan profile detect --exist-ok \
13
+ && pipx run conan install . --output-folder=build --build=missing -s build_type=Release
14
+ ENV CMAKE_TOOLCHAIN_FILE=/src/build/conan_toolchain.cmake
15
+ {% endif %}
16
+ {% if vcpkg %}
17
+ RUN git clone --depth 1 https://github.com/microsoft/vcpkg /opt/vcpkg \
18
+ && /opt/vcpkg/bootstrap-vcpkg.sh -disableMetrics
19
+ ENV VCPKG_ROOT=/opt/vcpkg \
20
+ CMAKE_TOOLCHAIN_FILE=/opt/vcpkg/scripts/buildsystems/vcpkg.cmake
21
+ {% endif %}
22
+ # libstdc++ and libgcc linked statically: the runtime image only needs glibc.
23
+ ENV LDFLAGS="-static-libstdc++ -static-libgcc"
24
+ RUN {% if vcpkg %}--mount=type=cache,target=/root/.cache/vcpkg {% endif %}{{ cfg.build_command }} \
25
+ && mkdir -p /out \
26
+ && cp "$(find build -type f -name '{{ target }}' -perm -u+x | head -n 1)" /out/app
27
+ {% endblock %}
28
+
29
+ {% block runtime %}
30
+ # distroless/cc: glibc only, no shell, no package manager, non-root.
31
+ FROM gcr.io/distroless/cc-debian13:nonroot
32
+ COPY --from=build /out/app /app
33
+ # distroless' nonroot, numeric: Kubernetes can then check it isn't root (runAsNonRoot).
34
+ USER 65532:65532
35
+ EXPOSE {{ cfg.port }}
36
+ CMD {{ cfg.command | exec_form }}
37
+ {% endblock %}
@@ -0,0 +1,17 @@
1
+ """C and C++: CMake (plain, Conan or vcpkg) and Meson."""
2
+
3
+ from quoll.languages.base import Language
4
+ from quoll.languages.cpp.ci import CppCI
5
+ from quoll.languages.cpp.detect import detect
6
+ from quoll.languages.cpp.docker import CppImage
7
+
8
+ language = Language(
9
+ name="cpp",
10
+ markers=("CMakeLists.txt", "meson.build"),
11
+ package_managers=("cmake", "conan", "vcpkg", "meson"),
12
+ lint_tools=("clang-format", "clang-tidy", "cppcheck"),
13
+ test_runners=("ctest", "meson"),
14
+ detect=detect,
15
+ ci=CppCI(),
16
+ docker=CppImage(),
17
+ )
@@ -0,0 +1,192 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING, List, Optional, Sequence
4
+
5
+ from quoll.ci.steps import Item, Step, step
6
+ from quoll.ci.tools import install_osv_scanner
7
+ from quoll.languages.base import CISteps, TestReports
8
+
9
+ if TYPE_CHECKING:
10
+ from quoll.ci.config import CIConfig
11
+
12
+ # Ubuntu 24.04 on both platforms (GitHub's runner, GitLab's image): the toolchain comes from its packages.
13
+ IMAGE = "ubuntu:24.04"
14
+ SOURCES = "'*.c' '*.cc' '*.cpp' '*.cxx' '*.h' '*.hh' '*.hpp' '*.hxx'"
15
+ COVERAGE_REGEX = r"/^TOTAL\s+\d+\s+\d+\s+(\d+(?:\.\d+)?)%/"
16
+
17
+ # Installs "<gcc|clang>-<major>" and exports CC, CXX and GCOV (gcovr reads clang's coverage through llvm-cov),
18
+ # for the next steps too on GitHub.
19
+ _TOOLCHAIN = """compiler="{compiler}"
20
+ case "$compiler" in
21
+ gcc-*)
22
+ v="${{compiler#gcc-}}"; packages="gcc-$v g++-$v"; cc="gcc-$v"; cxx="g++-$v"; gcov="gcov-$v" ;;
23
+ clang-*)
24
+ v="${{compiler#clang-}}"; packages="clang-$v llvm-$v"; cc="clang-$v"; cxx="clang++-$v"; gcov="llvm-cov-$v gcov" ;;
25
+ *) echo "Unknown compiler '$compiler' (expected gcc-<major> or clang-<major>)"; exit 1 ;;
26
+ esac
27
+ sudo=""; [ "$(id -u)" -eq 0 ] || sudo=sudo
28
+ $sudo apt-get update -qq
29
+ # shellcheck disable=SC2086 # $packages lists several packages
30
+ $sudo env DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends \\
31
+ $packages {tools}
32
+ export CC="$cc" CXX="$cxx" GCOV="$gcov"
33
+ if [ -n "${{GITHUB_ENV:-}}" ]; then printf 'CC=%s\\nCXX=%s\\nGCOV=%s\\n' "$cc" "$cxx" "$gcov" >> "$GITHUB_ENV"; fi"""
34
+
35
+ # vcpkg is preinstalled on GitHub's runners; elsewhere it is bootstrapped once per job.
36
+ _VCPKG = """if [ -z "${VCPKG_ROOT:-}" ]; then
37
+ if [ -n "${VCPKG_INSTALLATION_ROOT:-}" ]; then
38
+ VCPKG_ROOT="$VCPKG_INSTALLATION_ROOT"
39
+ else
40
+ git clone --quiet --depth 1 https://github.com/microsoft/vcpkg "$HOME/vcpkg"
41
+ "$HOME/vcpkg/bootstrap-vcpkg.sh" -disableMetrics
42
+ VCPKG_ROOT="$HOME/vcpkg"
43
+ fi
44
+ fi"""
45
+
46
+ _CLANG_FORMAT = f"git ls-files -z {SOURCES} | xargs -0 -r clang-format --dry-run --Werror"
47
+ # Sources of the project only: FetchContent dependencies are built under build/.
48
+ _CLANG_TIDY = 'run-clang-tidy -p build -quiet "^$PWD/(?!build/)"'
49
+ _CPPCHECK = (
50
+ "cppcheck --project=build/compile_commands.json -ibuild --enable=warning,style,performance,portability "
51
+ "--inline-suppr --suppress=missingIncludeSystem --error-exitcode=1 --quiet"
52
+ )
53
+
54
+
55
+ class CppCI(CISteps):
56
+ version_key = "compiler"
57
+ codeql_language = "c-cpp"
58
+ # CodeQL watches our own configure + build (dependencies included).
59
+ codeql_build_mode = "manual"
60
+
61
+ def setup(self, cfg: CIConfig, version: Optional[str]) -> List[Item]:
62
+ return [self._toolchain(cfg, version or cfg.versions[-1])]
63
+
64
+ def image(self, cfg: CIConfig, version: str) -> str:
65
+ return IMAGE
66
+
67
+ def image_setup(self, cfg: CIConfig) -> List[Step]:
68
+ # In a matrix job, COMPILER is the entry's.
69
+ return [self._toolchain(cfg, f"${{COMPILER:-{cfg.versions[-1]}}}")]
70
+
71
+ def install(self, cfg: CIConfig, packages: Sequence[str] = ()) -> List[Item]:
72
+ return [configure(cfg)]
73
+
74
+ def lint(self, cfg: CIConfig, tools: List[str]) -> List[Item]:
75
+ steps: List[Item] = []
76
+ if "clang-format" in tools:
77
+ steps.append(step("clang-format", run=_CLANG_FORMAT))
78
+ if "clang-tidy" in tools or "cppcheck" in tools:
79
+ # Both read the compilation database written by the configure step.
80
+ steps.append(configure(cfg))
81
+ if "clang-tidy" in tools:
82
+ steps.append(step("clang-tidy", run=_CLANG_TIDY))
83
+ if "cppcheck" in tools:
84
+ steps.append(step("cppcheck", run=_CPPCHECK))
85
+ return steps
86
+
87
+ def test(self, cfg: CIConfig) -> List[Item]:
88
+ t = cfg.test
89
+ meson = cfg.package_manager == "meson"
90
+ env = {}
91
+ if t.sanitizers:
92
+ env = {"ASAN_OPTIONS": "detect_leaks=1:abort_on_error=1", "UBSAN_OPTIONS": "print_stacktrace=1"}
93
+ if meson:
94
+ run = "meson compile -C build\nmeson test -C build --print-errorlogs"
95
+ else:
96
+ run = "cmake --build build\nctest --test-dir build --output-on-failure"
97
+ if t.reports:
98
+ run += " --output-junit junit.xml"
99
+ name = "Tests" + (" with sanitizers" if t.sanitizers else "")
100
+ steps: List[Item] = [configure(cfg, instrumented=True), step(name, run=run, env=env, secrets=t.env_secrets)]
101
+ if t.coverage:
102
+ command = (
103
+ 'gcovr --root . --exclude build/ --exclude "(.+/)?tests?/" --gcov-executable "$GCOV" '
104
+ f"--txt --cobertura coverage.xml --fail-under-line {t.coverage_threshold}"
105
+ )
106
+ steps.append(step("Coverage threshold", run=command))
107
+ return steps
108
+
109
+ def coverage_file(self, cfg: CIConfig) -> str:
110
+ return "coverage.xml"
111
+
112
+ def reports(self, cfg: CIConfig) -> TestReports:
113
+ junit = "build/meson-logs/testlog.junit.xml" if cfg.package_manager == "meson" else "build/junit.xml"
114
+ coverage = cfg.test.coverage
115
+ return TestReports(
116
+ junit=junit,
117
+ cobertura="coverage.xml" if coverage else "",
118
+ coverage_regex=COVERAGE_REGEX if coverage else "",
119
+ )
120
+
121
+ def audit(self, cfg: CIConfig) -> List[Item]:
122
+ scan = step("Dependency vulnerability scan", run="osv-scanner scan source -L conan.lock")
123
+ return [install_osv_scanner(), scan]
124
+
125
+ def build(self, cfg: CIConfig) -> List[Item]:
126
+ default = "meson compile -C build" if cfg.package_manager == "meson" else "cmake --build build"
127
+ return [configure(cfg, release=True), step("Build", run=cfg.build.command or default)]
128
+
129
+ def dependabot_ecosystem(self, cfg: CIConfig) -> str:
130
+ # Dependabot has no ecosystem for CMake, Conan or Meson.
131
+ return ""
132
+
133
+ def _toolchain(self, cfg: CIConfig, compiler: str) -> Step:
134
+ tools = ["ca-certificates", "git", "ninja-build"]
135
+ tools.append("meson" if cfg.package_manager == "meson" else "cmake")
136
+ if cfg.package_manager == "conan":
137
+ tools.append("pipx")
138
+ if cfg.package_manager == "vcpkg":
139
+ tools += ["curl", "zip", "unzip", "tar", "pkg-config"]
140
+ if cfg.lint.enabled:
141
+ tools += [{"clang-format": "clang-format", "clang-tidy": "clang-tidy", "cppcheck": "cppcheck"}[t]
142
+ for t in cfg.lint.tools if t in ("clang-format", "clang-tidy", "cppcheck")]
143
+ if cfg.test.enabled and cfg.test.coverage:
144
+ tools.append("gcovr")
145
+ if cfg.security.audit:
146
+ tools.append("curl")
147
+ tools_list = " ".join(dict.fromkeys(tools))
148
+ if "${{" in compiler:
149
+ # An expression (the setup action's input): through the environment, never pasted into the script.
150
+ run = _TOOLCHAIN.format(compiler="$QUOLL_COMPILER", tools=tools_list)
151
+ return step("Set up the toolchain", run=run, env={"QUOLL_COMPILER": compiler})
152
+ return step("Set up the toolchain", run=_TOOLCHAIN.format(compiler=compiler, tools=tools_list))
153
+
154
+
155
+ def configure(cfg: CIConfig, instrumented: bool = False, release: bool = False) -> Step:
156
+ """Configure the build in build/ (compilation database included), resolving the dependencies.
157
+
158
+ `instrumented` adds coverage and sanitizers (tests); `release` builds optimized (build job).
159
+ """
160
+ t = cfg.test
161
+ flags: List[str] = []
162
+ if instrumented and t.coverage:
163
+ flags.append("--coverage")
164
+ if instrumented and t.sanitizers:
165
+ flags.append("-fsanitize=address,undefined -fno-sanitize-recover=all -fno-omit-frame-pointer")
166
+ build_type = "Release" if release else "Debug"
167
+
168
+ if cfg.package_manager == "meson":
169
+ options = [f"--buildtype={build_type.lower()}"]
170
+ if instrumented and t.coverage:
171
+ options.append("-Db_coverage=true")
172
+ if instrumented and t.sanitizers:
173
+ options.append("-Db_sanitize=address,undefined -Dc_args=-fno-sanitize-recover=all "
174
+ "-Dcpp_args=-fno-sanitize-recover=all")
175
+ return step("Configure", run=f"meson setup build {' '.join(options)}")
176
+
177
+ lines: List[str] = []
178
+ args = [f"-DCMAKE_BUILD_TYPE={build_type}", "-DCMAKE_EXPORT_COMPILE_COMMANDS=ON"]
179
+ if flags:
180
+ joined = " ".join(flags)
181
+ args += [f'-DCMAKE_C_FLAGS="{joined}"', f'-DCMAKE_CXX_FLAGS="{joined}"']
182
+ if cfg.package_manager == "conan":
183
+ lines += [
184
+ "pipx run conan profile detect --exist-ok",
185
+ f"pipx run conan install . --output-folder=build --build=missing -s build_type={build_type}",
186
+ ]
187
+ args.append("-DCMAKE_TOOLCHAIN_FILE=build/conan_toolchain.cmake")
188
+ elif cfg.package_manager == "vcpkg":
189
+ lines.append(_VCPKG)
190
+ args.append('-DCMAKE_TOOLCHAIN_FILE="$VCPKG_ROOT/scripts/buildsystems/vcpkg.cmake"')
191
+ lines.append(f"cmake -S . -B build -G Ninja {' '.join(args)}")
192
+ return step("Configure", run="\n".join(lines))