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
generic_ci/sources.py
ADDED
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
"""Pinned, data-only Git configuration sources; no checkout or source code execution."""
|
|
2
|
+
import argparse
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
import re
|
|
8
|
+
import subprocess
|
|
9
|
+
from urllib.parse import urlsplit
|
|
10
|
+
|
|
11
|
+
import yaml
|
|
12
|
+
from packaging.specifiers import SpecifierSet
|
|
13
|
+
from packaging.version import Version
|
|
14
|
+
from pydantic import Field
|
|
15
|
+
|
|
16
|
+
from . import __version__
|
|
17
|
+
from .config import read_yaml, UniqueLoader
|
|
18
|
+
from .models import Model, relative
|
|
19
|
+
from .workflows.compiler import merge
|
|
20
|
+
from .workflows.models import Pipeline, Platform
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Source(Model):
|
|
24
|
+
repository: str
|
|
25
|
+
ref: str
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ProjectConfig(Model):
|
|
29
|
+
source: Source
|
|
30
|
+
delivery: str = 'delivery.yml'
|
|
31
|
+
platform: str | None = None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class Manifest(Model):
|
|
35
|
+
version: int = 1
|
|
36
|
+
cli: str = '>=0.3.0,<0.4.0'
|
|
37
|
+
defaults: dict[str, str] = Field(default_factory=dict)
|
|
38
|
+
templates: dict[str, str] = Field(default_factory=dict)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def location(root, value):
|
|
42
|
+
relative(value)
|
|
43
|
+
if any(part.lower() == '.git' for part in Path(value).parts):
|
|
44
|
+
raise ValueError('source files must not modify Git metadata')
|
|
45
|
+
result = root / value
|
|
46
|
+
if not result.resolve().is_relative_to(root.resolve()):
|
|
47
|
+
raise ValueError('path escapes its repository')
|
|
48
|
+
return result
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def home():
|
|
52
|
+
return Path(os.environ.get('GENERIC_CI_HOME', Path.home() / '.config/generic-ci'))
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def write_json(path, data):
|
|
56
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
57
|
+
tmp = path.with_suffix(path.suffix + '.tmp')
|
|
58
|
+
tmp.write_text(json.dumps(data, indent=2, sort_keys=True) + '\n')
|
|
59
|
+
tmp.replace(path)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def repository(value):
|
|
63
|
+
# Credentials belong to Git's credential helper or SSH agent, never the lock.
|
|
64
|
+
if value.startswith(('https://', 'ssh://')):
|
|
65
|
+
parsed = urlsplit(value)
|
|
66
|
+
if not parsed.hostname or parsed.password or parsed.query or parsed.fragment or (parsed.scheme == 'https' and parsed.username):
|
|
67
|
+
raise ValueError('repository must not contain credentials, query strings or fragments')
|
|
68
|
+
elif re.fullmatch(r'[\w.-]+@[\w.-]+:[^\s]+', value):
|
|
69
|
+
pass
|
|
70
|
+
elif '://' in value or value.startswith('-'):
|
|
71
|
+
raise ValueError('use HTTPS, SSH, or an existing local Git repository/bundle')
|
|
72
|
+
else:
|
|
73
|
+
path = Path(value).expanduser().resolve()
|
|
74
|
+
value = str(path)
|
|
75
|
+
return value
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def git(cache, *args, binary=False):
|
|
79
|
+
env = dict(os.environ, GIT_TERMINAL_PROMPT='0')
|
|
80
|
+
result = subprocess.run(['git', '-c', 'core.hooksPath=/dev/null', '-C', str(cache), *args], env=env,
|
|
81
|
+
capture_output=True, text=not binary)
|
|
82
|
+
if result.returncode:
|
|
83
|
+
# Git stderr may contain credential-helper output or authenticated URLs.
|
|
84
|
+
raise ValueError('source Git operation failed; check repository access and revision availability')
|
|
85
|
+
return result.stdout if binary else result.stdout.strip()
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def cache_for(repo):
|
|
89
|
+
return home() / 'cache' / hashlib.sha256(repo.encode()).hexdigest()
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def resolve(source, *, commit=None, offline=False, import_from=None):
|
|
93
|
+
repo = repository(source.repository)
|
|
94
|
+
if not source.ref or source.ref.startswith('-') or any(c.isspace() for c in source.ref):
|
|
95
|
+
raise ValueError('source ref must be a Git branch, tag or commit')
|
|
96
|
+
cache = cache_for(repo)
|
|
97
|
+
if commit and not re.fullmatch('[a-f0-9]{40}', commit):
|
|
98
|
+
raise ValueError('source lock must contain a full Git commit')
|
|
99
|
+
if commit and cache.exists():
|
|
100
|
+
try:
|
|
101
|
+
if git(cache, 'rev-parse', '--verify', commit + '^{commit}') == commit:
|
|
102
|
+
return cache, commit
|
|
103
|
+
except ValueError:
|
|
104
|
+
pass
|
|
105
|
+
if offline and not import_from:
|
|
106
|
+
raise ValueError('source revision is not cached; import a checkout/bundle or fetch it while connected')
|
|
107
|
+
if not cache.exists():
|
|
108
|
+
cache.mkdir(parents=True)
|
|
109
|
+
git(cache, 'init', '--bare')
|
|
110
|
+
origin = repository(import_from) if import_from else repo
|
|
111
|
+
revision = commit or source.ref
|
|
112
|
+
git(cache, 'fetch', '--no-tags', '--', origin, revision)
|
|
113
|
+
resolved = git(cache, 'rev-parse', 'FETCH_HEAD^{commit}')
|
|
114
|
+
if commit and resolved != commit:
|
|
115
|
+
raise ValueError('import does not match the locked source commit')
|
|
116
|
+
return cache, resolved
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def blob(cache, commit, path):
|
|
120
|
+
relative(path)
|
|
121
|
+
mode = git(cache, 'ls-tree', commit, '--', path).split()
|
|
122
|
+
if not mode or mode[0] not in {'100644', '100755'}:
|
|
123
|
+
raise ValueError(f'source path must be a regular tracked file: {path}')
|
|
124
|
+
data = git(cache, 'show', commit + ':' + path, binary=True)
|
|
125
|
+
if len(data) > 2_000_000:
|
|
126
|
+
raise ValueError('source file exceeds 2 MB')
|
|
127
|
+
return data
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def yaml_blob(cache, commit, path):
|
|
131
|
+
data = yaml.load(blob(cache, commit, path).decode(), Loader=UniqueLoader)
|
|
132
|
+
if not isinstance(data, dict):
|
|
133
|
+
raise ValueError(f'{path}: expected YAML mapping')
|
|
134
|
+
return data
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def snapshot(source, cache, commit):
|
|
138
|
+
manifest = Manifest.model_validate(yaml_blob(cache, commit, 'generic-ci-source.yml'))
|
|
139
|
+
if manifest.version != 1 or Version(__version__) not in SpecifierSet(manifest.cli):
|
|
140
|
+
raise ValueError('source manifest version or CLI compatibility requirement is not satisfied')
|
|
141
|
+
if set(manifest.defaults) - {'delivery', 'platform'}:
|
|
142
|
+
raise ValueError('source defaults supports only delivery and platform')
|
|
143
|
+
defaults = {key: yaml_blob(cache, commit, path) for key, path in manifest.defaults.items()}
|
|
144
|
+
return {'version': 1, 'source': source.model_dump(), 'commit': commit,
|
|
145
|
+
'manifest': manifest.model_dump(), 'defaults': defaults}
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def lock_path(root):
|
|
149
|
+
return root / 'generic-ci.lock.json'
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def project_config(root):
|
|
153
|
+
return ProjectConfig.model_validate(read_yaml(root / 'generic-ci.yml'))
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def locked(root, *, offline=False, import_from=None):
|
|
157
|
+
config = project_config(root)
|
|
158
|
+
path = lock_path(root)
|
|
159
|
+
if not path.exists():
|
|
160
|
+
raise ValueError('source lock is missing; run generic-ci source update')
|
|
161
|
+
lock = json.loads(path.read_text())
|
|
162
|
+
if lock.get('source') != config.source.model_dump() or lock.get('version') != 1:
|
|
163
|
+
raise ValueError('source configuration changed; run generic-ci source update')
|
|
164
|
+
if not re.fullmatch('[a-f0-9]{40}', str(lock.get('commit', ''))):
|
|
165
|
+
raise ValueError('source lock must contain a full Git commit')
|
|
166
|
+
cache, commit = resolve(config.source, commit=lock['commit'], offline=offline, import_from=import_from)
|
|
167
|
+
actual = snapshot(config.source, cache, commit)
|
|
168
|
+
if lock != actual:
|
|
169
|
+
raise ValueError('source lock content differs from its pinned Git revision')
|
|
170
|
+
return config, actual, cache
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def apply_layers(layers):
|
|
174
|
+
effective, origins = {}, {}
|
|
175
|
+
def mark(value, prefix, owner):
|
|
176
|
+
if isinstance(value, dict) and value:
|
|
177
|
+
for key, child in value.items():
|
|
178
|
+
mark(child, prefix + ('.' if prefix else '') + key, owner)
|
|
179
|
+
else:
|
|
180
|
+
origins[prefix] = owner
|
|
181
|
+
for owner, data in layers:
|
|
182
|
+
if not isinstance(data, dict):
|
|
183
|
+
raise ValueError('configuration layers must be mappings')
|
|
184
|
+
effective = merge(effective, data)
|
|
185
|
+
mark(data, '', owner)
|
|
186
|
+
# Drop origins for leaves replaced by a scalar/list or vice versa.
|
|
187
|
+
valid = {}
|
|
188
|
+
def retain(value, prefix=''):
|
|
189
|
+
if isinstance(value, dict) and value:
|
|
190
|
+
for key, child in value.items():
|
|
191
|
+
retain(child, prefix + ('.' if prefix else '') + key)
|
|
192
|
+
elif prefix in origins:
|
|
193
|
+
valid[prefix] = origins[prefix]
|
|
194
|
+
retain(effective)
|
|
195
|
+
return effective, valid
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def load_project(root, config_override=None, platform_override=None, offline=False):
|
|
199
|
+
config, lock, _ = locked(root, offline=offline)
|
|
200
|
+
delivery = location(root, config_override or config.delivery)
|
|
201
|
+
platform_name = platform_override or config.platform
|
|
202
|
+
delivery_data, dorigins = apply_layers([('source:' + lock['commit'], lock['defaults'].get('delivery', {})),
|
|
203
|
+
('project:' + delivery.relative_to(root).as_posix(), read_yaml(delivery))])
|
|
204
|
+
platform_layers = [('source:' + lock['commit'], lock['defaults'].get('platform', {}))]
|
|
205
|
+
paths = [root / 'generic-ci.yml', lock_path(root), delivery]
|
|
206
|
+
if platform_name:
|
|
207
|
+
local = location(root, platform_name)
|
|
208
|
+
platform_layers.append(('project:' + platform_name, read_yaml(local)))
|
|
209
|
+
paths.append(local)
|
|
210
|
+
platform_data, porigins = apply_layers(platform_layers)
|
|
211
|
+
return Pipeline.model_validate(delivery_data), Platform.model_validate(platform_data), {'delivery': dorigins, 'platform': porigins}, paths
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def initialize(root, template, name=None, repo=None, ref=None, offline=False, delivery=None):
|
|
215
|
+
if repo:
|
|
216
|
+
if not ref:
|
|
217
|
+
raise ValueError('--repo requires --ref')
|
|
218
|
+
source = Source(repository=repository(repo), ref=ref)
|
|
219
|
+
else:
|
|
220
|
+
registry = json.loads((home() / 'sources.json').read_text())
|
|
221
|
+
chosen = name or registry.get('default')
|
|
222
|
+
if chosen not in registry.get('sources', {}):
|
|
223
|
+
raise ValueError('choose a configured source with --source or provide --repo and --ref')
|
|
224
|
+
source = Source.model_validate(registry['sources'][chosen])
|
|
225
|
+
# Registry records the initial pinned revision for offline initialization.
|
|
226
|
+
registry_path = home() / 'pins.json'
|
|
227
|
+
pins = json.loads(registry_path.read_text()) if registry_path.exists() else {}
|
|
228
|
+
pin = pins.get(json.dumps(source.model_dump(), sort_keys=True))
|
|
229
|
+
cache, commit = resolve(source, commit=pin, offline=offline)
|
|
230
|
+
snap = snapshot(source, cache, commit)
|
|
231
|
+
templates = snap['manifest']['templates']
|
|
232
|
+
if not template:
|
|
233
|
+
print('Available templates: ' + ', '.join(sorted(templates)))
|
|
234
|
+
return
|
|
235
|
+
if template not in templates:
|
|
236
|
+
raise ValueError('unknown template; available: ' + ', '.join(sorted(templates)))
|
|
237
|
+
prefix = templates[template].rstrip('/')
|
|
238
|
+
relative(prefix)
|
|
239
|
+
paths = git(cache, 'ls-tree', '-r', '--name-only', commit, '--', prefix).splitlines()
|
|
240
|
+
files = {}
|
|
241
|
+
for path in paths:
|
|
242
|
+
if not path.startswith(prefix + '/'):
|
|
243
|
+
continue
|
|
244
|
+
destination = location(root, path[len(prefix) + 1:])
|
|
245
|
+
files[destination] = blob(cache, commit, path)
|
|
246
|
+
if not files:
|
|
247
|
+
raise ValueError('template contains no files')
|
|
248
|
+
project = ProjectConfig(source=source, delivery=delivery or 'delivery.yml')
|
|
249
|
+
for reserved in [root / 'generic-ci.yml', lock_path(root)]:
|
|
250
|
+
if reserved in files:
|
|
251
|
+
raise ValueError('template cannot supply reserved source/lock files')
|
|
252
|
+
files[root / 'generic-ci.yml'] = yaml.safe_dump(project.model_dump(by_alias=True, exclude_none=True), sort_keys=False).encode()
|
|
253
|
+
files[lock_path(root)] = (json.dumps(snap, indent=2, sort_keys=True) + '\n').encode()
|
|
254
|
+
if location(root, project.delivery) not in files:
|
|
255
|
+
raise ValueError('template does not contain the configured delivery file')
|
|
256
|
+
for path in files:
|
|
257
|
+
if path.exists() or path.is_symlink():
|
|
258
|
+
raise ValueError(f'init will not overwrite {path.relative_to(root)}')
|
|
259
|
+
for path, content in files.items():
|
|
260
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
261
|
+
path.write_bytes(content)
|
|
262
|
+
print(f'Created {template} from source commit {commit}; commit generic-ci.yml and generic-ci.lock.json')
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def source_main(argv):
|
|
266
|
+
parser = argparse.ArgumentParser(prog='generic-ci source')
|
|
267
|
+
sub = parser.add_subparsers(dest='action', required=True)
|
|
268
|
+
add = sub.add_parser('add'); add.add_argument('name'); add.add_argument('--repo', required=True); add.add_argument('--ref', required=True); add.add_argument('--default', action='store_true')
|
|
269
|
+
sub.add_parser('list')
|
|
270
|
+
update = sub.add_parser('update'); update.add_argument('--ref'); update.add_argument('--check', action='store_true')
|
|
271
|
+
fetch = sub.add_parser('fetch'); fetch.add_argument('--from', dest='import_from'); fetch.add_argument('--offline', action='store_true')
|
|
272
|
+
for p in [update, fetch]: p.add_argument('--root', default='.')
|
|
273
|
+
args = parser.parse_args(argv)
|
|
274
|
+
registry_path = home() / 'sources.json'
|
|
275
|
+
registry = json.loads(registry_path.read_text()) if registry_path.exists() else {'sources': {}}
|
|
276
|
+
if args.action == 'list':
|
|
277
|
+
print(json.dumps(registry, indent=2)); return 0
|
|
278
|
+
if args.action == 'add':
|
|
279
|
+
source = Source(repository=repository(args.repo), ref=args.ref)
|
|
280
|
+
cache, commit = resolve(source); snapshot(source, cache, commit)
|
|
281
|
+
registry['sources'][args.name] = source.model_dump()
|
|
282
|
+
if args.default: registry['default'] = args.name
|
|
283
|
+
write_json(registry_path, registry)
|
|
284
|
+
pins_path = home() / 'pins.json'
|
|
285
|
+
pins = json.loads(pins_path.read_text()) if pins_path.exists() else {}
|
|
286
|
+
pins[json.dumps(source.model_dump(), sort_keys=True)] = commit
|
|
287
|
+
write_json(pins_path, pins)
|
|
288
|
+
print(f'Registered {args.name} at {commit}'); return 0
|
|
289
|
+
root = Path(args.root).resolve()
|
|
290
|
+
if args.action == 'fetch':
|
|
291
|
+
_, snap, _ = locked(root, offline=args.offline, import_from=args.import_from)
|
|
292
|
+
print('Cached ' + snap['commit']); return 0
|
|
293
|
+
config = project_config(root)
|
|
294
|
+
if args.ref: config.source.ref = args.ref
|
|
295
|
+
cache, commit = resolve(config.source)
|
|
296
|
+
updated = snapshot(config.source, cache, commit)
|
|
297
|
+
old = json.loads(lock_path(root).read_text()) if lock_path(root).exists() else {}
|
|
298
|
+
import difflib
|
|
299
|
+
before = json.dumps(old.get('defaults', {}), indent=2, sort_keys=True).splitlines(True)
|
|
300
|
+
after = json.dumps(updated['defaults'], indent=2, sort_keys=True).splitlines(True)
|
|
301
|
+
print('Source revision: ' + old.get('commit', '(none)') + ' -> ' + commit)
|
|
302
|
+
print(''.join(difflib.unified_diff(before, after, fromfile='old defaults', tofile='new defaults')), end='')
|
|
303
|
+
# Check the complete effective configuration before recording the update.
|
|
304
|
+
delivery = read_yaml(location(root, config.delivery))
|
|
305
|
+
effective = Pipeline.model_validate(merge(updated['defaults'].get('delivery', {}), delivery))
|
|
306
|
+
platform = updated['defaults'].get('platform', {})
|
|
307
|
+
if config.platform: platform = merge(platform, read_yaml(location(root, config.platform)))
|
|
308
|
+
from .workflows.compiler import compile_pipeline
|
|
309
|
+
compile_pipeline(effective, Platform.model_validate(platform))
|
|
310
|
+
if args.check: return 0 if old == updated else 1
|
|
311
|
+
write_json(lock_path(root), updated)
|
|
312
|
+
(root / 'generic-ci.yml').write_text(yaml.safe_dump(config.model_dump(by_alias=True, exclude_none=True), sort_keys=False))
|
|
313
|
+
print('Updated source lock; review the diff and regenerate CI. Template files were preserved.')
|
|
314
|
+
return 0
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Developer-defined workflows, revision one."""
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
"""Compile event workflows into explicit jobs and verifiable artifact dependencies."""
|
|
2
|
+
import base64
|
|
3
|
+
import copy
|
|
4
|
+
import json
|
|
5
|
+
import re
|
|
6
|
+
import yaml
|
|
7
|
+
from generic_ci import __version__
|
|
8
|
+
from generic_ci.compiler import digest, variants
|
|
9
|
+
from generic_ci.config import read_yaml, allowed_url
|
|
10
|
+
from .models import Pipeline, Platform
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def merge(*values):
|
|
14
|
+
result = {}
|
|
15
|
+
for value in values:
|
|
16
|
+
for key, item in value.items():
|
|
17
|
+
result[key] = merge(result.get(key, {}), item) if isinstance(item, dict) and isinstance(result.get(key, {}), dict) else copy.deepcopy(item)
|
|
18
|
+
return result
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def load(config, platform):
|
|
22
|
+
return Pipeline.model_validate(read_yaml(config)), Platform.model_validate(read_yaml(platform))
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def tag_pattern(tag):
|
|
26
|
+
return '^' + re.escape(tag).replace(re.escape('{version}'), '.+') .replace('/', r'\/') + '$'
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def event_rule(event, project=None):
|
|
30
|
+
if event == 'release':
|
|
31
|
+
return '$CI_COMMIT_TAG =~ /' + tag_pattern(project['release']['tag']) + '/'
|
|
32
|
+
return {'push': '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH',
|
|
33
|
+
'merge-request': '$CI_PIPELINE_SOURCE == "merge_request_event"',
|
|
34
|
+
'manual': '$CI_PIPELINE_SOURCE == "web" && $CI_COMMIT_BRANCH',
|
|
35
|
+
'schedule': '$CI_PIPELINE_SOURCE == "schedule" && $CI_COMMIT_BRANCH'}[event]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def compile_pipeline(pipeline, platform, sources=None):
|
|
39
|
+
p, infra = pipeline.model_dump(), platform.model_dump()
|
|
40
|
+
projects, nodes, refs = p['projects'], {}, {}
|
|
41
|
+
# Defaults on models describe the schema, not explicit override requests.
|
|
42
|
+
for name, project in projects.items():
|
|
43
|
+
source = pipeline.projects[name]
|
|
44
|
+
project['defaults'] = source.defaults.model_dump(exclude_unset=True)
|
|
45
|
+
for key, settings in project['checks'].items():
|
|
46
|
+
explicit = source.checks[key].model_dump(exclude_unset=True)
|
|
47
|
+
for field in ('image', 'tags', 'timeout', 'variables', 'services', 'parallel', 'artifacts'):
|
|
48
|
+
if field not in explicit:
|
|
49
|
+
settings.pop(field, None)
|
|
50
|
+
if isinstance(settings['dependencies'], dict):
|
|
51
|
+
settings['dependencies'] = source.checks[key].dependencies.model_dump(exclude_unset=True)
|
|
52
|
+
hosts = infra['allowed_hosts']
|
|
53
|
+
for value in [*infra['images'].values(), infra['container_builder']['image'], *infra['registries'].values()]:
|
|
54
|
+
allowed_url('https://' + value.split('/')[0], hosts)
|
|
55
|
+
if infra['registries']['containers'] == infra['registries']['previews']:
|
|
56
|
+
raise ValueError('release and preview registries must be distinct repositories')
|
|
57
|
+
for name, project in projects.items():
|
|
58
|
+
for dep in project['depends_on']:
|
|
59
|
+
if dep not in projects or dep == name:
|
|
60
|
+
raise ValueError(f'{name}.depends-on: unknown/self project {dep}')
|
|
61
|
+
if project['container'] and project['container']['repository']:
|
|
62
|
+
allowed_url('https://' + project['container']['repository'].split('/')[0], hosts)
|
|
63
|
+
def project_visit(name, stack):
|
|
64
|
+
if name in stack:
|
|
65
|
+
raise ValueError('project dependency cycle: ' + ' -> '.join([*stack, name]))
|
|
66
|
+
for dep in projects[name]['depends_on']:
|
|
67
|
+
project_visit(dep, [*stack, name])
|
|
68
|
+
for name in projects:
|
|
69
|
+
project_visit(name, [])
|
|
70
|
+
|
|
71
|
+
def add(key, project, event, action, settings, needs=(), execution=None, condition=None, deployment=None):
|
|
72
|
+
candidate = dict(project=project, event=event, action=action, settings=settings,
|
|
73
|
+
needs=list(needs), execution=execution or {}, condition=condition,
|
|
74
|
+
deployment=deployment)
|
|
75
|
+
if key in nodes and nodes[key] != candidate:
|
|
76
|
+
raise ValueError(f'generated job name collision: {key}; rename the user check or project')
|
|
77
|
+
nodes[key] = candidate
|
|
78
|
+
return key
|
|
79
|
+
|
|
80
|
+
def check(name, key, event, context=None):
|
|
81
|
+
project = projects[name]
|
|
82
|
+
if key not in project['checks']:
|
|
83
|
+
raise ValueError(f'{name}: unknown check {key}; available: {list(project["checks"])}')
|
|
84
|
+
settings = project['checks'][key]
|
|
85
|
+
execution = merge(infra['defaults'], project['defaults'], {k: v for k, v in settings.items() if v is not None})
|
|
86
|
+
if settings['dependencies'] is not None and settings['dependencies'] is not False and project['python'] is None:
|
|
87
|
+
raise ValueError(f'{name}.{key}: dependency group overrides require Python')
|
|
88
|
+
rows = variants(execution)
|
|
89
|
+
if settings['outputs'] and len(rows) != 1:
|
|
90
|
+
raise ValueError(f'{name}.{key}: matrix output paths would collide')
|
|
91
|
+
result = []
|
|
92
|
+
for index, row in enumerate(rows):
|
|
93
|
+
identifier = ':'.join(filter(None, [event, context, name, key, str(index + 1) if len(rows) > 1 else None]))
|
|
94
|
+
ex = merge(execution, {'variables': row})
|
|
95
|
+
result.append(add(identifier, name, event, 'check', settings, execution=ex, deployment=context))
|
|
96
|
+
if not context:
|
|
97
|
+
refs[event, name + '.' + key] = result
|
|
98
|
+
return result
|
|
99
|
+
|
|
100
|
+
for name, project in projects.items():
|
|
101
|
+
for event, workflow in project['workflows'].items():
|
|
102
|
+
if event == 'release' and not project['release']:
|
|
103
|
+
raise ValueError(f'{name}: release workflow requires release metadata')
|
|
104
|
+
gates = [job for key in workflow['checks'] for job in check(name, key, event)]
|
|
105
|
+
if project['release']:
|
|
106
|
+
vjob = add(f'{event}:{name}:version', name, event, 'version', project['release'])
|
|
107
|
+
gates.append(vjob)
|
|
108
|
+
operations = workflow['build']
|
|
109
|
+
available = {'application': project['build'], 'container': project['container'], 'package': project['package']}
|
|
110
|
+
if operations is True:
|
|
111
|
+
operations = [kind for kind, setting in available.items() if setting is not None]
|
|
112
|
+
if len(operations) != 1:
|
|
113
|
+
raise ValueError(f'{name}: build: true requires exactly one output; list application/container/package explicitly')
|
|
114
|
+
elif operations is False:
|
|
115
|
+
operations = []
|
|
116
|
+
publication = workflow['publish']
|
|
117
|
+
channel = publication['channel'] if isinstance(publication, dict) else 'auto'
|
|
118
|
+
if publication:
|
|
119
|
+
if channel == 'development':
|
|
120
|
+
if event == 'release':
|
|
121
|
+
raise ValueError(f'{name}: development publication cannot run on release tags')
|
|
122
|
+
elif event != 'release':
|
|
123
|
+
raise ValueError(f'{name}: auto publication requires a release workflow')
|
|
124
|
+
if 'package' not in operations:
|
|
125
|
+
operations = [*operations, 'package']
|
|
126
|
+
built = []
|
|
127
|
+
for kind in operations:
|
|
128
|
+
if available[kind] is None:
|
|
129
|
+
raise ValueError(f'{name}: no {kind} build configured')
|
|
130
|
+
public = {'application': 'build', 'container': 'build-image', 'package': 'build-package'}[kind]
|
|
131
|
+
key = add(f'{event}:{name}:{public}', name, event, kind, available[kind], gates)
|
|
132
|
+
if kind == 'package':
|
|
133
|
+
nodes[key]['settings'] = {**nodes[key]['settings'], 'channel': channel if publication else 'auto'}
|
|
134
|
+
nodes[key]['execution'] = merge(infra['defaults'], project['defaults'])
|
|
135
|
+
refs[event, name + '.' + public] = [key]
|
|
136
|
+
built.append(key)
|
|
137
|
+
if event == 'release' or publication:
|
|
138
|
+
final = add(f'{event}:{name}:publish', name, event, 'publish', {'publish_package': bool(publication), 'channel': channel}, gates + built)
|
|
139
|
+
refs[event, name + '.publish'] = [final]
|
|
140
|
+
release = project['release']
|
|
141
|
+
if release and release['require_bump'] and 'merge-request' not in project['workflows']:
|
|
142
|
+
add(f'merge-request:{name}:version', name, 'merge-request', 'version', release)
|
|
143
|
+
if release and release['create']:
|
|
144
|
+
if 'push' not in project['workflows'] or 'release' not in project['workflows']:
|
|
145
|
+
raise ValueError(f'{name}: release button requires push and release workflows')
|
|
146
|
+
gates = [k for k, n in nodes.items() if n['project'] == name and n['event'] == 'push']
|
|
147
|
+
add(f'push:{name}:create-release', name, 'push', 'create-release', release, gates,
|
|
148
|
+
condition='$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == ' + json.dumps(release['create']['branch']))
|
|
149
|
+
|
|
150
|
+
# Explicit artifact edges never substitute packages. Producers must be selected in that event.
|
|
151
|
+
for key, node in list(nodes.items()):
|
|
152
|
+
if node['action'] not in {'check', 'application', 'container', 'package', 'publish'}:
|
|
153
|
+
continue
|
|
154
|
+
requested = node['settings'].get('needs', [])
|
|
155
|
+
if node['action'] == 'publish' and node['event'] == 'release':
|
|
156
|
+
requested = [dep + '.publish' for dep in projects[node['project']]['release']['needs']]
|
|
157
|
+
for ref in requested:
|
|
158
|
+
ref = ref if '.' in ref else node['project'] + '.' + ref
|
|
159
|
+
if (node['event'], ref) not in refs:
|
|
160
|
+
raise ValueError(f'{key}: producer {ref} is not selected in {node["event"]}')
|
|
161
|
+
node['needs'] += refs[node['event'], ref]
|
|
162
|
+
if node['event'] == 'release':
|
|
163
|
+
dep = ref.split('.')[0]
|
|
164
|
+
if projects[dep]['release']['tag'] != projects[node['project']]['release']['tag']:
|
|
165
|
+
raise ValueError('release artifact/publication dependencies require a coordinated shared release tag')
|
|
166
|
+
|
|
167
|
+
for name, deployment in p['deployments'].items():
|
|
168
|
+
if deployment['target'] not in infra['targets']:
|
|
169
|
+
raise ValueError(f'{name}: unknown target {deployment["target"]}')
|
|
170
|
+
chart = deployment['chart']
|
|
171
|
+
for source in ('repository', 'oci'):
|
|
172
|
+
if chart[source]:
|
|
173
|
+
allowed_url(chart[source], hosts)
|
|
174
|
+
paths = [v for binding in deployment['images'] for v in binding['set'].values() if v]
|
|
175
|
+
if any(a == b or a.startswith(b + '.') or b.startswith(a + '.') for i, a in enumerate(paths) for b in paths[i+1:]):
|
|
176
|
+
raise ValueError(f'{name}: overlapping image mappings')
|
|
177
|
+
for event, behavior in deployment['workflows'].items():
|
|
178
|
+
if event == 'release' and deployment['update'] == 'complete':
|
|
179
|
+
tags = {projects[b['from_'].split('.')[0]]['release']['tag']
|
|
180
|
+
for b in deployment['images']
|
|
181
|
+
if b['from_'].split('.')[0] in projects and projects[b['from_'].split('.')[0]]['release']}
|
|
182
|
+
if len(tags) > 1:
|
|
183
|
+
raise ValueError(f'{name}: complete release deployment requires a coordinated shared release tag; use partial for independent tags')
|
|
184
|
+
producers = []
|
|
185
|
+
conditions = []
|
|
186
|
+
for binding in deployment['images']:
|
|
187
|
+
ref = binding['from_']
|
|
188
|
+
if (event, ref) not in refs or not ref.endswith('.build-image'):
|
|
189
|
+
raise ValueError(f'{name}: image producer {ref} must build in {event}')
|
|
190
|
+
producers += refs[event, ref]
|
|
191
|
+
conditions.append(event_rule(event, projects[ref.split('.')[0]]))
|
|
192
|
+
condition = ' || '.join('(' + c + ')' for c in dict.fromkeys(conditions))
|
|
193
|
+
if event == 'merge-request':
|
|
194
|
+
condition = '(' + condition + ') && $CI_MERGE_REQUEST_SOURCE_PROJECT_ID == $CI_PROJECT_ID'
|
|
195
|
+
key = f'{event}:deploy:{name}'
|
|
196
|
+
prior = producers[:]
|
|
197
|
+
if behavior['when'] == 'manual':
|
|
198
|
+
approval = add(f'{event}:approve:{name}', None, event, 'approve', deployment, producers, condition=condition, deployment=name)
|
|
199
|
+
prior.append(approval)
|
|
200
|
+
before_dependencies = prior[:]
|
|
201
|
+
for ref in deployment['before']['checks']:
|
|
202
|
+
owner, ck = ref.split('.', 1)
|
|
203
|
+
if owner not in projects:
|
|
204
|
+
raise ValueError(f'unknown check owner {owner}')
|
|
205
|
+
for job in check(owner, ck, event, name + '-before'):
|
|
206
|
+
nodes[job]['needs'] = before_dependencies[:]
|
|
207
|
+
nodes[job]['condition'] = condition
|
|
208
|
+
nodes[job]['deployment'] = name
|
|
209
|
+
prior.append(job)
|
|
210
|
+
add(key, None, event, 'deploy', deployment, prior, condition=condition, deployment=name)
|
|
211
|
+
for ref in deployment['after']['checks']:
|
|
212
|
+
owner, ck = ref.split('.', 1)
|
|
213
|
+
if owner not in projects:
|
|
214
|
+
raise ValueError(f'unknown check owner {owner}')
|
|
215
|
+
for job in check(owner, ck, event, name + '-after'):
|
|
216
|
+
nodes[job]['needs'] = [key]
|
|
217
|
+
nodes[job]['condition'] = condition
|
|
218
|
+
nodes[job]['deployment'] = name
|
|
219
|
+
if event == 'merge-request':
|
|
220
|
+
add(f'{event}:stop:{name}', None, event, 'stop', deployment, condition=condition, deployment=name)
|
|
221
|
+
|
|
222
|
+
# Contextual deployment checks retain the same explicit artifact contract.
|
|
223
|
+
for key, node in nodes.items():
|
|
224
|
+
if node['action'] == 'check' and node['deployment']:
|
|
225
|
+
for ref in node['settings'].get('needs', []):
|
|
226
|
+
ref = ref if '.' in ref else node['project'] + '.' + ref
|
|
227
|
+
if (node['event'], ref) not in refs:
|
|
228
|
+
raise ValueError(f'{key}: producer {ref} is not selected in {node["event"]}')
|
|
229
|
+
node['needs'] += refs[node['event'], ref]
|
|
230
|
+
|
|
231
|
+
visiting, visited = set(), set()
|
|
232
|
+
def visit(key):
|
|
233
|
+
if key in visiting:
|
|
234
|
+
raise ValueError(f'job dependency cycle: {key}')
|
|
235
|
+
if key in visited:
|
|
236
|
+
return
|
|
237
|
+
visiting.add(key)
|
|
238
|
+
for dep in nodes[key]['needs']:
|
|
239
|
+
visit(dep)
|
|
240
|
+
visiting.remove(key)
|
|
241
|
+
visited.add(key)
|
|
242
|
+
for key in nodes:
|
|
243
|
+
visit(key)
|
|
244
|
+
if len(nodes) + 1 > infra['max_jobs']:
|
|
245
|
+
raise ValueError('generated job count exceeds platform max-jobs')
|
|
246
|
+
payload = {'version': __version__, 'format': 'workflows-v1', 'pipeline': p, 'platform': infra, 'nodes': nodes, 'sources': sources or {}}
|
|
247
|
+
fingerprint = digest(payload)
|
|
248
|
+
encoded = base64.b64encode(json.dumps(payload, sort_keys=True).encode()).decode()
|
|
249
|
+
if len(encoded) > 100000:
|
|
250
|
+
raise ValueError('execution configuration exceeds 100 KB; split project groups')
|
|
251
|
+
control = infra['images'].get('control', infra['images'].get('python'))
|
|
252
|
+
if not control:
|
|
253
|
+
raise ValueError('platform images.control or images.python is required for the planner')
|
|
254
|
+
output = {'stages': ['prepare', 'delivery'], 'workflow': {'rules': [
|
|
255
|
+
{'if': '$CI_PIPELINE_SOURCE == "merge_request_event"'},
|
|
256
|
+
{'if': '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_TAG'},
|
|
257
|
+
{'if': '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH && $CI_OPEN_MERGE_REQUESTS', 'when': 'never'},
|
|
258
|
+
{'if': '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH'},
|
|
259
|
+
{'if': '$CI_PIPELINE_SOURCE == "web" && $CI_COMMIT_BRANCH'},
|
|
260
|
+
{'if': '$CI_PIPELINE_SOURCE == "schedule" && $CI_COMMIT_BRANCH'}]},
|
|
261
|
+
'variables': {**infra['variables'], 'TOOLKIT_CONFIG_B64': encoded},
|
|
262
|
+
'toolkit-plan': {'stage': 'prepare', 'image': control, 'tags': infra['defaults']['tags'] or [],
|
|
263
|
+
'script': [f'python -m generic_ci.workflows.runtime plan {fingerprint}'],
|
|
264
|
+
'artifacts': {'paths': ['.ci-out/plan.json']}}}
|
|
265
|
+
for key, node in nodes.items():
|
|
266
|
+
project = projects.get(node['project'], {})
|
|
267
|
+
role = 'bun' if (project.get('node') or {}).get('package_manager') == 'bun' else 'node' if project.get('node') else 'python'
|
|
268
|
+
if node['action'] in {'deploy', 'stop'}:
|
|
269
|
+
role = 'helm'
|
|
270
|
+
if node['action'] in {'create-release', 'version', 'publish', 'approve'}:
|
|
271
|
+
role = 'control' if node['action'] != 'publish' else role
|
|
272
|
+
ex = node['execution']
|
|
273
|
+
if not ex.get('image') and role not in infra['images'] and role != 'control' and node['action'] != 'container':
|
|
274
|
+
raise ValueError(f'platform images.{role} is required for {key}')
|
|
275
|
+
image = ex.get('image') or infra['images'].get(role, control)
|
|
276
|
+
tags = ex.get('tags') if ex.get('tags') is not None else infra['defaults']['tags'] or []
|
|
277
|
+
if node['action'] == 'container':
|
|
278
|
+
image = infra['container_builder']['image']
|
|
279
|
+
tags = infra['container_builder']['tags'] or tags
|
|
280
|
+
allowed_url('https://' + image.split('/')[0], hosts)
|
|
281
|
+
for service in ex.get('services') or []:
|
|
282
|
+
allowed_url('https://' + service.split('/')[0], hosts)
|
|
283
|
+
if len(node['needs']) >= 50:
|
|
284
|
+
raise ValueError(f'{key}: exceeds supported needs limit')
|
|
285
|
+
condition = node['condition'] or event_rule(node['event'], project)
|
|
286
|
+
rule = {'if': condition}
|
|
287
|
+
if node['action'] in {'create-release', 'stop', 'approve'}:
|
|
288
|
+
rule.update(when='manual', allow_failure=node['action'] == 'stop')
|
|
289
|
+
job = {'stage': 'delivery', 'image': image, 'tags': tags, 'rules': [rule],
|
|
290
|
+
'script': [f'python -m generic_ci.workflows.runtime run {key} {fingerprint}'],
|
|
291
|
+
'needs': [{'job': 'toolkit-plan', 'artifacts': True}] + [{'job': d, 'artifacts': True, 'optional': True} for d in sorted(set(node['needs']))],
|
|
292
|
+
'artifacts': merge(ex.get('artifacts', {}), {'when': 'always'}), 'variables': ex.get('variables', {}), 'retry': 0}
|
|
293
|
+
job['artifacts']['paths'] = list(dict.fromkeys(job['artifacts'].get('paths', []) + [f'.ci-out/{key}/']))
|
|
294
|
+
for field in ('timeout', 'services'):
|
|
295
|
+
if ex.get(field) is not None:
|
|
296
|
+
job[field] = ex[field]
|
|
297
|
+
if node['action'] in {'deploy', 'stop'}:
|
|
298
|
+
target = infra['targets'][node['settings']['target']]
|
|
299
|
+
release = target['release'] or node['deployment']
|
|
300
|
+
suffix = '/mr-$CI_MERGE_REQUEST_IID' if node['event'] == 'merge-request' else ''
|
|
301
|
+
env = node['settings']['target'] + '/' + release + suffix
|
|
302
|
+
job['resource_group'] = env
|
|
303
|
+
job['environment'] = {'name': env, 'url': target['url']}
|
|
304
|
+
if node['event'] == 'merge-request':
|
|
305
|
+
job['environment'].update(on_stop=f'merge-request:stop:{node["deployment"]}', auto_stop_in=node['settings']['auto_stop_in'])
|
|
306
|
+
if node['action'] == 'stop':
|
|
307
|
+
job['environment'] = {'name': env, 'action': 'stop'}
|
|
308
|
+
job['needs'] = []
|
|
309
|
+
job['variables'] = {'GIT_STRATEGY': 'none'}
|
|
310
|
+
if node['action'] in {'create-release', 'publish'}:
|
|
311
|
+
job['resource_group'] = 'release/' + node['project']
|
|
312
|
+
output[key] = job
|
|
313
|
+
for key in ['toolkit-plan', *nodes]:
|
|
314
|
+
if not output[key].get('tags'):
|
|
315
|
+
output[key].pop('tags', None)
|
|
316
|
+
return output, payload
|