quoll-cli 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- quoll/__init__.py +0 -0
- quoll/cd/__init__.py +1 -0
- quoll/cd/config.py +75 -0
- quoll/ci/__init__.py +0 -0
- quoll/ci/config.py +213 -0
- quoll/ci/custom.py +32 -0
- quoll/ci/detect.py +139 -0
- quoll/ci/steps.py +85 -0
- quoll/ci/tools.py +104 -0
- quoll/commands/__init__.py +0 -0
- quoll/commands/_common.py +145 -0
- quoll/commands/_workspace.py +83 -0
- quoll/commands/add.py +104 -0
- quoll/commands/add_secret.py +75 -0
- quoll/commands/cd.py +214 -0
- quoll/commands/ci.py +521 -0
- quoll/commands/docker.py +244 -0
- quoll/commands/k8s.py +211 -0
- quoll/commands/new.py +337 -0
- quoll/commands/remove.py +79 -0
- quoll/config/__init__.py +0 -0
- quoll/config/quoll_toml.py +196 -0
- quoll/config/settings.py +22 -0
- quoll/config/store.py +39 -0
- quoll/console.py +3 -0
- quoll/docker/__init__.py +1 -0
- quoll/docker/config.py +64 -0
- quoll/docker/detect.py +41 -0
- quoll/docker/render.py +162 -0
- quoll/files.py +64 -0
- quoll/k8s/__init__.py +0 -0
- quoll/k8s/chart/templates/_helpers.tpl +38 -0
- quoll/k8s/chart/templates/deployment.yaml +93 -0
- quoll/k8s/chart/templates/hpa.yaml +24 -0
- quoll/k8s/chart/templates/ingress.yaml +35 -0
- quoll/k8s/chart/templates/pdb.yaml +16 -0
- quoll/k8s/chart/templates/service.yaml +18 -0
- quoll/k8s/chart/templates/serviceaccount.yaml +10 -0
- quoll/k8s/ci.py +39 -0
- quoll/k8s/config.py +97 -0
- quoll/k8s/deploy.py +22 -0
- quoll/k8s/detect.py +32 -0
- quoll/k8s/render.py +94 -0
- quoll/languages/__init__.py +121 -0
- quoll/languages/base.py +350 -0
- quoll/languages/cpp/Dockerfile.j2 +37 -0
- quoll/languages/cpp/__init__.py +17 -0
- quoll/languages/cpp/ci.py +192 -0
- quoll/languages/cpp/detect.py +84 -0
- quoll/languages/cpp/docker.py +47 -0
- quoll/languages/dotnet/Dockerfile.j2 +21 -0
- quoll/languages/dotnet/__init__.py +17 -0
- quoll/languages/dotnet/ci.py +88 -0
- quoll/languages/dotnet/detect.py +53 -0
- quoll/languages/dotnet/docker.py +47 -0
- quoll/languages/go/Dockerfile.j2 +25 -0
- quoll/languages/go/__init__.py +17 -0
- quoll/languages/go/ci.py +126 -0
- quoll/languages/go/detect.py +30 -0
- quoll/languages/go/docker.py +40 -0
- quoll/languages/java/Dockerfile.j2 +28 -0
- quoll/languages/java/__init__.py +20 -0
- quoll/languages/java/ci.py +163 -0
- quoll/languages/java/detect.py +77 -0
- quoll/languages/java/docker.py +45 -0
- quoll/languages/java/frameworks/__init__.py +1 -0
- quoll/languages/java/frameworks/spring_boot/Dockerfile.j2 +18 -0
- quoll/languages/java/frameworks/spring_boot/__init__.py +10 -0
- quoll/languages/java/frameworks/spring_boot/detect.py +9 -0
- quoll/languages/java/frameworks/spring_boot/docker.py +19 -0
- quoll/languages/java/frameworks/spring_boot/services.py +24 -0
- quoll/languages/node/Dockerfile.j2 +98 -0
- quoll/languages/node/__init__.py +29 -0
- quoll/languages/node/ci.py +200 -0
- quoll/languages/node/detect.py +164 -0
- quoll/languages/node/docker.py +141 -0
- quoll/languages/node/frameworks/__init__.py +1 -0
- quoll/languages/node/frameworks/angular/__init__.py +18 -0
- quoll/languages/node/frameworks/angular/ci.py +34 -0
- quoll/languages/node/frameworks/angular/detect.py +20 -0
- quoll/languages/node/frameworks/angular/docker.py +31 -0
- quoll/languages/node/frameworks/angular/starter.py +74 -0
- quoll/languages/node/frameworks/astro/__init__.py +10 -0
- quoll/languages/node/frameworks/astro/ci.py +17 -0
- quoll/languages/node/frameworks/astro/detect.py +14 -0
- quoll/languages/node/frameworks/astro/docker.py +17 -0
- quoll/languages/node/frameworks/nestjs/__init__.py +7 -0
- quoll/languages/node/frameworks/nestjs/detect.py +9 -0
- quoll/languages/node/frameworks/nestjs/docker.py +10 -0
- quoll/languages/node/frameworks/nextjs/Dockerfile.j2 +14 -0
- quoll/languages/node/frameworks/nextjs/__init__.py +8 -0
- quoll/languages/node/frameworks/nextjs/ci.py +29 -0
- quoll/languages/node/frameworks/nextjs/detect.py +13 -0
- quoll/languages/node/frameworks/nextjs/docker.py +41 -0
- quoll/languages/node/frameworks/nuxt/__init__.py +10 -0
- quoll/languages/node/frameworks/nuxt/ci.py +17 -0
- quoll/languages/node/frameworks/nuxt/detect.py +17 -0
- quoll/languages/node/frameworks/nuxt/docker.py +24 -0
- quoll/languages/node/frameworks/sveltekit/__init__.py +15 -0
- quoll/languages/node/frameworks/sveltekit/ci.py +15 -0
- quoll/languages/node/frameworks/sveltekit/detect.py +18 -0
- quoll/languages/node/frameworks/sveltekit/docker.py +18 -0
- quoll/languages/node/starter.py +42 -0
- quoll/languages/node/templates/express/README.md +11 -0
- quoll/languages/node/templates/express/env.example +2 -0
- quoll/languages/node/templates/express/eslint.config.js +15 -0
- quoll/languages/node/templates/express/gitignore +4 -0
- quoll/languages/node/templates/express/nvmrc +1 -0
- quoll/languages/node/templates/express/package.json +16 -0
- quoll/languages/node/templates/express/prettierignore +6 -0
- quoll/languages/node/templates/express/src/app.ts +27 -0
- quoll/languages/node/templates/express/src/server.ts +11 -0
- quoll/languages/node/templates/express/test/app.test.ts +29 -0
- quoll/languages/node/templates/express/tsconfig.build.json +8 -0
- quoll/languages/node/templates/express/tsconfig.json +14 -0
- quoll/languages/node/templates/express/vitest.config.ts +12 -0
- quoll/languages/php/Dockerfile.j2 +40 -0
- quoll/languages/php/__init__.py +19 -0
- quoll/languages/php/ci.py +147 -0
- quoll/languages/php/detect.py +75 -0
- quoll/languages/php/docker.py +59 -0
- quoll/languages/php/frameworks/__init__.py +1 -0
- quoll/languages/php/frameworks/laravel/__init__.py +17 -0
- quoll/languages/php/frameworks/laravel/ci.py +24 -0
- quoll/languages/php/frameworks/laravel/detect.py +13 -0
- quoll/languages/php/frameworks/laravel/docker.py +29 -0
- quoll/languages/php/frameworks/laravel/services.py +25 -0
- quoll/languages/php/frameworks/symfony/__init__.py +15 -0
- quoll/languages/php/frameworks/symfony/ci.py +22 -0
- quoll/languages/php/frameworks/symfony/detect.py +17 -0
- quoll/languages/php/frameworks/symfony/docker.py +25 -0
- quoll/languages/python/Dockerfile.j2 +61 -0
- quoll/languages/python/__init__.py +19 -0
- quoll/languages/python/ci.py +157 -0
- quoll/languages/python/detect.py +175 -0
- quoll/languages/python/docker.py +80 -0
- quoll/languages/python/frameworks/__init__.py +1 -0
- quoll/languages/python/frameworks/django/__init__.py +16 -0
- quoll/languages/python/frameworks/django/ci.py +43 -0
- quoll/languages/python/frameworks/django/detect.py +17 -0
- quoll/languages/python/frameworks/django/docker.py +20 -0
- quoll/languages/python/frameworks/fastapi/__init__.py +7 -0
- quoll/languages/python/frameworks/fastapi/detect.py +9 -0
- quoll/languages/python/frameworks/fastapi/docker.py +21 -0
- quoll/languages/python/frameworks/flask/__init__.py +7 -0
- quoll/languages/python/frameworks/flask/detect.py +9 -0
- quoll/languages/python/frameworks/flask/docker.py +22 -0
- quoll/languages/ruby/Dockerfile.j2 +45 -0
- quoll/languages/ruby/__init__.py +19 -0
- quoll/languages/ruby/ci.py +110 -0
- quoll/languages/ruby/detect.py +72 -0
- quoll/languages/ruby/docker.py +63 -0
- quoll/languages/ruby/frameworks/__init__.py +1 -0
- quoll/languages/ruby/frameworks/rails/Dockerfile.j2 +16 -0
- quoll/languages/ruby/frameworks/rails/__init__.py +18 -0
- quoll/languages/ruby/frameworks/rails/ci.py +26 -0
- quoll/languages/ruby/frameworks/rails/detect.py +15 -0
- quoll/languages/ruby/frameworks/rails/docker.py +62 -0
- quoll/languages/ruby/frameworks/rails/services.py +10 -0
- quoll/languages/rust/Dockerfile.j2 +23 -0
- quoll/languages/rust/__init__.py +17 -0
- quoll/languages/rust/ci.py +125 -0
- quoll/languages/rust/detect.py +46 -0
- quoll/languages/rust/docker.py +38 -0
- quoll/main.py +50 -0
- quoll/platforms/__init__.py +62 -0
- quoll/platforms/base.py +103 -0
- quoll/platforms/github/__init__.py +4 -0
- quoll/platforms/github/actions.py +78 -0
- quoll/platforms/github/api.py +124 -0
- quoll/platforms/github/cd.py +265 -0
- quoll/platforms/github/ci.py +497 -0
- quoll/platforms/github/dependabot.yml.j2 +49 -0
- quoll/platforms/github/platform.py +52 -0
- quoll/platforms/github/workflow.py +74 -0
- quoll/platforms/gitlab/__init__.py +4 -0
- quoll/platforms/gitlab/api.py +158 -0
- quoll/platforms/gitlab/cd.py +226 -0
- quoll/platforms/gitlab/ci.py +400 -0
- quoll/platforms/gitlab/pipeline.py +136 -0
- quoll/platforms/gitlab/platform.py +54 -0
- quoll/project.py +60 -0
- quoll/services.py +117 -0
- quoll/starters.py +117 -0
- quoll/workspace.py +232 -0
- quoll/yaml_dump.py +137 -0
- quoll_cli-0.2.0.dist-info/METADATA +641 -0
- quoll_cli-0.2.0.dist-info/RECORD +191 -0
- quoll_cli-0.2.0.dist-info/WHEEL +4 -0
- quoll_cli-0.2.0.dist-info/entry_points.txt +2 -0
- quoll_cli-0.2.0.dist-info/licenses/LICENSE +21 -0
quoll/__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
|