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
quoll/__init__.py ADDED
File without changes
quoll/cd/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """Continuous delivery: build the Docker image, scan it, push it to a registry."""
quoll/cd/config.py ADDED
@@ -0,0 +1,75 @@
1
+ """The [cd] section of quoll.toml — the single source of truth for the generated delivery workflow."""
2
+ from __future__ import annotations
3
+
4
+ from pathlib import Path
5
+ from typing import List
6
+
7
+ from pydantic import Field, model_validator
8
+
9
+ from quoll.config.quoll_toml import CONFIG_FILE, ConfigError, PlatformField, Section, load_section, save_section
10
+ from quoll.platforms import get as get_platform
11
+
12
+ __all__ = ["CONFIG_FILE", "CDConfig", "ConfigError", "load_config", "save_config"]
13
+
14
+ SEVERITIES = ("UNKNOWN", "LOW", "MEDIUM", "HIGH", "CRITICAL")
15
+
16
+
17
+ class CDConfig(Section, PlatformField):
18
+ shared_fields = ("platform",)
19
+
20
+ # The platform's own registry, authenticated with the pipeline's token (no secret to set):
21
+ # ghcr (GitHub Container Registry) on GitHub, gitlab (the project's container registry) on GitLab.
22
+ # dockerhub: Docker Hub, with the DOCKERHUB_USERNAME and DOCKERHUB_TOKEN secrets / CI/CD variables.
23
+ registry: str = ""
24
+ # Full image name, e.g. "ghcr.io/acme/api", "registry.gitlab.com/acme/api" or "acme/api" on Docker Hub.
25
+ image: str
26
+ # Every push to these branches publishes :<branch>, :sha-<sha> and :latest for the default branch.
27
+ branches: List[str] = Field(default_factory=lambda: ["main"])
28
+ # Git tags v1.2.3 publish :1.2.3 and :1.2.
29
+ release_tags: bool = True
30
+ platforms: List[str] = Field(default_factory=lambda: ["linux/amd64"])
31
+ # Trivy scans the image before it is pushed; findings of these severities block the push.
32
+ scan: bool = True
33
+ severity: List[str] = Field(default_factory=lambda: ["CRITICAL", "HIGH"])
34
+ # Only block on vulnerabilities that have a fix available.
35
+ ignore_unfixed: bool = True
36
+ # SBOM (CycloneDX) of the published image: a pipeline artifact, attached to the v* releases.
37
+ sbom: bool = True
38
+ # Sign the image and its SBOM with Sigstore: GitHub artifact attestations (public repositories, or GitHub
39
+ # Enterprise Cloud for private ones) / cosign keyless on gitlab.com. Verify with `gh attestation verify` / `cosign
40
+ # verify`.
41
+ sign: bool = True
42
+
43
+ @model_validator(mode="after")
44
+ def _check(self) -> "CDConfig":
45
+ registries = get_platform(self.platform).registries
46
+ if not self.registry:
47
+ self.registry = registries[0]
48
+ if self.registry not in registries:
49
+ raise ValueError(
50
+ f"unknown registry '{self.registry}' for {self.platform} (expected one of: {', '.join(registries)})"
51
+ )
52
+ if self.registry == "ghcr" and not self.image.startswith("ghcr.io/"):
53
+ raise ValueError("image must start with ghcr.io/ when registry is ghcr")
54
+ if self.registry == "gitlab" and "/" not in self.image.split("/", 1)[-1]:
55
+ raise ValueError("image must include the registry host, e.g. registry.gitlab.com/group/project")
56
+ if self.image != self.image.lower():
57
+ raise ValueError("image names must be lowercase")
58
+ unknown = [s for s in self.severity if s not in SEVERITIES]
59
+ if unknown:
60
+ raise ValueError(f"unknown severity '{unknown[0]}' (expected some of: {', '.join(SEVERITIES)})")
61
+ if self.scan and not self.severity:
62
+ raise ValueError("severity must list at least one level when scan is true")
63
+ if not self.branches and not self.release_tags:
64
+ raise ValueError("nothing would publish the image: set branches or release_tags")
65
+ if not self.platforms:
66
+ raise ValueError("platforms must contain at least one platform")
67
+ return self
68
+
69
+
70
+ def load_config(project_dir: Path) -> CDConfig:
71
+ return load_section(project_dir, "cd", CDConfig)
72
+
73
+
74
+ def save_config(project_dir: Path, config: CDConfig) -> Path:
75
+ return save_section(project_dir, "cd", config)
quoll/ci/__init__.py ADDED
File without changes
quoll/ci/config.py ADDED
@@ -0,0 +1,213 @@
1
+ """The [ci] section of quoll.toml — the single source of truth for the generated workflow."""
2
+ from __future__ import annotations
3
+
4
+ from pathlib import Path
5
+ from typing import Any, Dict, List
6
+
7
+ from pydantic import ConfigDict, Field, field_validator, model_validator
8
+
9
+ from quoll.config.quoll_toml import CONFIG_FILE, ConfigError, ProjectConfig, Section, load_section, save_section
10
+
11
+ __all__ = ["CONFIG_FILE", "CIConfig", "ConfigError", "load_config", "save_config"]
12
+
13
+
14
+ SEVERITIES = ("UNKNOWN", "LOW", "MEDIUM", "HIGH", "CRITICAL")
15
+
16
+
17
+ class LintConfig(Section):
18
+ enabled: bool = True
19
+ tools: List[str] = Field(default_factory=list)
20
+
21
+
22
+ class TestConfig(Section):
23
+ __test__ = False # not a pytest test class
24
+
25
+ enabled: bool = True
26
+ runner: str = ""
27
+ coverage: bool = True
28
+ coverage_threshold: int = Field(default=80, ge=0, le=100)
29
+ codecov: bool = False
30
+ # Test results (JUnit) and coverage (Cobertura) shown in pull/merge requests, where the runner produces them.
31
+ reports: bool = True
32
+ # Run the tests with AddressSanitizer and UndefinedBehaviorSanitizer (C/C++).
33
+ sanitizers: bool = False
34
+ env_secrets: List[str] = Field(default_factory=list)
35
+
36
+
37
+ class SecurityConfig(Section):
38
+ # Known vulnerabilities in the dependencies.
39
+ audit: bool = True
40
+ # Credentials committed anywhere in the git history (gitleaks).
41
+ secrets: bool = True
42
+ # Static analysis of the code for bugs and vulnerabilities (Semgrep), every language.
43
+ sast: bool = True
44
+ # GitHub CodeQL; results in the Security tab (needs GitHub Advanced Security on private repositories).
45
+ codeql: bool = False
46
+ # Software bill of materials of the dependencies (CycloneDX), kept as a pipeline artifact.
47
+ sbom: bool = True
48
+ # Fail on dependencies whose license falls in these Trivy categories: CRITICAL = forbidden (AGPL, SSPL...),
49
+ # HIGH = restricted (GPL, LGPL), MEDIUM = reciprocal (MPL, EPL), LOW = notice (MIT, Apache, BSD).
50
+ licenses: bool = True
51
+ license_severity: List[str] = Field(default_factory=lambda: ["CRITICAL"])
52
+ sonar: bool = False
53
+ sonar_organization: str = ""
54
+ sonar_project_key: str = ""
55
+
56
+
57
+ class BuildConfig(Section):
58
+ enabled: bool = False
59
+ command: str = ""
60
+ docker: bool = False
61
+ # The project's Helm chart (directory): linted, checked against Kubernetes' schemas, scanned.
62
+ chart: str = ""
63
+ # What the build produces, kept a week (paths or globs in the project, e.g. "dist/", "target/*.jar").
64
+ artifacts: List[str] = []
65
+
66
+
67
+ # Generated jobs custom steps can extend.
68
+ EXTENSIBLE_JOBS = ("lint", "test", "build", "audit", "sbom")
69
+ # Jobs the scheduled pipeline runs: new vulnerabilities appear without any code change.
70
+ SECURITY_JOBS = ("audit", "secrets", "sast", "codeql", "sbom", "docker")
71
+
72
+
73
+ class CustomStep(Section):
74
+ """A step added to one of the generated jobs. For anything bigger, write plain CI next to quoll's: GitHub
75
+ workflows using the quoll-setup action, GitLab jobs in .gitlab/custom-ci.yml."""
76
+
77
+ job: str
78
+ # before: once the dependencies are installed, before the job's own commands; after: at the end.
79
+ when: str = "before"
80
+ name: str
81
+ # A shell command...
82
+ run: str = ""
83
+ # ...or a GitHub Action (GitHub only), with its inputs.
84
+ uses: str = ""
85
+ with_: Dict[str, Any] = Field(default_factory=dict, alias="with")
86
+ env: Dict[str, str] = Field(default_factory=dict)
87
+ # CI secrets / variables the step reads.
88
+ secrets: List[str] = Field(default_factory=list)
89
+
90
+ model_config = ConfigDict(extra="forbid", populate_by_name=True, serialize_by_alias=True)
91
+
92
+ @model_validator(mode="after")
93
+ def _check(self) -> "CustomStep":
94
+ if self.job not in EXTENSIBLE_JOBS:
95
+ raise ValueError(f"unknown job '{self.job}' (expected one of: {', '.join(EXTENSIBLE_JOBS)})")
96
+ if self.when not in ("before", "after"):
97
+ raise ValueError(f"when must be 'before' or 'after', not '{self.when}'")
98
+ if bool(self.run) == bool(self.uses):
99
+ raise ValueError(f"step '{self.name}' needs either run or uses")
100
+ if self.with_ and not self.uses:
101
+ raise ValueError(f"step '{self.name}': with only goes with uses")
102
+ return self
103
+
104
+
105
+ class ScheduleConfig(Section):
106
+ # Re-run the security jobs on a schedule (GitHub: in the workflow; GitLab: a pipeline schedule).
107
+ enabled: bool = True
108
+ # Cron expression, UTC. Default: Mondays at 06:00.
109
+ cron: str = "0 6 * * 1"
110
+
111
+ @field_validator("cron")
112
+ @classmethod
113
+ def _five_fields(cls, value: str) -> str:
114
+ if len(value.split()) != 5:
115
+ raise ValueError(f"cron '{value}' must have 5 fields (minute hour day month weekday)")
116
+ return value
117
+
118
+
119
+ class CIConfig(ProjectConfig):
120
+ omit_when_empty = ("steps", "build.artifacts", "build.chart")
121
+
122
+ # Exact versions to test against. More than one turns the test job into a matrix.
123
+ versions: List[str] = Field(default_factory=list)
124
+ # Used when `versions` is empty: let the setup action read .python-version, .nvmrc, go.mod... (GitHub only:
125
+ # GitLab jobs run in images chosen by version).
126
+ version_file: str = ""
127
+ install: str
128
+ cache: bool = True
129
+ source_dir: str = "."
130
+ branches: List[str] = Field(default_factory=lambda: ["main"])
131
+ # Dependabot on GitHub, Renovate on GitLab. Their config is only created, never overwritten.
132
+ dependency_updates: bool = True
133
+ lint: LintConfig = Field(default_factory=LintConfig)
134
+ test: TestConfig = Field(default_factory=TestConfig)
135
+ security: SecurityConfig = Field(default_factory=SecurityConfig)
136
+ build: BuildConfig = Field(default_factory=BuildConfig)
137
+ schedule: ScheduleConfig = Field(default_factory=ScheduleConfig)
138
+ # Your own steps in the generated jobs ([[ci.steps]]), kept across `quoll ci update`.
139
+ steps: List[CustomStep] = Field(default_factory=list)
140
+
141
+ @model_validator(mode="after")
142
+ def _check_consistency(self) -> "CIConfig":
143
+ lang = self.language
144
+ for tool in self.lint.tools:
145
+ if not self.is_lint_tool(tool):
146
+ raise ValueError(
147
+ f"unknown lint tool '{tool}' for {self.target} (expected one of: {', '.join(self.lint_tools)})"
148
+ )
149
+ if self.test.enabled and not (lang.is_test_runner(self.test.runner) or self.is_framework_runner):
150
+ raise ValueError(
151
+ f"unknown test runner '{self.test.runner}' for {self.target} "
152
+ f"(expected one of: {', '.join(self.test_runners)})"
153
+ )
154
+ if self.security.codeql and self.platform != "github":
155
+ raise ValueError("security.codeql is only available on GitHub (GitLab: security.sast)")
156
+ if self.security.codeql and not lang.ci.codeql_language:
157
+ raise ValueError(f"CodeQL does not analyze {lang.name}: use security.sast")
158
+ unknown = [s for s in self.security.license_severity if s not in SEVERITIES]
159
+ if unknown:
160
+ raise ValueError(
161
+ f"unknown security.license_severity '{unknown[0]}' (expected some of: {', '.join(SEVERITIES)})"
162
+ )
163
+ if self.security.licenses and not self.security.license_severity:
164
+ raise ValueError("security.license_severity must list at least one level when licenses is true")
165
+ if self.security.sonar and not self.security.sonar_organization:
166
+ raise ValueError("security.sonar_organization is required when security.sonar is enabled")
167
+ if self.build.enabled and not self.build.command:
168
+ raise ValueError("build.command is required when build.enabled is true")
169
+ if not self.branches:
170
+ raise ValueError("branches must contain at least one branch")
171
+ actions = [s.name for s in self.steps if s.uses]
172
+ if actions and self.platform != "github":
173
+ raise ValueError(
174
+ f"step '{actions[0]}' uses a GitHub Action: on GitLab, write it as a job in .gitlab/custom-ci.yml"
175
+ )
176
+ if self.platform == "gitlab" and not self.versions:
177
+ raise ValueError(
178
+ "versions is required on GitLab: jobs run in images chosen by version "
179
+ "(version_file only works with GitHub's setup actions)"
180
+ )
181
+ return self
182
+
183
+ @property
184
+ def lint_tools(self) -> List[str]:
185
+ """Lint tools available to this project: the language's, then the framework's."""
186
+ framework = self.get_framework()
187
+ return [*self.language.lint_tools, *(framework.lint_tools if framework else ())]
188
+
189
+ @property
190
+ def test_runners(self) -> List[str]:
191
+ framework = self.get_framework()
192
+ return [*self.language.test_runners, *(framework.test_runners if framework else ())]
193
+
194
+ def is_lint_tool(self, tool: str) -> bool:
195
+ return self.language.is_lint_tool(tool) or tool in self.lint_tools
196
+
197
+ @property
198
+ def is_framework_runner(self) -> bool:
199
+ framework = self.get_framework()
200
+ return framework is not None and self.test.runner in framework.test_runners
201
+
202
+ @property
203
+ def matrix(self) -> bool:
204
+ return len(self.versions) > 1
205
+
206
+
207
+ def load_config(project_dir: Path) -> CIConfig:
208
+ return load_section(project_dir, "ci", CIConfig)
209
+
210
+
211
+ def save_config(project_dir: Path, config: CIConfig) -> Path:
212
+ """Write [project] and [ci], preserving any other section already in quoll.toml."""
213
+ return save_section(project_dir, "ci", config)
quoll/ci/custom.py ADDED
@@ -0,0 +1,32 @@
1
+ """The project's own steps from quoll.toml ([[ci.steps]]), merged into the generated jobs."""
2
+ from __future__ import annotations
3
+
4
+ from typing import TYPE_CHECKING, List
5
+
6
+ from quoll.ci.steps import Item, Step, step
7
+
8
+ if TYPE_CHECKING:
9
+ from quoll.ci.config import CIConfig, CustomStep
10
+
11
+
12
+ def extend(cfg: CIConfig, job: str, items: List[Item]) -> List[Item]:
13
+ """`items` of a generated job with the custom steps of `job`: "before" ones right after the dependency
14
+ installation (the language's `install` steps), "after" ones at the end."""
15
+ before = [_step(s) for s in cfg.steps if s.job == job and s.when == "before"]
16
+ after = [_step(s) for s in cfg.steps if s.job == job and s.when == "after"]
17
+ if not before and not after:
18
+ return items
19
+ installed = cfg.language.ci.install(cfg)
20
+ last = max((i for i, item in enumerate(items) if item in installed), default=-1)
21
+ return [*items[: last + 1], *before, *items[last + 1 :], *after]
22
+
23
+
24
+ def _step(custom: CustomStep) -> Step:
25
+ return step(
26
+ custom.name,
27
+ run=custom.run.strip(),
28
+ uses=custom.uses,
29
+ with_=custom.with_,
30
+ env=custom.env,
31
+ secrets=custom.secrets,
32
+ )
quoll/ci/detect.py ADDED
@@ -0,0 +1,139 @@
1
+ """Inspect a project and infer everything the CI needs, so the user only answers real questions.
2
+
3
+ Toolchain detection is delegated to the language and framework plugins; this module adds what every project shares
4
+ (git, .env.example, Dockerfile).
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import subprocess
9
+ from pathlib import Path
10
+ from typing import Dict
11
+
12
+ from quoll import languages, platforms
13
+ from quoll.ci.config import CIConfig
14
+ from quoll.config import quoll_toml
15
+ from quoll.languages.base import Detection
16
+ from quoll.project import Project
17
+
18
+ __all__ = ["Detection", "configured_platform", "default_config", "detect", "parse_env_file"]
19
+
20
+
21
+ def detect(project_dir: Path) -> Detection:
22
+ project = Project(project_dir)
23
+ found = languages.matching(project)
24
+ if found:
25
+ language = found[0]
26
+ det = language.detect(project)
27
+ det.stack = language.name
28
+ for framework in languages.frameworks(language.name):
29
+ if framework.detect(project, det):
30
+ det.framework = framework.name
31
+ break
32
+ det.other_stacks = [lang.name for lang in found[1:]]
33
+ if det.other_stacks:
34
+ det.warnings.insert(
35
+ 0,
36
+ f"Also found {', '.join(det.other_stacks)} files — quoll configures one stack per repository for now.",
37
+ )
38
+ else:
39
+ det = Detection()
40
+ det.warnings.extend(project.warnings)
41
+
42
+ det.has_dockerfile = project.exists("Dockerfile")
43
+ try:
44
+ k8s = quoll_toml.read(project_dir).get("k8s")
45
+ except quoll_toml.ConfigError:
46
+ k8s = None
47
+ det.chart = k8s.get("chart", "chart") if k8s is not None else ""
48
+ if project.exists(".env.example"):
49
+ det.env_vars = parse_env_file(project_dir / ".env.example")
50
+ det.default_branch = default_branch(project_dir)
51
+ det.remote = platforms.remote(project_dir)
52
+ det.platform = configured_platform(project_dir) or platforms.detect(project_dir, det.remote)
53
+ return det
54
+
55
+
56
+ def configured_platform(project_dir: Path) -> str:
57
+ """The platform quoll.toml already names: it wins over the detection."""
58
+ try:
59
+ platform = quoll_toml.read(project_dir).get(quoll_toml.PROJECT_SECTION, {}).get("platform", "")
60
+ except quoll_toml.ConfigError:
61
+ return ""
62
+ return platform if platform in platforms.NAMES else ""
63
+
64
+
65
+ def default_config(det: Detection) -> CIConfig:
66
+ if det.stack is None:
67
+ raise ValueError("cannot build a CI config without a detected stack")
68
+ # GitLab jobs run in images picked by version: they can't read a version file.
69
+ version_file = det.version_file if det.platform == "github" else ""
70
+ language = languages.get(det.stack)
71
+ artifacts = language.ci.build_artifacts(det) if det.build_command else []
72
+ return CIConfig.model_validate(
73
+ {
74
+ "platform": det.platform,
75
+ "stack": det.stack,
76
+ "framework": det.framework,
77
+ "package_manager": det.package_manager,
78
+ "package_manager_version": det.package_manager_version,
79
+ "services": det.services,
80
+ "versions": [] if version_file else [det.version],
81
+ "version_file": version_file,
82
+ "install": det.install,
83
+ "cache": det.cache,
84
+ "source_dir": det.source_dir,
85
+ "branches": [det.default_branch],
86
+ "lint": {"enabled": bool(det.lint_tools), "tools": det.lint_tools},
87
+ "test": {
88
+ "enabled": bool(det.test_runner),
89
+ "runner": det.test_runner,
90
+ "coverage": det.coverage,
91
+ "sanitizers": det.sanitizers,
92
+ },
93
+ "security": {"audit": det.audit},
94
+ "build": {
95
+ "enabled": bool(det.build_command),
96
+ "command": det.build_command,
97
+ "docker": det.has_dockerfile,
98
+ "chart": det.chart,
99
+ "artifacts": artifacts,
100
+ },
101
+ }
102
+ )
103
+
104
+
105
+ # --------------------------------------------------------------------------- git
106
+
107
+
108
+ def _git(project_dir: Path, *args: str) -> str:
109
+ try:
110
+ result = subprocess.run(["git", *args], cwd=project_dir, capture_output=True, text=True, timeout=5)
111
+ except (OSError, subprocess.SubprocessError):
112
+ return ""
113
+ return result.stdout.strip() if result.returncode == 0 else ""
114
+
115
+
116
+ def default_branch(project_dir: Path) -> str:
117
+ remote_head = _git(project_dir, "symbolic-ref", "--short", "refs/remotes/origin/HEAD")
118
+ if remote_head:
119
+ return remote_head.split("/", 1)[-1]
120
+ return _git(project_dir, "branch", "--show-current") or "main"
121
+
122
+
123
+ # --------------------------------------------------------------------------- helpers
124
+
125
+
126
+ def parse_env_file(path: Path) -> Dict[str, str]:
127
+ """Return {key: value} from a .env-style file. Value is empty when unset."""
128
+ result: Dict[str, str] = {}
129
+ for line in path.read_text().splitlines():
130
+ line = line.strip()
131
+ if not line or line.startswith("#"):
132
+ continue
133
+ if line.startswith("export "):
134
+ line = line[len("export "):]
135
+ key, _, value = line.partition("=")
136
+ key = key.strip()
137
+ if key:
138
+ result[key] = value.strip().strip("\"'")
139
+ return result
quoll/ci/steps.py ADDED
@@ -0,0 +1,85 @@
1
+ """Platform-neutral CI steps.
2
+
3
+ Languages and frameworks describe their jobs with `Step` and `Cache`; each platform (see quoll/platforms) renders
4
+ them. GitHub Actions runs a step's `uses` action when it has one; GitLab CI runs its `run` command in the
5
+ language's container image.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass, field
10
+ from typing import Any, Mapping, Optional, Sequence, Tuple, Union
11
+
12
+ # Stands for the absolute project directory in `Cache.env` values.
13
+ ROOT = "{root}"
14
+ # The job summary file in shell commands: GitHub's, or /dev/null on platforms without one.
15
+ SUMMARY_FILE = '"${GITHUB_STEP_SUMMARY:-/dev/null}"'
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class Step:
20
+ name: str
21
+ # Shell command. Required unless the step is a GitHub-only setup step (see CISteps.setup).
22
+ run: str = ""
23
+ # GitHub Action doing the same as `run`, used instead of it on GitHub.
24
+ uses: str = ""
25
+ with_: Mapping[str, Any] = field(default_factory=dict)
26
+ id: str = ""
27
+ env: Mapping[str, str] = field(default_factory=dict)
28
+ # CI secrets (GitHub) / variables (GitLab) the step reads, as environment variables of the same name.
29
+ secrets: Tuple[str, ...] = ()
30
+ # Also runs when an earlier step failed (reports, uploads).
31
+ always: bool = False
32
+ # Its failure does not fail the job.
33
+ optional: bool = False
34
+ # Only feeds the job summary page: dropped on platforms without one.
35
+ summary: bool = False
36
+
37
+
38
+ @dataclass(frozen=True)
39
+ class Cache:
40
+ """Directories kept between runs, invalidated when `key_files` change."""
41
+
42
+ key: str
43
+ paths: Tuple[str, ...]
44
+ # At most two on GitLab.
45
+ key_files: Tuple[str, ...] = ()
46
+ # GitHub caches are immutable: a change in these files saves a fresh one (restored from the previous).
47
+ revision_files: Tuple[str, ...] = ()
48
+ # Variables pointing the tools at `paths`; ROOT stands for the project directory.
49
+ env: Mapping[str, str] = field(default_factory=dict)
50
+ name: str = ""
51
+
52
+
53
+ Item = Union[Step, Cache]
54
+
55
+
56
+ def step(
57
+ name: str,
58
+ run: str = "",
59
+ *,
60
+ uses: str = "",
61
+ with_: Optional[Mapping[str, Any]] = None,
62
+ id_: str = "",
63
+ env: Optional[Mapping[str, str]] = None,
64
+ secrets: Sequence[str] = (),
65
+ always: bool = False,
66
+ optional: bool = False,
67
+ summary: bool = False,
68
+ ) -> Step:
69
+ return Step(
70
+ name=name,
71
+ run=run,
72
+ uses=uses,
73
+ with_=dict(with_ or {}),
74
+ id=id_,
75
+ env=dict(env or {}),
76
+ secrets=tuple(secrets),
77
+ always=always,
78
+ optional=optional,
79
+ summary=summary,
80
+ )
81
+
82
+
83
+ def on_github(cfg: Any, text: str) -> str:
84
+ """`text` when the project's CI runs on GitHub, e.g. a linter flag printing GitHub annotations."""
85
+ return text if cfg.platform == "github" else ""
quoll/ci/tools.py ADDED
@@ -0,0 +1,104 @@
1
+ """Language-independent CI tools (secret scanning, static analysis, image checks), shared by the platforms.
2
+
3
+ Images are pinned; both platforms run the same commands, GitHub in a container job or on the runner, GitLab in a
4
+ container job.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ from typing import List
9
+
10
+ from quoll.ci.steps import Step, step
11
+ from quoll.platforms.github.actions import ACTIONS
12
+
13
+ GITLEAKS_IMAGE = "ghcr.io/gitleaks/gitleaks:v8.30.1"
14
+ SEMGREP_IMAGE = "semgrep/semgrep:1.185.0"
15
+ TRIVY_VERSION = "0.76.2"
16
+ TRIVY_IMAGE = f"aquasec/trivy:{TRIVY_VERSION}"
17
+ SBOM_FILE = "sbom.cdx.json"
18
+
19
+ # Fixable vulnerabilities of these severities fail the image scan (same defaults as [cd]).
20
+ IMAGE_SCAN_SEVERITY = "CRITICAL,HIGH"
21
+
22
+
23
+ _OSV_SCANNER = """arch=$(uname -m | sed 's/x86_64/amd64/; s/aarch64/arm64/')
24
+ curl -sSfL -o /usr/local/bin/osv-scanner \\
25
+ "https://github.com/google/osv-scanner/releases/latest/download/osv-scanner_linux_$arch"
26
+ chmod +x /usr/local/bin/osv-scanner"""
27
+
28
+
29
+ def install_osv_scanner() -> Step:
30
+ """osv-scanner reads lock files of many ecosystems (Maven, Gradle, Conan...)."""
31
+ return step("Install osv-scanner", run=_OSV_SCANNER, uses=ACTIONS["install-tool"], with_={"tool": "osv-scanner"})
32
+
33
+
34
+ # The release binary, pinned (no install script fetched from a branch); curl comes from apt in slim images.
35
+ _TRIVY_INSTALL = """if ! command -v curl >/dev/null; then
36
+ apt-get update -qq && apt-get install -y -qq curl ca-certificates
37
+ fi
38
+ arch=$(uname -m | sed 's/x86_64/64bit/; s/aarch64/ARM64/')
39
+ mkdir -p /tmp/trivy
40
+ curl -sSfL "https://github.com/aquasecurity/trivy/releases/download/v{v}/trivy_{v}_Linux-$arch.tar.gz" \\
41
+ | tar -xz -C /tmp/trivy trivy"""
42
+
43
+
44
+ # Tool caches the container jobs keep in the project (see CISteps.cache): not dependencies of the project.
45
+ _TOOL_CACHES = (".go", ".cargo", ".m2", ".gradle", ".cache", ".npm", ".pnpm-store", ".yarn/cache", ".yarn-cache",
46
+ ".nuget", ".sonar", "build")
47
+
48
+
49
+ def dependency_checks(sbom: bool, licenses: bool, severities: List[str]) -> List[Step]:
50
+ """SBOM and license gate, run once the dependencies are installed: `trivy rootfs` reads the installed packages
51
+ (node_modules, .venv, vendor/, jars...), where the licenses are; lock files alone don't carry them."""
52
+ trivy = f"/tmp/trivy/trivy rootfs --quiet --skip-dirs {','.join(_TOOL_CACHES)}"
53
+ steps = [step("Install Trivy", run=_TRIVY_INSTALL.format(v=TRIVY_VERSION))]
54
+ if sbom:
55
+ steps.append(step("SBOM (CycloneDX)", run=f"{trivy} --format cyclonedx --output {SBOM_FILE} ."))
56
+ if licenses:
57
+ # Trivy's categories: CRITICAL = forbidden (AGPL, SSPL...), HIGH = restricted (GPL, LGPL),
58
+ # MEDIUM = reciprocal (MPL, EPL), LOW = notice (MIT, Apache, BSD).
59
+ steps.append(
60
+ step(
61
+ "License check",
62
+ # Every license is listed; only the configured categories fail. Free-text licenses Trivy can't
63
+ # classify are UNKNOWN.
64
+ run=f"{trivy} --scanners license --severity {','.join(severities)} --exit-code 1 .",
65
+ )
66
+ )
67
+ return steps
68
+
69
+
70
+ def image_sbom() -> str:
71
+ """Shell command writing the SBOM of the pushed image $IMAGE@$DIGEST (registry credentials in
72
+ TRIVY_USERNAME / TRIVY_PASSWORD) to SBOM_FILE. Trivy runs as a container: works on runners and in dind."""
73
+ return (
74
+ f"docker run --rm -e TRIVY_USERNAME -e TRIVY_PASSWORD {TRIVY_IMAGE} image --quiet --format cyclonedx "
75
+ f'"$IMAGE@$DIGEST" > {SBOM_FILE}'
76
+ )
77
+
78
+
79
+ def secret_scan() -> Step:
80
+ """Every commit of the history, so a secret removed later is still caught (allow-list: .gitleaksignore)."""
81
+ return step("Secret scanning", run="gitleaks git --redact --verbose .")
82
+
83
+
84
+ def sast(report: str = "") -> Step:
85
+ """Semgrep's default rules for every language of the repository; `report` also writes GitLab's SAST report."""
86
+ # Blocking: ERROR rules only; WARNING ones are mostly low-confidence "audit" rules, noise in a gate.
87
+ command = "semgrep scan --config p/default --severity ERROR --error --metrics off"
88
+ if report:
89
+ command += f" --text --gitlab-sast-output={report}"
90
+ # An explicit target: without one, semgrep's image expects the code mounted at /src.
91
+ return step("Static analysis", run=command + " .")
92
+
93
+
94
+ def image_checks(tag: str) -> List[Step]:
95
+ """Lint the Dockerfile (BuildKit's checks), build the image, scan it with Trivy before anyone ships it."""
96
+ scan = (
97
+ f"docker run --rm -v /var/run/docker.sock:/var/run/docker.sock {TRIVY_IMAGE} image --no-progress "
98
+ f'--exit-code 1 --severity {IMAGE_SCAN_SEVERITY} --ignore-unfixed "{tag}"'
99
+ )
100
+ return [
101
+ step("Lint Dockerfile", run="docker build --check ."),
102
+ step("Build image", run=f'docker build --tag "{tag}" .'),
103
+ step("Scan image", run=scan),
104
+ ]
File without changes