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
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""GitLab REST API (gitlab.com or a self-managed instance): projects, CI/CD variables, protected branches."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import re
|
|
5
|
+
from typing import Dict, List, Optional, Sequence
|
|
6
|
+
from urllib.parse import quote
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
from quoll.config.settings import settings
|
|
11
|
+
from quoll.platforms.base import PlatformAPI, PlatformError, Repo
|
|
12
|
+
|
|
13
|
+
_ERROR_HINTS = {
|
|
14
|
+
401: "The token is invalid or expired (QUOLL_GITLAB_TOKEN, `glab auth login`, or ~/.config/quoll/config.toml).",
|
|
15
|
+
403: "The token lacks permissions: it needs the 'api' scope and the Maintainer role on the project.",
|
|
16
|
+
404: "Project or group not found (or not visible to this token).",
|
|
17
|
+
}
|
|
18
|
+
# Identifies the pipeline schedule quoll manages among the project's others.
|
|
19
|
+
SCHEDULE_DESCRIPTION = "quoll: security scans"
|
|
20
|
+
# Access levels: https://docs.gitlab.com/api/protected_branches/
|
|
21
|
+
_NO_ONE = 0
|
|
22
|
+
_MAINTAINER = 40
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class GitLabError(PlatformError):
|
|
26
|
+
pass
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def gitlab_client(base_url: Optional[str] = None) -> httpx.Client:
|
|
30
|
+
return httpx.Client(
|
|
31
|
+
base_url=f"{(base_url or settings.gitlab_url).rstrip('/')}/api/v4",
|
|
32
|
+
headers={"PRIVATE-TOKEN": settings.gitlab_token},
|
|
33
|
+
timeout=30,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class GitLabAPI(PlatformAPI):
|
|
38
|
+
def __init__(self, base_url: Optional[str] = None) -> None:
|
|
39
|
+
self.base_url = base_url
|
|
40
|
+
|
|
41
|
+
def create_repo(self, name: str, private: bool = True) -> Repo:
|
|
42
|
+
with gitlab_client(self.base_url) as client:
|
|
43
|
+
payload: Dict[str, object] = {
|
|
44
|
+
"name": name,
|
|
45
|
+
"path": name,
|
|
46
|
+
"visibility": "private" if private else "public",
|
|
47
|
+
"description": "Scaffolded by quoll",
|
|
48
|
+
"initialize_with_readme": False,
|
|
49
|
+
}
|
|
50
|
+
if settings.gitlab_group:
|
|
51
|
+
response = client.get(f"/groups/{_id(settings.gitlab_group)}")
|
|
52
|
+
_raise_for_status(response, f"find the group {settings.gitlab_group}")
|
|
53
|
+
payload["namespace_id"] = response.json()["id"]
|
|
54
|
+
response = client.post("/projects", json=payload)
|
|
55
|
+
_raise_for_status(response, "create the project")
|
|
56
|
+
data = response.json()
|
|
57
|
+
return Repo(
|
|
58
|
+
path=data["path_with_namespace"], clone_url=data["http_url_to_repo"], web_url=data["web_url"]
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
def set_secrets(self, repo: str, secrets: Dict[str, str]) -> None:
|
|
62
|
+
"""Create or update CI/CD variables, masked whenever GitLab accepts to mask the value."""
|
|
63
|
+
repo = self._full_path(repo)
|
|
64
|
+
with gitlab_client(self.base_url) as client:
|
|
65
|
+
for key, value in secrets.items():
|
|
66
|
+
payload = {"key": key, "value": value, "masked": _maskable(value), "protected": False}
|
|
67
|
+
response = client.put(f"/projects/{_id(repo)}/variables/{key}", json=payload)
|
|
68
|
+
if response.status_code == 404:
|
|
69
|
+
response = client.post(f"/projects/{_id(repo)}/variables", json=payload)
|
|
70
|
+
_raise_for_status(response, f"save the variable {key}")
|
|
71
|
+
|
|
72
|
+
def ensure_schedule(self, repo: str, branch: str, cron: str) -> bool:
|
|
73
|
+
"""The pipeline schedule quoll owns (found by its description), created or updated."""
|
|
74
|
+
repo = self._full_path(repo)
|
|
75
|
+
payload = {"description": SCHEDULE_DESCRIPTION, "ref": branch, "cron": cron, "cron_timezone": "UTC",
|
|
76
|
+
"active": True}
|
|
77
|
+
schedules = f"/projects/{_id(repo)}/pipeline_schedules"
|
|
78
|
+
with gitlab_client(self.base_url) as client:
|
|
79
|
+
response = client.get(schedules, params={"per_page": 100})
|
|
80
|
+
_raise_for_status(response, "list the pipeline schedules")
|
|
81
|
+
existing = next((s for s in response.json() if s.get("description") == SCHEDULE_DESCRIPTION), None)
|
|
82
|
+
if existing:
|
|
83
|
+
response = client.put(f"{schedules}/{existing['id']}", json=payload)
|
|
84
|
+
else:
|
|
85
|
+
response = client.post(schedules, json=payload)
|
|
86
|
+
_raise_for_status(response, "save the pipeline schedule")
|
|
87
|
+
return True
|
|
88
|
+
|
|
89
|
+
def protect_branch(
|
|
90
|
+
self, repo: str, branch: str, required_checks: Sequence[str] = (), required_reviews: int = 0
|
|
91
|
+
) -> List[str]:
|
|
92
|
+
"""No direct pushes, merge requests only once the pipeline passed, linear history."""
|
|
93
|
+
repo = self._full_path(repo)
|
|
94
|
+
warnings: List[str] = []
|
|
95
|
+
project = f"/projects/{_id(repo)}"
|
|
96
|
+
with gitlab_client(self.base_url) as client:
|
|
97
|
+
# Protection settings can't be changed in place: unprotect, then protect again.
|
|
98
|
+
client.delete(f"{project}/protected_branches/{quote(branch, safe='')}")
|
|
99
|
+
response = client.post(
|
|
100
|
+
f"{project}/protected_branches",
|
|
101
|
+
json={
|
|
102
|
+
"name": branch,
|
|
103
|
+
"push_access_level": _NO_ONE,
|
|
104
|
+
"merge_access_level": _MAINTAINER,
|
|
105
|
+
"allow_force_push": False,
|
|
106
|
+
},
|
|
107
|
+
)
|
|
108
|
+
_raise_for_status(response, f"protect the branch {branch}")
|
|
109
|
+
response = client.put(
|
|
110
|
+
project,
|
|
111
|
+
json={
|
|
112
|
+
"only_allow_merge_if_pipeline_succeeds": True,
|
|
113
|
+
"only_allow_merge_if_all_discussions_are_resolved": True,
|
|
114
|
+
"merge_method": "ff",
|
|
115
|
+
},
|
|
116
|
+
)
|
|
117
|
+
_raise_for_status(response, "require a successful pipeline to merge")
|
|
118
|
+
if required_reviews:
|
|
119
|
+
# Approval rules are a GitLab Premium feature.
|
|
120
|
+
response = client.post(
|
|
121
|
+
f"{project}/approval_rules",
|
|
122
|
+
json={"name": "quoll", "approvals_required": required_reviews},
|
|
123
|
+
)
|
|
124
|
+
if response.is_error:
|
|
125
|
+
warnings.append(
|
|
126
|
+
f"required approvals not set (GitLab {response.status_code}): they need GitLab Premium"
|
|
127
|
+
)
|
|
128
|
+
return warnings
|
|
129
|
+
|
|
130
|
+
def _full_path(self, repo: str) -> str:
|
|
131
|
+
"""group/project; a bare name belongs to the configured group, or to the authenticated user."""
|
|
132
|
+
if "/" in repo:
|
|
133
|
+
return repo
|
|
134
|
+
if settings.gitlab_group:
|
|
135
|
+
return f"{settings.gitlab_group}/{repo}"
|
|
136
|
+
with gitlab_client(self.base_url) as client:
|
|
137
|
+
response = client.get("/user")
|
|
138
|
+
_raise_for_status(response, "read the authenticated user")
|
|
139
|
+
return f"{response.json()['username']}/{repo}"
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _id(path: str) -> str:
|
|
143
|
+
"""Projects and groups are addressed by their URL-encoded full path."""
|
|
144
|
+
return quote(path, safe="")
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _maskable(value: str) -> bool:
|
|
148
|
+
# https://docs.gitlab.com/ci/variables/#mask-a-cicd-variable
|
|
149
|
+
return len(value) >= 8 and bool(re.fullmatch(r"[A-Za-z0-9+/=@:.~_-]+", value))
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _raise_for_status(response: httpx.Response, action: str) -> None:
|
|
153
|
+
if response.is_error:
|
|
154
|
+
hint = _ERROR_HINTS.get(response.status_code, "")
|
|
155
|
+
msg = f"GitLab {response.status_code} while trying to {action}."
|
|
156
|
+
if hint:
|
|
157
|
+
msg += f" {hint}"
|
|
158
|
+
raise GitLabError(msg)
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
"""Turn a CDConfig into .gitlab/cd.yml: build, scan with Trivy, then push.
|
|
2
|
+
|
|
3
|
+
.gitlab-ci.yml (`quoll ci`) includes this file. Its job runs in the `publish` stage, so only once every CI job
|
|
4
|
+
of the pipeline passed.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any, Dict, List, Union
|
|
10
|
+
|
|
11
|
+
from quoll.cd.config import CDConfig
|
|
12
|
+
from quoll.ci.tools import SBOM_FILE, TRIVY_IMAGE, image_sbom
|
|
13
|
+
from quoll.files import PlannedFile, header, planned
|
|
14
|
+
from quoll.k8s.ci import HELM_IMAGE
|
|
15
|
+
from quoll.k8s.config import Environment
|
|
16
|
+
from quoll.k8s.deploy import DIGEST, IMAGE, helm_upgrade, url
|
|
17
|
+
from quoll.platforms.gitlab.ci import CD_PATH, SCHEDULED
|
|
18
|
+
from quoll.platforms.gitlab.pipeline import branch_rule, docker_job, dump, registry_cache
|
|
19
|
+
from quoll.workspace import Delivery
|
|
20
|
+
|
|
21
|
+
PIPELINE_PATH = Path(CD_PATH)
|
|
22
|
+
|
|
23
|
+
# Same tags as docker/metadata-action on GitHub: branch, sha-<sha>, latest on the default branch,
|
|
24
|
+
# 1.2.3 and 1.2 for a v1.2.3 tag (plus latest unless it is a pre-release).
|
|
25
|
+
_TAGS = """tags="sha-$CI_COMMIT_SHORT_SHA"
|
|
26
|
+
if [ -n "$CI_COMMIT_TAG" ]; then
|
|
27
|
+
version="${CI_COMMIT_TAG#v}"
|
|
28
|
+
case "$version" in
|
|
29
|
+
*-*) tags="$tags $version" ;;
|
|
30
|
+
*) tags="$tags $version ${version%.*} latest" ;;
|
|
31
|
+
esac
|
|
32
|
+
else
|
|
33
|
+
tags="$tags $CI_COMMIT_REF_SLUG"
|
|
34
|
+
if [ "$CI_COMMIT_BRANCH" = "$CI_DEFAULT_BRANCH" ]; then tags="$tags latest"; fi
|
|
35
|
+
fi
|
|
36
|
+
tag_args=""
|
|
37
|
+
for tag in $tags; do tag_args="$tag_args --tag $IMAGE:$tag"; done"""
|
|
38
|
+
|
|
39
|
+
# The scanner runs as a container of the Docker-in-Docker daemon, next to the image it scans.
|
|
40
|
+
_SCAN = (
|
|
41
|
+
"docker run --rm -v /var/run/docker.sock:/var/run/docker.sock {trivy} image --no-progress --exit-code 1 "
|
|
42
|
+
'--severity {severity}{unfixed} "scan:$CI_COMMIT_SHA"'
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
# The digest buildx pushed, from its metadata file.
|
|
46
|
+
_DIGEST = """DIGEST=$(sed -n 's/.*"containerimage.digest": *"\\([^"]*\\)".*/\\1/p' metadata.json | head -n 1)
|
|
47
|
+
echo "Digest: $DIGEST\""""
|
|
48
|
+
# Variables the publish job hands to the deploy jobs.
|
|
49
|
+
DEPLOY_ENV = "deploy.env"
|
|
50
|
+
# `release:` runs through glab.
|
|
51
|
+
GLAB_IMAGE = "registry.gitlab.com/gitlab-org/cli:v1.130.0"
|
|
52
|
+
|
|
53
|
+
_LOGIN = {
|
|
54
|
+
"gitlab": 'echo "$CI_REGISTRY_PASSWORD" | docker login --username "$CI_REGISTRY_USER" --password-stdin '
|
|
55
|
+
'"$CI_REGISTRY"',
|
|
56
|
+
"dockerhub": 'echo "$DOCKERHUB_TOKEN" | docker login --username "$DOCKERHUB_USERNAME" --password-stdin',
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def render_pipeline(target: Union[CDConfig, List[Delivery]]) -> str:
|
|
61
|
+
"""The delivery of a project, or of each workspace member with a [cd] (when its directory changed)."""
|
|
62
|
+
deliveries = [Delivery("", ".", target)] if isinstance(target, CDConfig) else target
|
|
63
|
+
# Not "image": a reserved top-level keyword.
|
|
64
|
+
doc: Dict[str, Any] = {_publish_job(d): _publish(d) for d in deliveries}
|
|
65
|
+
for d in deliveries:
|
|
66
|
+
for environment, settings in (d.k8s.environments.items() if d.k8s else ()):
|
|
67
|
+
job = f"{d.name}-deploy-{environment}" if d.name else f"deploy-{environment}"
|
|
68
|
+
doc[job] = _deploy(d, environment, settings)
|
|
69
|
+
released = [d for d in deliveries if d.cfg.sbom and d.cfg.release_tags]
|
|
70
|
+
if released:
|
|
71
|
+
doc["release"] = _release(released)
|
|
72
|
+
return header("cd") + dump(doc)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _publish(delivery: Delivery) -> Dict[str, Any]:
|
|
76
|
+
cfg, name, directory = delivery.cfg, delivery.name, delivery.directory
|
|
77
|
+
branches = [branch_rule(b) for b in cfg.branches]
|
|
78
|
+
if name and directory != ".":
|
|
79
|
+
# Branch pushes only publish what changed (tag pipelines count everything as changed).
|
|
80
|
+
changes = {"paths": [f"{directory}/**/*", "quoll.toml", ".gitlab-ci.yml", ".gitlab/**/*"]}
|
|
81
|
+
branches = [{**rule, "changes": changes} for rule in branches]
|
|
82
|
+
# Never from a scheduled pipeline (quoll ci's security scans run on the default branch).
|
|
83
|
+
rules: List[Dict[str, Any]] = [{"if": SCHEDULED, "when": "never"}, *branches]
|
|
84
|
+
if cfg.release_tags:
|
|
85
|
+
rules.append({"if": "$CI_COMMIT_TAG =~ /^v/"})
|
|
86
|
+
|
|
87
|
+
script = [_TAGS]
|
|
88
|
+
if any(not p.endswith("amd64") for p in cfg.platforms):
|
|
89
|
+
script.append("docker run --privileged --rm tonistiigi/binfmt --install all")
|
|
90
|
+
# A container builder (multi-platform images, provenance and SBOM attestations), with the layers the CI's docker
|
|
91
|
+
# job cached in the project's registry.
|
|
92
|
+
script.append(registry_cache(name or "ci"))
|
|
93
|
+
if cfg.scan:
|
|
94
|
+
script += [
|
|
95
|
+
# Built and loaded locally first: a vulnerable image never reaches the registry.
|
|
96
|
+
f'docker buildx build --platform {cfg.platforms[0]} --load $cache --tag "scan:$CI_COMMIT_SHA" .',
|
|
97
|
+
_SCAN.format(
|
|
98
|
+
trivy=TRIVY_IMAGE,
|
|
99
|
+
severity=",".join(cfg.severity),
|
|
100
|
+
unfixed=" --ignore-unfixed" if cfg.ignore_unfixed else "",
|
|
101
|
+
),
|
|
102
|
+
]
|
|
103
|
+
gitlab = cfg.registry == "gitlab"
|
|
104
|
+
script += [
|
|
105
|
+
_LOGIN["gitlab" if gitlab else "dockerhub"],
|
|
106
|
+
# Supply-chain metadata stored next to the image; the metadata file gives the pushed digest.
|
|
107
|
+
f"docker buildx build --platform {','.join(cfg.platforms)} --push $cache --provenance=mode=max --sbom=true "
|
|
108
|
+
"--metadata-file metadata.json $tag_args .",
|
|
109
|
+
_DIGEST,
|
|
110
|
+
]
|
|
111
|
+
if cfg.sbom:
|
|
112
|
+
user, password = ("$CI_REGISTRY_USER", "$CI_REGISTRY_PASSWORD") if gitlab else (
|
|
113
|
+
"$DOCKERHUB_USERNAME", "$DOCKERHUB_TOKEN"
|
|
114
|
+
)
|
|
115
|
+
script.append(f'TRIVY_USERNAME="{user}" TRIVY_PASSWORD="{password}" {image_sbom()}')
|
|
116
|
+
if cfg.sign:
|
|
117
|
+
script += [
|
|
118
|
+
"apk add --no-cache cosign",
|
|
119
|
+
# Keyless: the job's OIDC token (id_tokens below) is the identity, recorded in Sigstore's public log.
|
|
120
|
+
'cosign sign --yes "$IMAGE@$DIGEST"',
|
|
121
|
+
]
|
|
122
|
+
if cfg.sbom:
|
|
123
|
+
script.append(f'cosign attest --yes --type cyclonedx --predicate {SBOM_FILE} "$IMAGE@$DIGEST"')
|
|
124
|
+
script.append('echo "Published:"; for tag in $tags; do echo " $IMAGE:$tag"; done; echo " $IMAGE@$DIGEST"')
|
|
125
|
+
if delivery.k8s and delivery.k8s.environments:
|
|
126
|
+
# For the deploy jobs (a dotenv report becomes their variables).
|
|
127
|
+
script.append(f'echo "{DIGEST}=$DIGEST" > {DEPLOY_ENV}')
|
|
128
|
+
|
|
129
|
+
publish = docker_job(
|
|
130
|
+
"publish",
|
|
131
|
+
script,
|
|
132
|
+
timeout=30,
|
|
133
|
+
directory=directory,
|
|
134
|
+
rules=rules,
|
|
135
|
+
variables={"IMAGE": cfg.image},
|
|
136
|
+
# Never cancel a publication halfway, and never run two at once.
|
|
137
|
+
interruptible=False,
|
|
138
|
+
resource_group=f"publish-{name}" if name else "publish",
|
|
139
|
+
)
|
|
140
|
+
if cfg.sign:
|
|
141
|
+
publish["id_tokens"] = {"SIGSTORE_ID_TOKEN": {"aud": "sigstore"}}
|
|
142
|
+
if cfg.sbom:
|
|
143
|
+
sbom = _sbom_path(delivery)
|
|
144
|
+
publish["artifacts"] = {"paths": [sbom], "reports": {"cyclonedx": [sbom]}, "expire_in": "1 week"}
|
|
145
|
+
if delivery.k8s and delivery.k8s.environments:
|
|
146
|
+
reports = publish.setdefault("artifacts", {"expire_in": "1 week"}).setdefault("reports", {})
|
|
147
|
+
reports["dotenv"] = DEPLOY_ENV if directory == "." else f"{directory}/{DEPLOY_ENV}"
|
|
148
|
+
return publish
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _publish_job(delivery: Delivery) -> str:
|
|
152
|
+
return f"{delivery.name}-publish" if delivery.name else "publish"
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _deploy(delivery: Delivery, environment: str, settings: Environment) -> Dict[str, Any]:
|
|
156
|
+
"""Deploy the image just published, by digest, once its signature checks out. KUBECONFIG is a file variable
|
|
157
|
+
scoped to the environment: a cluster each."""
|
|
158
|
+
cfg, k8s, directory = delivery.cfg, delivery.k8s, delivery.directory
|
|
159
|
+
publish_rules = _publish(delivery)["rules"]
|
|
160
|
+
if settings.deploy_on == "branch":
|
|
161
|
+
# Where the image is published from a branch (and the project changed, in a workspace).
|
|
162
|
+
rules = [r for r in publish_rules if not r.get("if", "").startswith("$CI_COMMIT_TAG")]
|
|
163
|
+
else:
|
|
164
|
+
# Production: on v* tags, started by hand (protect the environment to choose who can).
|
|
165
|
+
rules = [{"if": SCHEDULED, "when": "never"}, {"if": "$CI_COMMIT_TAG =~ /^v/", "when": "manual"}]
|
|
166
|
+
script = [] if directory == "." else [f'cd "{directory}"']
|
|
167
|
+
if cfg.sign:
|
|
168
|
+
script += [
|
|
169
|
+
"apk add --no-cache cosign",
|
|
170
|
+
# Signed keylessly by this project's pipelines (the publish job).
|
|
171
|
+
f'cosign verify "${IMAGE}@${DIGEST}" --certificate-identity-regexp "^$CI_SERVER_URL/$CI_PROJECT_PATH//" '
|
|
172
|
+
'--certificate-oidc-issuer "$CI_SERVER_URL" > /dev/null',
|
|
173
|
+
]
|
|
174
|
+
# Without one, helm would try a cluster on localhost.
|
|
175
|
+
script.append(f'[ -n "${{KUBECONFIG:-}}" ] || {{ echo "No KUBECONFIG file variable for the {environment} '
|
|
176
|
+
'environment: add the cluster\'s kubeconfig (CI/CD variable, type File)."; exit 1; }')
|
|
177
|
+
script.append(helm_upgrade(k8s, environment, settings))
|
|
178
|
+
target: Dict[str, Any] = {"name": environment, "action": "start"}
|
|
179
|
+
address = url(settings, k8s.tls)
|
|
180
|
+
if address:
|
|
181
|
+
target["url"] = address
|
|
182
|
+
return {
|
|
183
|
+
"stage": "deploy",
|
|
184
|
+
"image": {"name": HELM_IMAGE, "entrypoint": [""]},
|
|
185
|
+
"needs": [{"job": _publish_job(delivery), "artifacts": True}],
|
|
186
|
+
"rules": rules,
|
|
187
|
+
"variables": {IMAGE: cfg.image},
|
|
188
|
+
"environment": target,
|
|
189
|
+
"resource_group": f"deploy-{environment}-{delivery.name or 'app'}",
|
|
190
|
+
"interruptible": False,
|
|
191
|
+
"timeout": "15 minutes",
|
|
192
|
+
"script": script,
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _sbom_path(delivery: Delivery) -> str:
|
|
197
|
+
return SBOM_FILE if delivery.directory == "." else f"{delivery.directory}/{SBOM_FILE}"
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _release(deliveries: List[Delivery]) -> Dict[str, Any]:
|
|
201
|
+
"""On v* tags, a GitLab release linking to the images' SBOMs, kept with this job's artifacts."""
|
|
202
|
+
links = []
|
|
203
|
+
for d in deliveries:
|
|
204
|
+
url = f"$CI_PROJECT_URL/-/jobs/$CI_JOB_ID/artifacts/raw/{_sbom_path(d)}"
|
|
205
|
+
label = f"{d.name} SBOM (CycloneDX)" if d.name else "SBOM (CycloneDX)"
|
|
206
|
+
links.append({"name": label, "url": url, "link_type": "other"})
|
|
207
|
+
images = ", ".join(d.cfg.image for d in deliveries)
|
|
208
|
+
plural = "s" if len(deliveries) > 1 else ""
|
|
209
|
+
return {
|
|
210
|
+
"stage": "publish",
|
|
211
|
+
"image": GLAB_IMAGE,
|
|
212
|
+
"needs": [{"job": f"{d.name}-publish" if d.name else "publish", "artifacts": True} for d in deliveries],
|
|
213
|
+
"rules": [{"if": "$CI_COMMIT_TAG =~ /^v/"}],
|
|
214
|
+
"script": ['echo "Release $CI_COMMIT_TAG"'],
|
|
215
|
+
"artifacts": {"paths": [_sbom_path(d) for d in deliveries], "expire_in": "never"},
|
|
216
|
+
"release": {
|
|
217
|
+
"tag_name": "$CI_COMMIT_TAG",
|
|
218
|
+
"description": f"Image{plural} {images} ($CI_COMMIT_TAG), with {'their' if plural else 'its'} SBOM "
|
|
219
|
+
"(CycloneDX).",
|
|
220
|
+
"assets": {"links": links},
|
|
221
|
+
},
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def plan(project_dir: Path, target: Union[CDConfig, List[Delivery]]) -> List[PlannedFile]:
|
|
226
|
+
return [planned(project_dir, PIPELINE_PATH, render_pipeline(target))]
|