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,84 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from typing import List
5
+
6
+ from quoll.languages.base import Detection
7
+ from quoll.project import Project
8
+ from quoll.services import detect_services
9
+
10
+ # Toolchains, as "<compiler>-<major>" (packages of the Ubuntu 24.04 runners and images).
11
+ COMPILER_DEFAULT = "gcc-14"
12
+ COMPILERS = ("gcc-14", "clang-18")
13
+
14
+ # Libraries (CMake packages, vcpkg ports, Conan references) revealing a backing service.
15
+ _SERVICE_LIBRARIES = {
16
+ "postgres": ("libpq", "libpqxx", "postgresql", "pq"),
17
+ "mysql": ("libmysqlclient", "mysqlclient", "mariadb-connector-c", "mysql-connector-cpp"),
18
+ "redis": ("hiredis", "redis-plus-plus"),
19
+ "mongodb": ("mongo-cxx-driver", "mongocxx", "mongo-c-driver"),
20
+ }
21
+ # What makes CTest run tests.
22
+ _CTEST = re.compile(
23
+ r"\b(enable_testing|add_test|include\s*\(\s*CTest|gtest_discover_tests|catch_discover_tests)\b", re.I
24
+ )
25
+
26
+
27
+ def detect(project: Project) -> Detection:
28
+ det = Detection(version=COMPILER_DEFAULT, matrix_versions=list(COMPILERS))
29
+ meson = project.exists("meson.build") and not project.exists("CMakeLists.txt")
30
+ if meson:
31
+ det.package_manager = "meson"
32
+ elif project.glob_any("conanfile.txt", "conanfile.py"):
33
+ det.package_manager = "conan"
34
+ det.lockfile = "conan.lock" if project.exists("conan.lock") else ""
35
+ elif project.exists("vcpkg.json"):
36
+ det.package_manager = "vcpkg"
37
+ else:
38
+ det.package_manager = "cmake"
39
+ # Dependencies are resolved while configuring the build (FetchContent, Conan, vcpkg, wraps).
40
+ det.install = "meson setup build" if meson else "cmake -S . -B build"
41
+
42
+ det.lint_tools = [tool for tool, config in (("clang-format", ".clang-format"), ("clang-tidy", ".clang-tidy"))
43
+ if project.exists(config)]
44
+ det.lint_tools.append("cppcheck")
45
+
46
+ build_files = cmake_files(project) if not meson else project.read_text("meson.build")
47
+ if meson and re.search(r"\btest\s*\(", build_files):
48
+ det.test_runner = "meson"
49
+ elif not meson and _CTEST.search(build_files):
50
+ det.test_runner = "ctest"
51
+ else:
52
+ det.warnings.append("No tests found (enable_testing()/add_test() or meson test()) — the test job is disabled.")
53
+ det.sanitizers = True
54
+
55
+ # osv-scanner reads conan.lock; there is no vulnerability database for other C/C++ dependencies.
56
+ det.audit = bool(det.lockfile)
57
+ if not det.audit:
58
+ det.warnings.append("No conan.lock: no dependency audit for C/C++ (Semgrep and the image scan still run).")
59
+
60
+ manifests = " ".join([build_files, project.read_text("vcpkg.json"), project.read_text("conanfile.txt"),
61
+ project.read_text("conanfile.py")]).lower()
62
+ det.services = detect_services(
63
+ [lib for libs in _SERVICE_LIBRARIES.values() for lib in libs if re.search(rf"\b{re.escape(lib)}\b", manifests)],
64
+ _SERVICE_LIBRARIES,
65
+ )
66
+ det.build_command = "meson compile -C build" if meson else "cmake --build build"
67
+ return det
68
+
69
+
70
+ def cmake_files(project: Project) -> str:
71
+ """The root CMakeLists.txt and those of the first-level directories (src/, tests/...)."""
72
+ nested = (p.relative_to(project.root).as_posix() for p in project.glob("*/CMakeLists.txt"))
73
+ paths: List[str] = ["CMakeLists.txt", *nested]
74
+ return "\n".join(project.read_text(p) for p in paths)
75
+
76
+
77
+ def executable(project: Project) -> str:
78
+ """The first executable target that isn't a test."""
79
+ if project.exists("meson.build"):
80
+ pattern, text = r"executable\s*\(\s*['\"]([\w.-]+)['\"]", project.read_text("meson.build")
81
+ else:
82
+ pattern, text = r"add_executable\s*\(\s*([\w.-]+)", cmake_files(project)
83
+ names = [n for n in re.findall(pattern, text, re.I) if not re.search(r"test", n, re.I)]
84
+ return names[0] if names else ""
@@ -0,0 +1,47 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING, Any, Dict, List
4
+
5
+ from quoll.languages.base import Detection, DockerDefaults, DockerImage
6
+ from quoll.languages.cpp.detect import executable
7
+ from quoll.project import Project
8
+
9
+ if TYPE_CHECKING:
10
+ from quoll.docker.config import DockerConfig
11
+
12
+
13
+ # Build tools on top of the gcc image.
14
+ _PACKAGES = {
15
+ "cmake": ("cmake", "ninja-build"),
16
+ "conan": ("cmake", "ninja-build", "pipx"),
17
+ "vcpkg": ("cmake", "ninja-build", "curl", "zip", "unzip", "tar", "pkg-config"),
18
+ "meson": ("meson", "ninja-build"),
19
+ }
20
+
21
+
22
+ class CppImage(DockerImage):
23
+ def defaults(self, project: Project, det: Detection) -> DockerDefaults:
24
+ warnings = ["C/C++ programs don't say which port they listen on — check docker.port."]
25
+ target = executable(project)
26
+ if not target:
27
+ warnings.append("No executable target found (add_executable / executable()) — check docker.command.")
28
+ if det.package_manager == "meson":
29
+ build = "meson setup build --buildtype=release && meson compile -C build"
30
+ else:
31
+ build = "cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release && cmake --build build"
32
+ return DockerDefaults(port=8080, command="/app", build_command=build, warnings=warnings)
33
+
34
+ def context(self, project: Project, cfg: DockerConfig) -> Dict[str, Any]:
35
+ compiler, _, major = cfg.version.partition("-")
36
+ return {
37
+ # The official gcc image builds both C and C++; clang projects build with the same major of GCC's
38
+ # libstdc++ either way.
39
+ "gcc_tag": major if compiler == "gcc" and major else "14",
40
+ "target": executable(project) or project.name,
41
+ "packages": " ".join(_PACKAGES[cfg.package_manager]),
42
+ "conan": cfg.package_manager == "conan",
43
+ "vcpkg": cfg.package_manager == "vcpkg",
44
+ }
45
+
46
+ def dockerignore(self, cfg: DockerConfig) -> List[str]:
47
+ return ["build", "build-*", "cmake-build-*", "out", ".env", ".env.*", "!.env.example"]
@@ -0,0 +1,21 @@
1
+ {% block build %}
2
+ FROM mcr.microsoft.com/dotnet/sdk:{{ cfg.version }} AS build
3
+ WORKDIR /src
4
+ COPY . .
5
+ RUN --mount=type=cache,target=/root/.nuget/packages \
6
+ {{ cfg.build_command }}
7
+ {% endblock %}
8
+
9
+ {% block runtime %}
10
+ FROM mcr.microsoft.com/dotnet/{{ "aspnet" if web else "runtime" }}:{{ cfg.version }}
11
+ {{ os_updates }}
12
+ WORKDIR /app
13
+ COPY --from=build /out .
14
+ {% if web %}
15
+ ENV ASPNETCORE_HTTP_PORTS={{ cfg.port }}
16
+ {% endif %}
17
+ # Non-root user provided by the .NET images.
18
+ USER $APP_UID
19
+ EXPOSE {{ cfg.port }}
20
+ CMD {{ cfg.command | exec_form }}
21
+ {% endblock %}
@@ -0,0 +1,17 @@
1
+ """.NET: C#, F# and VB projects and solutions."""
2
+
3
+ from quoll.languages.base import Language
4
+ from quoll.languages.dotnet.ci import DotnetCI
5
+ from quoll.languages.dotnet.detect import detect
6
+ from quoll.languages.dotnet.docker import DotnetImage
7
+
8
+ language = Language(
9
+ name="dotnet",
10
+ markers=("*.sln", "*.slnx", "*.csproj", "*.fsproj", "*.vbproj"),
11
+ package_managers=("dotnet",),
12
+ lint_tools=("dotnet-format",),
13
+ test_runners=("dotnet",),
14
+ detect=detect,
15
+ ci=DotnetCI(),
16
+ docker=DotnetImage(),
17
+ )
@@ -0,0 +1,88 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING, Any, Dict, List, Optional, Sequence
4
+
5
+ from quoll.ci.steps import ROOT, Cache, Item, step
6
+ from quoll.languages.base import CISteps, TestReports
7
+ from quoll.platforms.github.actions import ACTIONS
8
+
9
+ if TYPE_CHECKING:
10
+ from quoll.ci.config import CIConfig
11
+
12
+ # `dotnet list package --vulnerable` always exits 0: fail on high or critical advisories ourselves.
13
+ _AUDIT = """dotnet list package --vulnerable --include-transitive 2>&1 | tee vulnerable.txt
14
+ if grep -qE "\\b(High|Critical)\\b" vulnerable.txt; then
15
+ echo "::error::Packages with high or critical vulnerabilities found"
16
+ exit 1
17
+ fi"""
18
+
19
+ # dotnet test writes TRX results; trx2junit, installed outside the project, converts them for the platforms. Tests
20
+ # that fail still get their report: the status is kept until the conversion is done.
21
+ _JUNIT = """status=0
22
+ {test} || status=$?
23
+ dotnet tool install --tool-path /tmp/quoll-trx2junit trx2junit >/dev/null
24
+ /tmp/quoll-trx2junit/trx2junit TestResults/*.trx
25
+ exit $status"""
26
+
27
+
28
+ class DotnetCI(CISteps):
29
+ version_key = "dotnet-version"
30
+ codeql_language = "csharp"
31
+
32
+ def setup(self, cfg: CIConfig, version: Optional[str]) -> List[Item]:
33
+ with_: Dict[str, Any] = (
34
+ {"dotnet-version": version} if version else {"global-json-file": cfg.path(cfg.version_file)}
35
+ )
36
+ if cfg.cache:
37
+ with_["cache"] = True
38
+ with_["cache-dependency-path"] = cfg.path("**/packages.lock.json")
39
+ return [step("Set up .NET", uses=ACTIONS["setup-dotnet"], with_=with_)]
40
+
41
+ def image(self, cfg: CIConfig, version: str) -> str:
42
+ return f"mcr.microsoft.com/dotnet/sdk:{version}"
43
+
44
+ def cache(self, cfg: CIConfig) -> Optional[Cache]:
45
+ if not cfg.cache:
46
+ return None
47
+ return Cache("nuget", (".nuget/packages",), env={"NUGET_PACKAGES": f"{ROOT}/.nuget/packages"})
48
+
49
+ def install(self, cfg: CIConfig, packages: Sequence[str] = ()) -> List[Item]:
50
+ return [step("Restore dependencies", run=cfg.install)]
51
+
52
+ def lint(self, cfg: CIConfig, tools: List[str]) -> List[Item]:
53
+ steps = self.install(cfg)
54
+ if "dotnet-format" in tools:
55
+ steps.append(step("dotnet format", run="dotnet format --verify-no-changes --no-restore"))
56
+ return steps
57
+
58
+ def test(self, cfg: CIConfig) -> List[Item]:
59
+ t = cfg.test
60
+ cmd = "dotnet test --no-restore"
61
+ if t.coverage:
62
+ cmd += (
63
+ " -p:CollectCoverage=true -p:CoverletOutputFormat=cobertura"
64
+ f" -p:Threshold={t.coverage_threshold} -p:ThresholdType=line"
65
+ )
66
+ if t.reports:
67
+ cmd = _JUNIT.format(test=cmd + " --logger trx --results-directory TestResults")
68
+ name = "Tests" + (" with coverage" if t.coverage else "")
69
+ return [*self.install(cfg), step(name, run=cmd, secrets=t.env_secrets)]
70
+
71
+ def coverage_file(self, cfg: CIConfig) -> str:
72
+ return "**/coverage.cobertura.xml"
73
+
74
+ def reports(self, cfg: CIConfig) -> TestReports:
75
+ if not cfg.test.coverage:
76
+ return TestReports(junit="TestResults/*.xml")
77
+ # coverlet's summary table: | Total | 85.5% | ...
78
+ regex = r"/\|\s*Total\s*\|\s*(\d+(?:\.\d+)?)%/"
79
+ return TestReports(junit="TestResults/*.xml", cobertura="**/coverage.cobertura.xml", coverage_regex=regex)
80
+
81
+ def audit(self, cfg: CIConfig) -> List[Item]:
82
+ return [*self.install(cfg), step("Dependency vulnerability scan", run=_AUDIT)]
83
+
84
+ def build(self, cfg: CIConfig) -> List[Item]:
85
+ return [*self.install(cfg), step("Build", run=cfg.build.command)]
86
+
87
+ def dependabot_ecosystem(self, cfg: CIConfig) -> str:
88
+ return "nuget"
@@ -0,0 +1,53 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+
5
+ from quoll.languages.base import Detection
6
+ from quoll.project import Project
7
+ from quoll.services import detect_services
8
+
9
+ DOTNET_DEFAULT = "10.0"
10
+ DOTNET_LTS = ("8.0", "10.0")
11
+
12
+ # NuGet packages revealing a backing service.
13
+ _SERVICE_PACKAGES = {
14
+ "postgres": ("Npgsql",),
15
+ "mysql": ("MySqlConnector", "MySql.Data", "Pomelo.EntityFrameworkCore.MySql"),
16
+ "redis": ("StackExchange.Redis",),
17
+ "mongodb": ("MongoDB.Driver",),
18
+ }
19
+ _TEST_MARKERS = ("Microsoft.NET.Test.Sdk", "<IsTestProject>true", "xunit", "NUnit", "MSTest")
20
+
21
+
22
+ def detect(project: Project) -> Detection:
23
+ det = Detection(package_manager="dotnet", lint_tools=["dotnet-format"])
24
+ projects = "\n".join(p.read_text() for p in project.glob("**/*.csproj", "**/*.fsproj", "**/*.vbproj"))
25
+
26
+ locked = project.glob_any("**/packages.lock.json")
27
+ det.lockfile = "packages.lock.json" if locked else ""
28
+ det.install = "dotnet restore --locked-mode" if locked else "dotnet restore"
29
+ # setup-dotnet can only cache NuGet packages keyed on lock files.
30
+ det.cache = locked
31
+
32
+ if project.exists("global.json"):
33
+ det.version_file = "global.json"
34
+ global_json = project.read_json("global.json")
35
+ sdk = global_json.get("sdk", {}) if isinstance(global_json, dict) else {}
36
+ m = re.search(r"(\d+\.\d+)", str(sdk.get("version", "")))
37
+ det.version = m.group(1) if m else DOTNET_DEFAULT
38
+ else:
39
+ frameworks = [int(v) for v in re.findall(r"net(\d+)\.0", projects)]
40
+ det.version = f"{max(frameworks)}.0" if frameworks else DOTNET_DEFAULT
41
+ low = float(det.version)
42
+ det.matrix_versions = [v for v in DOTNET_LTS if float(v) >= low] or [det.version]
43
+
44
+ if any(marker in projects for marker in _TEST_MARKERS):
45
+ det.test_runner = "dotnet"
46
+ # Thresholds need coverlet.msbuild; the default coverlet.collector only reports.
47
+ det.coverage = "coverlet.msbuild" in projects
48
+ else:
49
+ det.warnings.append("No test project found (Microsoft.NET.Test.Sdk) — the test job is disabled.")
50
+ found = [p for packages in _SERVICE_PACKAGES.values() for p in packages if f'"{p}' in projects]
51
+ det.services = detect_services(found, _SERVICE_PACKAGES)
52
+ det.build_command = "dotnet build --no-restore --configuration Release"
53
+ return det
@@ -0,0 +1,47 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from pathlib import Path
5
+ from typing import TYPE_CHECKING, Any, Dict, List, Optional
6
+
7
+ from quoll.languages.base import Detection, DockerDefaults, DockerImage
8
+ from quoll.project import Project
9
+
10
+ if TYPE_CHECKING:
11
+ from quoll.docker.config import DockerConfig
12
+
13
+ _TEST_MARKERS = ("Microsoft.NET.Test.Sdk", "<IsTestProject>true")
14
+
15
+
16
+ class DotnetImage(DockerImage):
17
+ def defaults(self, project: Project, det: Detection) -> DockerDefaults:
18
+ app = _app_project(project)
19
+ if app is None:
20
+ return DockerDefaults(port=8080, warnings=["No application project found — set docker.build_command."])
21
+ rel = app.relative_to(project.root).as_posix()
22
+ assembly = re.search(r"<AssemblyName>([^<]+)</AssemblyName>", app.read_text())
23
+ name = assembly.group(1) if assembly else app.stem
24
+ return DockerDefaults(
25
+ port=8080,
26
+ command=f"dotnet {name}.dll",
27
+ build_command=f"dotnet publish {rel} --configuration Release --output /out",
28
+ )
29
+
30
+ def context(self, project: Project, cfg: DockerConfig) -> Dict[str, Any]:
31
+ published = re.search(r"dotnet publish (\S+)", cfg.build_command)
32
+ csproj = project.read_text(published.group(1)) if published else ""
33
+ return {"web": 'Sdk="Microsoft.NET.Sdk.Web"' in csproj}
34
+
35
+ def dockerignore(self, cfg: DockerConfig) -> List[str]:
36
+ return ["**/bin", "**/obj", "**/TestResults", ".vs", "*.user", ".env", ".env.*", "!.env.example"]
37
+
38
+
39
+ def _app_project(project: Project) -> Optional[Path]:
40
+ """The project to publish: a web project first, then any non-test project, shallowest first."""
41
+ candidates = [
42
+ p
43
+ for p in project.glob("*.csproj", "*/*.csproj", "*/*/*.csproj", "*.fsproj", "*/*.fsproj", "*/*/*.fsproj")
44
+ if not any(marker in p.read_text() for marker in _TEST_MARKERS)
45
+ ]
46
+ candidates.sort(key=lambda p: ("Microsoft.NET.Sdk.Web" not in p.read_text(), len(p.parts)))
47
+ return candidates[0] if candidates else None
@@ -0,0 +1,25 @@
1
+ {% block build %}
2
+ FROM golang:{{ go_tag }} AS build
3
+ WORKDIR /src
4
+ # Dependencies first: code changes don't download them again.
5
+ RUN --mount=type=cache,target=/go/pkg/mod \
6
+ --mount=type=bind,source=go.mod,target=go.mod \
7
+ {% if go_sum %}
8
+ --mount=type=bind,source=go.sum,target=go.sum \
9
+ {% endif %}
10
+ go mod download
11
+ COPY . .
12
+ RUN --mount=type=cache,target=/go/pkg/mod \
13
+ --mount=type=cache,target=/root/.cache/go-build \
14
+ CGO_ENABLED=0 {{ cfg.build_command }}
15
+ {% endblock %}
16
+
17
+ {% block runtime %}
18
+ # Static binary on distroless: no shell, no package manager, non-root.
19
+ FROM gcr.io/distroless/static-debian13:nonroot
20
+ COPY --from=build /out/app /app
21
+ # distroless' nonroot, numeric: Kubernetes can then check it isn't root (runAsNonRoot).
22
+ USER 65532:65532
23
+ EXPOSE {{ cfg.port }}
24
+ CMD {{ cfg.command | exec_form }}
25
+ {% endblock %}
@@ -0,0 +1,17 @@
1
+ """Go modules."""
2
+
3
+ from quoll.languages.base import Language
4
+ from quoll.languages.go.ci import GoCI
5
+ from quoll.languages.go.detect import detect
6
+ from quoll.languages.go.docker import GoImage
7
+
8
+ language = Language(
9
+ name="go",
10
+ markers=("go.mod",),
11
+ package_managers=("go",),
12
+ lint_tools=("gofmt", "vet", "golangci-lint"),
13
+ test_runners=("go",),
14
+ detect=detect,
15
+ ci=GoCI(),
16
+ docker=GoImage(),
17
+ )
@@ -0,0 +1,126 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING, Any, Dict, List, Optional, Sequence
4
+
5
+ from quoll.ci.steps import ROOT, SUMMARY_FILE, Cache, Item, Step, step
6
+ from quoll.languages.base import THRESHOLD_REGEX, CISteps, TestReports
7
+ from quoll.platforms.github.actions import ACTIONS
8
+
9
+ if TYPE_CHECKING:
10
+ from quoll.ci.config import CIConfig
11
+
12
+ # Only tracked files: container jobs keep the module cache inside the project.
13
+ _GOFMT = """unformatted=$(git ls-files -z '*.go' | xargs -0 -r gofmt -l)
14
+ if [ -n "$unformatted" ]; then
15
+ echo "::error::These files are not gofmt-formatted:"
16
+ echo "$unformatted"
17
+ exit 1
18
+ fi"""
19
+
20
+ _COVERAGE_CHECK = """total=$(go tool cover -func=coverage.out | awk '/^total:/ {{ sub("%", "", $3); print $3 }}')
21
+ echo "### Coverage: ${{total}}%" >> {summary}
22
+ awk -v total="$total" -v min={threshold} 'BEGIN {{
23
+ if (total + 0 < min) {{ printf "::error::Coverage %.1f%% is below the %d%% threshold\\n", total, min; exit 1 }}
24
+ printf "Coverage %.1f%% (threshold %d%%)\\n", total, min
25
+ }}'"""
26
+
27
+ _GOTESTSUM = "GOTOOLCHAIN=auto go run gotest.tools/gotestsum@latest"
28
+ _COBERTURA = "GOTOOLCHAIN=auto go run github.com/boumenot/gocover-cobertura@latest < coverage.out > coverage.xml"
29
+ _GOLANGCI_LINT = """curl -sSfL https://golangci-lint.run/install.sh | sh -s -- -b "$(go env GOPATH)/bin"
30
+ "$(go env GOPATH)/bin/golangci-lint" run"""
31
+
32
+
33
+ def _working_directory(cfg: CIConfig) -> Dict[str, str]:
34
+ return {"working-directory": cfg.directory} if cfg.directory != "." else {}
35
+
36
+
37
+ class GoCI(CISteps):
38
+ version_key = "go-version"
39
+ codeql_language = "go"
40
+ codeql_build_mode = "autobuild"
41
+
42
+ def setup(self, cfg: CIConfig, version: Optional[str]) -> List[Item]:
43
+ with_: Dict[str, Any] = {"go-version": version} if version else {"go-version-file": cfg.path(cfg.version_file)}
44
+ if not cfg.cache:
45
+ with_["cache"] = False
46
+ elif cfg.directory != ".":
47
+ with_["cache-dependency-path"] = cfg.path("go.sum")
48
+ return [step("Set up Go", uses=ACTIONS["setup-go"], with_=with_)]
49
+
50
+ def image(self, cfg: CIConfig, version: str) -> str:
51
+ if version == "oldstable":
52
+ raise ValueError("Go 'oldstable' has no image: list explicit versions (e.g. 1.25) in [ci] versions")
53
+ # The "1" tag follows the latest release, like setup-go's "stable".
54
+ return f"golang:{'1' if version == 'stable' else version}"
55
+
56
+ def cache(self, cfg: CIConfig) -> Optional[Cache]:
57
+ if not cfg.cache:
58
+ return None
59
+ env = {"GOMODCACHE": f"{ROOT}/.go/mod", "GOCACHE": f"{ROOT}/.go/build"}
60
+ return Cache("go", (".go/mod", ".go/build"), key_files=("go.sum",), env=env)
61
+
62
+ def install(self, cfg: CIConfig, packages: Sequence[str] = ()) -> List[Step]:
63
+ return [step("Download modules", run=cfg.install)]
64
+
65
+ def lint(self, cfg: CIConfig, tools: List[str]) -> List[Item]:
66
+ steps = self.install(cfg)
67
+ for tool in tools:
68
+ if tool == "gofmt":
69
+ steps.append(step("gofmt", run=_GOFMT))
70
+ elif tool == "vet":
71
+ steps.append(step("go vet", run="go vet ./..."))
72
+ elif tool == "golangci-lint":
73
+ steps.append(
74
+ step(
75
+ "golangci-lint",
76
+ run=_GOLANGCI_LINT,
77
+ uses=ACTIONS["golangci-lint"],
78
+ with_={"version": "latest", **_working_directory(cfg)},
79
+ )
80
+ )
81
+ return steps
82
+
83
+ def test(self, cfg: CIConfig) -> List[Item]:
84
+ t = cfg.test
85
+ # gotestsum runs `go test` and writes JUnit results.
86
+ go_test = f"{_GOTESTSUM} --junitfile junit.xml --" if t.reports else "go test"
87
+ if not t.coverage:
88
+ return [*self.install(cfg), step("Tests", run=f"{go_test} -race ./...", secrets=t.env_secrets)]
89
+ return [
90
+ *self.install(cfg),
91
+ step(
92
+ "Tests with coverage",
93
+ run=f"{go_test} -race -covermode=atomic -coverprofile=coverage.out ./...",
94
+ secrets=t.env_secrets,
95
+ ),
96
+ *([step("Coverage report (Cobertura)", run=_COBERTURA)] if t.reports else []),
97
+ step(
98
+ "Coverage threshold",
99
+ run=_COVERAGE_CHECK.format(threshold=t.coverage_threshold, summary=SUMMARY_FILE),
100
+ ),
101
+ ]
102
+
103
+ def coverage_file(self, cfg: CIConfig) -> str:
104
+ return "coverage.out"
105
+
106
+ def reports(self, cfg: CIConfig) -> TestReports:
107
+ coverage = cfg.test.coverage
108
+ return TestReports(
109
+ junit="junit.xml",
110
+ cobertura="coverage.xml" if coverage else "",
111
+ coverage_regex=THRESHOLD_REGEX if coverage else "",
112
+ )
113
+
114
+ def audit(self, cfg: CIConfig) -> List[Item]:
115
+ # govulncheck@latest may need a newer Go than the project's: let go fetch it to build the tool, while the
116
+ # scan itself uses the project's toolchain (setup-go and the golang images pin GOTOOLCHAIN=local).
117
+ return [
118
+ *self.install(cfg),
119
+ step("govulncheck", run="GOTOOLCHAIN=auto go run golang.org/x/vuln/cmd/govulncheck@latest ./..."),
120
+ ]
121
+
122
+ def build(self, cfg: CIConfig) -> List[Item]:
123
+ return [*self.install(cfg), step("Build", run=cfg.build.command)]
124
+
125
+ def dependabot_ecosystem(self, cfg: CIConfig) -> str:
126
+ return "gomod"
@@ -0,0 +1,30 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+
5
+ from quoll.languages.base import Detection
6
+ from quoll.project import Project
7
+ from quoll.services import detect_services
8
+
9
+ # Modules revealing a backing service.
10
+ _SERVICE_MODULES = {
11
+ "postgres": ("github.com/lib/pq", "github.com/jackc/pgx", "gorm.io/driver/postgres"),
12
+ "mysql": ("github.com/go-sql-driver/mysql", "gorm.io/driver/mysql"),
13
+ "redis": ("github.com/redis/go-redis", "github.com/go-redis/redis", "github.com/gomodule/redigo"),
14
+ "mongodb": ("go.mongodb.org/mongo-driver",),
15
+ }
16
+
17
+
18
+ def detect(project: Project) -> Detection:
19
+ det = Detection(package_manager="go", install="go mod download", version_file="go.mod")
20
+ det.lockfile = "go.sum" if project.exists("go.sum") else ""
21
+ det.cache = bool(det.lockfile)
22
+ m = re.search(r"^go\s+(\d+\.\d+)", project.read_text("go.mod"), re.M)
23
+ det.version = m.group(1) if m else "stable"
24
+ det.matrix_versions = ["oldstable", "stable"]
25
+ det.lint_tools = ["gofmt", "vet", "golangci-lint"]
26
+ det.test_runner = "go"
27
+ det.build_command = "go build ./..."
28
+ go_mod = project.read_text("go.mod")
29
+ det.services = detect_services([m for ms in _SERVICE_MODULES.values() for m in ms if m in go_mod], _SERVICE_MODULES)
30
+ return det
@@ -0,0 +1,40 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING, Any, Dict, List
4
+
5
+ from quoll.languages.base import Detection, DockerDefaults, DockerImage
6
+ from quoll.project import Project
7
+
8
+ if TYPE_CHECKING:
9
+ from quoll.docker.config import DockerConfig
10
+
11
+
12
+ class GoImage(DockerImage):
13
+ def defaults(self, project: Project, det: Detection) -> DockerDefaults:
14
+ package, warnings = _main_package(project)
15
+ return DockerDefaults(
16
+ port=8080,
17
+ command="/app",
18
+ build_command=f'go build -trimpath -ldflags="-s -w" -o /out/app {package}',
19
+ warnings=warnings,
20
+ )
21
+
22
+ def context(self, project: Project, cfg: DockerConfig) -> Dict[str, Any]:
23
+ # go.mod without a go directive reads as "stable": use the latest Go.
24
+ return {"go_tag": "1" if cfg.version == "stable" else cfg.version, "go_sum": project.exists("go.sum")}
25
+
26
+ def dockerignore(self, cfg: DockerConfig) -> List[str]:
27
+ return ["bin", "coverage.out", ".env", ".env.*", "!.env.example"]
28
+
29
+
30
+ def _main_package(project: Project):
31
+ """The package to build: the root, cmd/, or the only cmd/<name>/."""
32
+ if project.exists("main.go"):
33
+ return ".", []
34
+ if project.exists("cmd/main.go"):
35
+ return "./cmd", []
36
+ commands = sorted(p.parent.relative_to(project.root).as_posix() for p in project.glob("cmd/*/main.go"))
37
+ if not commands:
38
+ return ".", ["No main package found (main.go or cmd/<name>/main.go) — check docker.build_command."]
39
+ warnings = [] if len(commands) == 1 else [f"Several commands in cmd/ — building ./{commands[0]}."]
40
+ return f"./{commands[0]}", warnings
@@ -0,0 +1,28 @@
1
+ {% block build %}
2
+ FROM {{ build_image }} AS build
3
+ WORKDIR /src
4
+ COPY . .
5
+ RUN --mount=type=cache,target={{ cache }} \
6
+ {% if wrapper %}
7
+ chmod +x {{ wrapper }} \
8
+ && {{ cfg.build_command }} \
9
+ {% else %}
10
+ {{ cfg.build_command }} \
11
+ {% endif %}
12
+ && mkdir -p /out \
13
+ && cp "$(find {{ jar_dir }} -maxdepth 1 -name '*.jar' ! -name '*-plain.jar' ! -name '*-sources.jar' ! -name '*-javadoc.jar' | head -n 1)" /out/app.jar
14
+ {% endblock %}
15
+
16
+ {% block runtime %}
17
+ FROM eclipse-temurin:{{ cfg.version }}-jre
18
+ {{ os_updates }}
19
+ # A numeric user: Kubernetes can then check it isn't root (runAsNonRoot).
20
+ RUN groupadd --system --gid 10001 app && useradd --system --uid 10001 --gid app --no-create-home app
21
+ WORKDIR /app
22
+ {% block app_files %}
23
+ COPY --from=build --chown=app:app /out/app.jar /app/app.jar
24
+ {% endblock %}
25
+ USER 10001:10001
26
+ EXPOSE {{ cfg.port }}
27
+ CMD {{ cfg.command | exec_form }}
28
+ {% endblock %}
@@ -0,0 +1,20 @@
1
+ """JVM projects (Java, Kotlin) built with Maven or Gradle."""
2
+
3
+ from quoll.languages.base import Language
4
+ from quoll.languages.java.ci import JavaCI
5
+ from quoll.languages.java.detect import detect
6
+ from quoll.languages.java.docker import JavaImage
7
+ from quoll.languages.java.frameworks import spring_boot
8
+
9
+ language = Language(
10
+ name="java",
11
+ markers=("pom.xml", "build.gradle", "build.gradle.kts", "settings.gradle", "settings.gradle.kts"),
12
+ # The -wrapper variants run the committed ./mvnw or ./gradlew.
13
+ package_managers=("maven", "maven-wrapper", "gradle", "gradle-wrapper"),
14
+ lint_tools=("spotless", "checkstyle", "ktlint", "detekt", "spotbugs", "pmd"),
15
+ test_runners=("junit",),
16
+ detect=detect,
17
+ ci=JavaCI(),
18
+ frameworks=(spring_boot.framework,),
19
+ docker=JavaImage(),
20
+ )