generic-gitlab-cicd 0.3.2__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.
- generic_ci/__init__.py +3 -0
- generic_ci/__main__.py +3 -0
- generic_ci/cli.py +110 -0
- generic_ci/compiler.py +242 -0
- generic_ci/config.py +148 -0
- generic_ci/dependencies.py +186 -0
- generic_ci/models.py +189 -0
- generic_ci/runtime.py +482 -0
- generic_ci/sources.py +314 -0
- generic_ci/workflows/__init__.py +1 -0
- generic_ci/workflows/compiler.py +316 -0
- generic_ci/workflows/ecosystems.py +185 -0
- generic_ci/workflows/helm.py +168 -0
- generic_ci/workflows/models.py +222 -0
- generic_ci/workflows/publication.py +82 -0
- generic_ci/workflows/runtime.py +421 -0
- generic_gitlab_cicd-0.3.2.dist-info/METADATA +170 -0
- generic_gitlab_cicd-0.3.2.dist-info/RECORD +21 -0
- generic_gitlab_cicd-0.3.2.dist-info/WHEEL +5 -0
- generic_gitlab_cicd-0.3.2.dist-info/entry_points.txt +2 -0
- generic_gitlab_cicd-0.3.2.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"""Temporary uv environments with workspace ownership and immutable candidates."""
|
|
2
|
+
import contextlib
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
import re
|
|
8
|
+
import subprocess
|
|
9
|
+
import sys
|
|
10
|
+
import tomllib
|
|
11
|
+
|
|
12
|
+
import tomlkit
|
|
13
|
+
from packaging.requirements import Requirement
|
|
14
|
+
|
|
15
|
+
from .config import allowed_url
|
|
16
|
+
from .models import relative
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def normalize(name):
|
|
20
|
+
return re.sub(r"[-_.]+", "-", name).lower()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def resolve_candidates(environment, hosts, projects):
|
|
24
|
+
single = [environment.get("CI_DEPENDENCY_" + key, "") for key in ("REPO", "REF", "PACKAGE")]
|
|
25
|
+
raw = environment.get("CI_DEPENDENCY_OVERRIDES", "[]") or "[]"
|
|
26
|
+
filename = environment.get("CI_DEPENDENCY_FILE", "")
|
|
27
|
+
decoded = json.loads(raw)
|
|
28
|
+
if not isinstance(decoded, list):
|
|
29
|
+
raise ValueError("CI_DEPENDENCY_OVERRIDES must be a JSON array")
|
|
30
|
+
if sum([bool(any(single)), bool(decoded), bool(filename)]) > 1:
|
|
31
|
+
raise ValueError("provide only one override input: single fields, JSON, or file")
|
|
32
|
+
if filename:
|
|
33
|
+
decoded = json.loads(Path(filename).read_text())
|
|
34
|
+
if any(single):
|
|
35
|
+
if not all(single):
|
|
36
|
+
raise ValueError("provide repository, ref and package together")
|
|
37
|
+
decoded = [dict(zip(("repository", "ref", "package"), single),
|
|
38
|
+
subdirectory=environment.get("CI_DEPENDENCY_SUBDIRECTORY", ""))]
|
|
39
|
+
if not isinstance(decoded, list):
|
|
40
|
+
raise ValueError("dependency file must contain a JSON array")
|
|
41
|
+
seen, result = set(), []
|
|
42
|
+
for item in decoded:
|
|
43
|
+
if not isinstance(item, dict) or set(item) - {"repository", "repo", "ref", "package", "subdirectory", "projects"}:
|
|
44
|
+
raise ValueError("override fields: repository, ref, package, subdirectory, projects")
|
|
45
|
+
if "repo" in item and "repository" in item:
|
|
46
|
+
raise ValueError("use repository or legacy repo, not both")
|
|
47
|
+
repo, ref, name = item.get("repository", item.get("repo", "")), item.get("ref", ""), item.get("package", "")
|
|
48
|
+
if not all(isinstance(v, str) for v in (repo, ref, name)):
|
|
49
|
+
raise ValueError("override repository/ref/package must be strings")
|
|
50
|
+
allowed_url(repo, hosts)
|
|
51
|
+
if not re.fullmatch(r"(?:@[A-Za-z0-9._-]+/)?[A-Za-z0-9][A-Za-z0-9._-]*", name):
|
|
52
|
+
raise ValueError("invalid override distribution name")
|
|
53
|
+
if normalize(name) in seen:
|
|
54
|
+
raise ValueError(f"duplicate override package {name}")
|
|
55
|
+
seen.add(normalize(name))
|
|
56
|
+
if not ref or ref.startswith("-") or re.search(r"[\s~^:?*\[\\]", ref):
|
|
57
|
+
raise ValueError("invalid dependency ref")
|
|
58
|
+
sub = item.get("subdirectory", "")
|
|
59
|
+
if sub:
|
|
60
|
+
relative(sub)
|
|
61
|
+
scope = item.get("projects", list(projects))
|
|
62
|
+
if not isinstance(scope, list) or not scope or any(p not in projects for p in scope):
|
|
63
|
+
raise ValueError("override projects must name existing projects")
|
|
64
|
+
if re.fullmatch(r"[a-fA-F0-9]{40}", ref):
|
|
65
|
+
sha = ref.lower()
|
|
66
|
+
else:
|
|
67
|
+
wanted = [ref] if ref.startswith("refs/") else ["refs/heads/" + ref, "refs/tags/" + ref]
|
|
68
|
+
output = subprocess.check_output(["git", "ls-remote", "--exit-code", repo, *wanted, *[v + "^{}" for v in wanted]], text=True)
|
|
69
|
+
matches = dict(line.split()[::-1] for line in output.splitlines())
|
|
70
|
+
found = [v for v in wanted if v in matches]
|
|
71
|
+
if len(found) != 1:
|
|
72
|
+
raise ValueError("ambiguous/missing dependency ref; use refs/heads/... or refs/tags/...")
|
|
73
|
+
sha = matches.get(found[0] + "^{}", matches[found[0]])
|
|
74
|
+
result.append({"package": name, "repository": repo, "requested_ref": ref, "commit": sha,
|
|
75
|
+
"subdirectory": sub, "projects": scope})
|
|
76
|
+
return result
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def workspace(directory, repository):
|
|
80
|
+
directory, repository = Path(directory).resolve(), Path(repository).resolve()
|
|
81
|
+
directory.relative_to(repository)
|
|
82
|
+
root = directory
|
|
83
|
+
for parent in (directory, *directory.parents):
|
|
84
|
+
if not parent.is_relative_to(repository):
|
|
85
|
+
break
|
|
86
|
+
manifest = parent / "pyproject.toml"
|
|
87
|
+
if manifest.is_file():
|
|
88
|
+
data = tomllib.loads(manifest.read_text())
|
|
89
|
+
settings = data.get("tool", {}).get("uv", {}).get("workspace")
|
|
90
|
+
if settings:
|
|
91
|
+
members = {p.resolve() for pattern in settings.get("members", []) for p in parent.glob(pattern)}
|
|
92
|
+
excluded = {p.resolve() for pattern in settings.get("exclude", []) for p in parent.glob(pattern)}
|
|
93
|
+
if directory == parent or directory in members - excluded:
|
|
94
|
+
root = parent
|
|
95
|
+
break
|
|
96
|
+
return root
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def validate_indexes(root, hosts):
|
|
100
|
+
data = tomllib.loads((root / "pyproject.toml").read_text())
|
|
101
|
+
uv = data.get("tool", {}).get("uv", {})
|
|
102
|
+
for index in uv.get("index", []):
|
|
103
|
+
for key in ("url", "publish-url"):
|
|
104
|
+
if key in index:
|
|
105
|
+
allowed_url(index[key], hosts)
|
|
106
|
+
for key in ("PIP_INDEX_URL", "PIP_EXTRA_INDEX_URL", "UV_INDEX_URL", "UV_DEFAULT_INDEX", "UV_EXTRA_INDEX_URL"):
|
|
107
|
+
for url in os.environ.get(key, "").split():
|
|
108
|
+
allowed_url(url, hosts)
|
|
109
|
+
for dependency in data.get("project", {}).get("dependencies", []):
|
|
110
|
+
url = Requirement(dependency).url
|
|
111
|
+
if url and not url.startswith("file:"):
|
|
112
|
+
allowed_url(url.removeprefix("git+"), hosts)
|
|
113
|
+
return data
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@contextlib.contextmanager
|
|
117
|
+
def prepared(directory, repository, policy, candidates, project, hosts, record_dir):
|
|
118
|
+
"""Keep temporary metadata active until caller completes; always restore it."""
|
|
119
|
+
if policy["manager"] == "none":
|
|
120
|
+
yield {"manager": "none"}
|
|
121
|
+
return
|
|
122
|
+
root = workspace(directory, repository)
|
|
123
|
+
manifest, lock = root / "pyproject.toml", root / "uv.lock"
|
|
124
|
+
if not lock.is_file():
|
|
125
|
+
raise ValueError(f"commit workspace lockfile {lock}")
|
|
126
|
+
validate_indexes(root, hosts)
|
|
127
|
+
original, original_lock = manifest.read_bytes(), lock.read_bytes()
|
|
128
|
+
selected = [r for r in candidates if project in r["projects"]]
|
|
129
|
+
groups = policy.get("groups")
|
|
130
|
+
options = [] if groups is None else ["--no-default-groups"]
|
|
131
|
+
if groups == "all" or (groups is not None and "*" in groups):
|
|
132
|
+
options += ["--all-groups"]
|
|
133
|
+
else:
|
|
134
|
+
for group in groups or []:
|
|
135
|
+
options += ["--group", group]
|
|
136
|
+
if policy["extras"] == "all":
|
|
137
|
+
options += ["--all-extras"]
|
|
138
|
+
else:
|
|
139
|
+
for extra in policy["extras"]:
|
|
140
|
+
options += ["--extra", extra]
|
|
141
|
+
if Path(directory).resolve() != root:
|
|
142
|
+
member = tomllib.loads((Path(directory) / "pyproject.toml").read_text())["project"]["name"]
|
|
143
|
+
options += ["--package", member]
|
|
144
|
+
environment = {**os.environ, "UV_PYTHON_DOWNLOADS": "never", "UV_PROJECT_ENVIRONMENT": str(root / ".venv")}
|
|
145
|
+
python_options = ["--python", policy["python"]] if policy.get("python") else []
|
|
146
|
+
try:
|
|
147
|
+
data = tomlkit.parse(original.decode())
|
|
148
|
+
uv = data.setdefault("tool", {}).setdefault("uv", {})
|
|
149
|
+
if selected:
|
|
150
|
+
names = {normalize(r["package"]) for r in selected}
|
|
151
|
+
previous = uv.get("override-dependencies", [])
|
|
152
|
+
if any(not isinstance(v, str) for v in previous):
|
|
153
|
+
raise ValueError("scoped uv overrides require an explicit adapter; string overrides supported")
|
|
154
|
+
uv["override-dependencies"] = [v for v in previous if normalize(Requirement(v).name) not in names] + [
|
|
155
|
+
f"{r['package']} @ git+{r['repository']}@{r['commit']}" + (f"#subdirectory={r['subdirectory']}" if r["subdirectory"] else "") for r in selected]
|
|
156
|
+
for key in list(uv.get("sources", {})):
|
|
157
|
+
if normalize(key) in names:
|
|
158
|
+
del uv["sources"][key]
|
|
159
|
+
manifest.write_text(tomlkit.dumps(data))
|
|
160
|
+
upgrade = policy["upgrade"]
|
|
161
|
+
upgrade_options = ["--upgrade"] if upgrade == "all" else []
|
|
162
|
+
if isinstance(upgrade, list):
|
|
163
|
+
for package in upgrade:
|
|
164
|
+
upgrade_options += ["--upgrade-package", package]
|
|
165
|
+
if selected or upgrade != "none":
|
|
166
|
+
subprocess.run(["uv", "lock", *upgrade_options, *python_options], cwd=root, env=environment, check=True)
|
|
167
|
+
subprocess.run(["uv", "sync", "--locked", *options, *python_options], cwd=root, env=environment, check=True)
|
|
168
|
+
interpreter = root / ".venv" / ("Scripts/python.exe" if os.name == "nt" else "bin/python")
|
|
169
|
+
actual = subprocess.check_output([str(interpreter), "-c", "import platform; print(platform.python_version())"], text=True).strip()
|
|
170
|
+
if policy.get("python") and not (actual == policy["python"] or actual.startswith(policy["python"] + ".")):
|
|
171
|
+
raise ValueError(f"requested Python {policy['python']}; got {actual}")
|
|
172
|
+
for candidate in selected:
|
|
173
|
+
code = "import importlib.metadata as m; print(m.distribution(__import__('sys').argv[1]).read_text('direct_url.json') or '{}')"
|
|
174
|
+
source = json.loads(subprocess.check_output([str(interpreter), "-c", code, candidate["package"]], text=True))
|
|
175
|
+
if source.get("vcs_info", {}).get("commit_id") != candidate["commit"] or source.get("url", "").rstrip("/") != candidate["repository"].rstrip("/"):
|
|
176
|
+
raise ValueError(f"installed {candidate['package']} does not match candidate repository/commit")
|
|
177
|
+
record_dir = Path(record_dir)
|
|
178
|
+
record_dir.mkdir(parents=True, exist_ok=True)
|
|
179
|
+
(record_dir / "uv.lock").write_bytes(lock.read_bytes())
|
|
180
|
+
record = {"manager": "uv", "python": actual, "workspace": root.relative_to(repository).as_posix(),
|
|
181
|
+
"lock_sha256": hashlib.sha256(lock.read_bytes()).hexdigest(), "candidates": selected,
|
|
182
|
+
"policy": policy, "interpreter": str(interpreter)}
|
|
183
|
+
yield record
|
|
184
|
+
finally:
|
|
185
|
+
manifest.write_bytes(original)
|
|
186
|
+
lock.write_bytes(original_lock)
|
generic_ci/models.py
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"""Toolkit vocabulary. Native job execution fields retain their GitLab meanings."""
|
|
2
|
+
from typing import Annotated, Any, Literal
|
|
3
|
+
from pathlib import PurePosixPath
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
|
6
|
+
|
|
7
|
+
Name = Annotated[str, Field(pattern=r"^[a-z][a-z0-9-]{0,39}$")]
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Model(BaseModel):
|
|
11
|
+
model_config = ConfigDict(extra="forbid", populate_by_name=True, strict=True,
|
|
12
|
+
alias_generator=lambda value: value.replace("_", "-"))
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def relative(value: str) -> str:
|
|
16
|
+
if not value or "\\" in value or "$" in value or ":" in value:
|
|
17
|
+
raise ValueError("use a literal POSIX relative path")
|
|
18
|
+
path = PurePosixPath(value)
|
|
19
|
+
if path.is_absolute() or ".." in path.parts:
|
|
20
|
+
raise ValueError("path must remain inside the repository")
|
|
21
|
+
return value
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class RootPath(Model):
|
|
25
|
+
repository: str = Field(description="Explicit path relative to the repository root.")
|
|
26
|
+
_path = field_validator("repository")(relative)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class Dependencies(Model):
|
|
30
|
+
manager: Literal["uv", "none"] = "uv"
|
|
31
|
+
upgrade: Literal["none", "all"] | list[str] = Field(default="none", description="Upgrade policy within project constraints; changes are temporary.")
|
|
32
|
+
groups: list[str] = Field(default_factory=lambda: ["*"], description="Dependency groups; * selects all, [] excludes dev groups.")
|
|
33
|
+
extras: list[str] = Field(default_factory=list)
|
|
34
|
+
python: str | None = Field(default=None, description="Required interpreter version, verified without downloading Python.")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class NativeJob(Model):
|
|
38
|
+
model_config = ConfigDict(extra="forbid", populate_by_name=True, strict=True, alias_generator=None)
|
|
39
|
+
image: str | None = None
|
|
40
|
+
tags: list[str] | None = None
|
|
41
|
+
services: list[Any] | None = None
|
|
42
|
+
parallel: dict[str, Any] | None = Field(default=None, description="Native parallel:matrix; compiled into explicit jobs for receipt identity.")
|
|
43
|
+
rules: list[dict[str, Any]] | None = None
|
|
44
|
+
timeout: str | None = None
|
|
45
|
+
cache: dict[str, Any] | None = None
|
|
46
|
+
resource_group: str | None = None
|
|
47
|
+
variables: dict[str, str] = Field(default_factory=dict)
|
|
48
|
+
allow_failure: bool = False
|
|
49
|
+
|
|
50
|
+
@field_validator("variables")
|
|
51
|
+
@classmethod
|
|
52
|
+
def reserved(cls, value):
|
|
53
|
+
if any(k.startswith(("CI_", "TOOLKIT_")) or k in {"PYTHONPATH", "PYTHONHOME"} for k in value):
|
|
54
|
+
raise ValueError("CI_*, TOOLKIT_* and Python bootstrap variables are reserved")
|
|
55
|
+
return value
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class Check(Model):
|
|
59
|
+
runtime: str = Field(default="default", description="Platform runtime profile; selects tools and shell, independently of runner tags.")
|
|
60
|
+
script: list[str] | None = Field(default=None, min_length=1, description="Commands after dependency setup; omit only when inheriting a preset check.")
|
|
61
|
+
dependencies: Dependencies | None = None
|
|
62
|
+
gitlab: NativeJob = Field(default_factory=NativeJob)
|
|
63
|
+
needs: list[str] = Field(default_factory=list, description="Public step references: step or project.step.")
|
|
64
|
+
junit: list[str] = Field(default_factory=list, description="Project-relative report paths.")
|
|
65
|
+
_paths = field_validator("junit")(lambda paths: [relative(p) for p in paths])
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class Step(Check):
|
|
69
|
+
script: list[str] = Field(min_length=1)
|
|
70
|
+
outputs: dict[Name, str] = Field(default_factory=dict, description="Named files/directories passed to dependent steps and builds.")
|
|
71
|
+
_output_paths = field_validator("outputs")(lambda paths: {k: relative(v) for k, v in paths.items()})
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class Package(Model):
|
|
75
|
+
directory: str = Field(default=".", description="Package build directory relative to project.path; useful for generated SDKs.")
|
|
76
|
+
index: str | None = Field(default=None, description="Named tool.uv.index in pyproject; required to publish.")
|
|
77
|
+
needs: list[str] = Field(default_factory=list)
|
|
78
|
+
checks: list[str] | None = None
|
|
79
|
+
_directory = field_validator("directory")(relative)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class Container(Model):
|
|
83
|
+
dockerfile: str = "Dockerfile"
|
|
84
|
+
context: str | RootPath = "."
|
|
85
|
+
repository: str | None = None
|
|
86
|
+
platform: str = "linux/amd64"
|
|
87
|
+
target: str | None = Field(default=None, description="Optional Dockerfile build stage.")
|
|
88
|
+
build_args: dict[str, str] = Field(default_factory=dict)
|
|
89
|
+
secrets: dict[str, str] = Field(default_factory=dict, description="BuildKit secret ID to file-type CI variable name.")
|
|
90
|
+
needs: list[str] = Field(default_factory=list)
|
|
91
|
+
checks: list[str] | None = None
|
|
92
|
+
dependency_bundle: bool = Field(default=False, description="Dockerfile explicitly consumes named ci-dependencies context (see recipe).")
|
|
93
|
+
_dockerfile = field_validator("dockerfile")(relative)
|
|
94
|
+
|
|
95
|
+
@field_validator("context")
|
|
96
|
+
@classmethod
|
|
97
|
+
def path(cls, value):
|
|
98
|
+
return relative(value) if isinstance(value, str) else value
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class ImageBinding(Model):
|
|
102
|
+
repository: list[str] = Field(min_length=1, description="Path in chart values for image repository.")
|
|
103
|
+
digest: list[str] = Field(min_length=1, description="Path in chart values for image sha256 digest.")
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class Deployment(Model):
|
|
107
|
+
target: str
|
|
108
|
+
chart: str | None = None
|
|
109
|
+
chart_version: str | None = None
|
|
110
|
+
values: list[str] = Field(default_factory=list, description="Project-relative values files, applied in order.")
|
|
111
|
+
checks: list[str] | None = Field(default=None, description="Required source checks; omitted inherits all enabled project checks, [] opts out.")
|
|
112
|
+
before: list[str] = Field(default_factory=list, description="Fresh commands after approval and before rollout, e.g. migrations.")
|
|
113
|
+
after: list[str] = Field(default_factory=list, description="Fresh commands after rollout; failure marks deployment failed.")
|
|
114
|
+
images: dict[str, ImageBinding] = Field(default_factory=dict, description="Container project to chart values binding; stock chart defaults to own container.")
|
|
115
|
+
preview: bool = False
|
|
116
|
+
auto_stop_in: str = "2 days"
|
|
117
|
+
when: Literal["manual", "on_success"] = "manual"
|
|
118
|
+
_values = field_validator("values")(lambda paths: [relative(p) for p in paths])
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class Release(Model):
|
|
122
|
+
tag: str = Field(default="v{version}", description="Existing tag pattern; exactly one {version} placeholder.")
|
|
123
|
+
version_file: str = "pyproject.toml"
|
|
124
|
+
bump: bool = True
|
|
125
|
+
needs: list[str] = Field(default_factory=list, description="Projects whose publication must complete first.")
|
|
126
|
+
notes: str = "CHANGELOG.md"
|
|
127
|
+
gitlab_release: bool = True
|
|
128
|
+
_version = field_validator("version_file", "notes")(relative)
|
|
129
|
+
|
|
130
|
+
@field_validator("tag")
|
|
131
|
+
@classmethod
|
|
132
|
+
def pattern(cls, value):
|
|
133
|
+
if value.count("{version}") != 1 or "{" in value.replace("{version}", ""):
|
|
134
|
+
raise ValueError("tag must contain exactly one {version}")
|
|
135
|
+
return value
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
class Project(Model):
|
|
139
|
+
path: str = "."
|
|
140
|
+
preset: Literal["generic", "python-package", "python-service"] = "generic"
|
|
141
|
+
dependencies: Dependencies | None = None
|
|
142
|
+
checks: dict[Name, Check | Literal[False]] = Field(default_factory=dict)
|
|
143
|
+
steps: dict[Name, Step] = Field(default_factory=dict)
|
|
144
|
+
package: Package | None = None
|
|
145
|
+
container: Container | None = None
|
|
146
|
+
deploy: dict[Name, Deployment] = Field(default_factory=dict)
|
|
147
|
+
release: Release | None = None
|
|
148
|
+
depends_on: list[str] = Field(default_factory=list, description="Project dependencies used for transitive changed-only selection.")
|
|
149
|
+
watch: list[str] = Field(default_factory=list, description="Additional repository-relative changed paths/globs.")
|
|
150
|
+
_path = field_validator("path")(relative)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
class Pipeline(Model):
|
|
154
|
+
schema_version: Literal[1] = 1
|
|
155
|
+
projects: dict[Name, Project] = Field(min_length=1)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
class Runtime(Model):
|
|
159
|
+
image: str | None = Field(default=None, description="Approved image with generic-gitlab-ci at the compiler version plus required tools.")
|
|
160
|
+
tags: list[str] = Field(default_factory=list)
|
|
161
|
+
shell: Literal["sh", "powershell"] = "sh"
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
class Target(Model):
|
|
165
|
+
namespace: str
|
|
166
|
+
release_prefix: str = "app"
|
|
167
|
+
url: str = ""
|
|
168
|
+
production: bool = True
|
|
169
|
+
kubeconfig_variable: str = "KUBECONFIG"
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
class Platform(Model):
|
|
173
|
+
schema_version: Literal[1] = 1
|
|
174
|
+
runtimes: dict[str, Runtime] = Field(description="Must include default; optional build, helm, release runtime overrides.")
|
|
175
|
+
registry: str
|
|
176
|
+
preview_registry: str
|
|
177
|
+
chart: str
|
|
178
|
+
chart_version: str
|
|
179
|
+
targets: dict[str, Target] = Field(default_factory=dict)
|
|
180
|
+
allowed_hosts: list[str] = Field(min_length=1, description="Internal Git, registry, index and chart hosts; no implicit public fallback.")
|
|
181
|
+
max_jobs: int = Field(default=150, ge=1, le=1000)
|
|
182
|
+
artifact_retention: str = "30 days"
|
|
183
|
+
mandatory_checks: list[str] = Field(default_factory=list)
|
|
184
|
+
|
|
185
|
+
@model_validator(mode="after")
|
|
186
|
+
def default_runtime(self):
|
|
187
|
+
if "default" not in self.runtimes:
|
|
188
|
+
raise ValueError("runtimes.default is required")
|
|
189
|
+
return self
|