##// END OF EJS Templates
bundle-spec: add an official method to format a spec into a string...
marmoute -
r51605:0913a49e default
parent child Browse files
Show More
@@ -1,556 +1,562 b''
1 1 # bundlecaches.py - utility to deal with pre-computed bundle for servers
2 2 #
3 3 # This software may be used and distributed according to the terms of the
4 4 # GNU General Public License version 2 or any later version.
5 5
6 6 import collections
7 7
8 8 from typing import (
9 9 cast,
10 10 )
11 11
12 12 from .i18n import _
13 13
14 14 from .thirdparty import attr
15 15
16 16 from . import (
17 17 error,
18 18 requirements as requirementsmod,
19 19 sslutil,
20 20 util,
21 21 )
22 22 from .utils import stringutil
23 23
24 24 urlreq = util.urlreq
25 25
26 26 BUNDLE_CACHE_DIR = b'bundle-cache'
27 27 CB_MANIFEST_FILE = b'clonebundles.manifest'
28 28 CLONEBUNDLESCHEME = b"peer-bundle-cache://"
29 29
30 30
31 31 def get_manifest(repo):
32 32 """get the bundle manifest to be served to a client from a server"""
33 33 raw_text = repo.vfs.tryread(CB_MANIFEST_FILE)
34 34 entries = [e.split(b' ', 1) for e in raw_text.splitlines()]
35 35
36 36 new_lines = []
37 37 for e in entries:
38 38 url = alter_bundle_url(repo, e[0])
39 39 if len(e) == 1:
40 40 line = url + b'\n'
41 41 else:
42 42 line = b"%s %s\n" % (url, e[1])
43 43 new_lines.append(line)
44 44 return b''.join(new_lines)
45 45
46 46
47 47 def alter_bundle_url(repo, url):
48 48 """a function that exist to help extension and hosting to alter the url
49 49
50 50 This will typically be used to inject authentication information in the url
51 51 of cached bundles."""
52 52 return url
53 53
54 54
55 55 SUPPORTED_CLONEBUNDLE_SCHEMES = [
56 56 b"http://",
57 57 b"https://",
58 58 b"largefile://",
59 59 CLONEBUNDLESCHEME,
60 60 ]
61 61
62 62
63 63 @attr.s
64 64 class bundlespec:
65 65 compression = attr.ib()
66 66 wirecompression = attr.ib()
67 67 version = attr.ib()
68 68 wireversion = attr.ib()
69 69 # parameters explicitly overwritten by the config or the specification
70 70 _explicit_params = attr.ib()
71 71 # default parameter for the version
72 72 #
73 73 # Keeping it separated is useful to check what was actually overwritten.
74 74 _default_opts = attr.ib()
75 75
76 76 @property
77 77 def params(self):
78 78 return collections.ChainMap(self._explicit_params, self._default_opts)
79 79
80 80 @property
81 81 def contentopts(self):
82 82 # kept for Backward Compatibility concerns.
83 83 return self.params
84 84
85 85 def set_param(self, key, value, overwrite=True):
86 86 """Set a bundle parameter value.
87 87
88 88 Will only overwrite if overwrite is true"""
89 89 if overwrite or key not in self._explicit_params:
90 90 self._explicit_params[key] = value
91 91
92 def as_spec(self):
93 parts = [b"%s-%s" % (self.compression, self.version)]
94 for param in sorted(self._explicit_params.items()):
95 parts.append(b'%s=%s' % param)
96 return b';'.join(parts)
97
92 98
93 99 # Maps bundle version human names to changegroup versions.
94 100 _bundlespeccgversions = {
95 101 b'v1': b'01',
96 102 b'v2': b'02',
97 103 b'v3': b'03',
98 104 b'packed1': b's1',
99 105 b'bundle2': b'02', # legacy
100 106 }
101 107
102 108 # Maps bundle version with content opts to choose which part to bundle
103 109 _bundlespeccontentopts = {
104 110 b'v1': {
105 111 b'changegroup': True,
106 112 b'cg.version': b'01',
107 113 b'obsolescence': False,
108 114 b'phases': False,
109 115 b'tagsfnodescache': False,
110 116 b'revbranchcache': False,
111 117 },
112 118 b'v2': {
113 119 b'changegroup': True,
114 120 b'cg.version': b'02',
115 121 b'obsolescence': False,
116 122 b'phases': False,
117 123 b'tagsfnodescache': True,
118 124 b'revbranchcache': True,
119 125 },
120 126 b'v3': {
121 127 b'changegroup': True,
122 128 b'cg.version': b'03',
123 129 b'obsolescence': False,
124 130 b'phases': True,
125 131 b'tagsfnodescache': True,
126 132 b'revbranchcache': True,
127 133 },
128 134 b'streamv2': {
129 135 b'changegroup': False,
130 136 b'cg.version': b'02',
131 137 b'obsolescence': False,
132 138 b'phases': False,
133 139 b"streamv2": True,
134 140 b'tagsfnodescache': False,
135 141 b'revbranchcache': False,
136 142 },
137 143 b'streamv3-exp': {
138 144 b'changegroup': False,
139 145 b'cg.version': b'03',
140 146 b'obsolescence': False,
141 147 b'phases': False,
142 148 b"streamv3-exp": True,
143 149 b'tagsfnodescache': False,
144 150 b'revbranchcache': False,
145 151 },
146 152 b'packed1': {
147 153 b'cg.version': b's1',
148 154 },
149 155 b'bundle2': { # legacy
150 156 b'cg.version': b'02',
151 157 },
152 158 }
153 159 _bundlespeccontentopts[b'bundle2'] = _bundlespeccontentopts[b'v2']
154 160
155 161 _bundlespecvariants = {b"streamv2": {}}
156 162
157 163 # Compression engines allowed in version 1. THIS SHOULD NEVER CHANGE.
158 164 _bundlespecv1compengines = {b'gzip', b'bzip2', b'none'}
159 165
160 166
161 167 def param_bool(key, value):
162 168 """make a boolean out of a parameter value"""
163 169 b = stringutil.parsebool(value)
164 170 if b is None:
165 171 msg = _(b"parameter %s should be a boolean ('%s')")
166 172 msg %= (key, value)
167 173 raise error.InvalidBundleSpecification(msg)
168 174 return b
169 175
170 176
171 177 # mapping of known parameter name need their value processed
172 178 bundle_spec_param_processing = {
173 179 b"obsolescence": param_bool,
174 180 b"obsolescence-mandatory": param_bool,
175 181 b"phases": param_bool,
176 182 }
177 183
178 184
179 185 def _parseparams(s):
180 186 """parse bundlespec parameter section
181 187
182 188 input: "comp-version;params" string
183 189
184 190 return: (spec; {param_key: param_value})
185 191 """
186 192 if b';' not in s:
187 193 return s, {}
188 194
189 195 params = {}
190 196 version, paramstr = s.split(b';', 1)
191 197
192 198 err = _(b'invalid bundle specification: missing "=" in parameter: %s')
193 199 for p in paramstr.split(b';'):
194 200 if b'=' not in p:
195 201 msg = err % p
196 202 raise error.InvalidBundleSpecification(msg)
197 203
198 204 key, value = p.split(b'=', 1)
199 205 key = urlreq.unquote(key)
200 206 value = urlreq.unquote(value)
201 207 process = bundle_spec_param_processing.get(key)
202 208 if process is not None:
203 209 value = process(key, value)
204 210 params[key] = value
205 211
206 212 return version, params
207 213
208 214
209 215 def parsebundlespec(repo, spec, strict=True):
210 216 """Parse a bundle string specification into parts.
211 217
212 218 Bundle specifications denote a well-defined bundle/exchange format.
213 219 The content of a given specification should not change over time in
214 220 order to ensure that bundles produced by a newer version of Mercurial are
215 221 readable from an older version.
216 222
217 223 The string currently has the form:
218 224
219 225 <compression>-<type>[;<parameter0>[;<parameter1>]]
220 226
221 227 Where <compression> is one of the supported compression formats
222 228 and <type> is (currently) a version string. A ";" can follow the type and
223 229 all text afterwards is interpreted as URI encoded, ";" delimited key=value
224 230 pairs.
225 231
226 232 If ``strict`` is True (the default) <compression> is required. Otherwise,
227 233 it is optional.
228 234
229 235 Returns a bundlespec object of (compression, version, parameters).
230 236 Compression will be ``None`` if not in strict mode and a compression isn't
231 237 defined.
232 238
233 239 An ``InvalidBundleSpecification`` is raised when the specification is
234 240 not syntactically well formed.
235 241
236 242 An ``UnsupportedBundleSpecification`` is raised when the compression or
237 243 bundle type/version is not recognized.
238 244
239 245 Note: this function will likely eventually return a more complex data
240 246 structure, including bundle2 part information.
241 247 """
242 248 if strict and b'-' not in spec:
243 249 raise error.InvalidBundleSpecification(
244 250 _(
245 251 b'invalid bundle specification; '
246 252 b'must be prefixed with compression: %s'
247 253 )
248 254 % spec
249 255 )
250 256
251 257 pre_args = spec.split(b';', 1)[0]
252 258 if b'-' in pre_args:
253 259 compression, version = spec.split(b'-', 1)
254 260
255 261 if compression not in util.compengines.supportedbundlenames:
256 262 raise error.UnsupportedBundleSpecification(
257 263 _(b'%s compression is not supported') % compression
258 264 )
259 265
260 266 version, params = _parseparams(version)
261 267
262 268 if version not in _bundlespeccontentopts:
263 269 raise error.UnsupportedBundleSpecification(
264 270 _(b'%s is not a recognized bundle version') % version
265 271 )
266 272 else:
267 273 # Value could be just the compression or just the version, in which
268 274 # case some defaults are assumed (but only when not in strict mode).
269 275 assert not strict
270 276
271 277 spec, params = _parseparams(spec)
272 278
273 279 if spec in util.compengines.supportedbundlenames:
274 280 compression = spec
275 281 version = b'v1'
276 282 # Generaldelta repos require v2.
277 283 if requirementsmod.GENERALDELTA_REQUIREMENT in repo.requirements:
278 284 version = b'v2'
279 285 elif requirementsmod.REVLOGV2_REQUIREMENT in repo.requirements:
280 286 version = b'v2'
281 287 # Modern compression engines require v2.
282 288 if compression not in _bundlespecv1compengines:
283 289 version = b'v2'
284 290 elif spec in _bundlespeccontentopts:
285 291 if spec == b'packed1':
286 292 compression = b'none'
287 293 else:
288 294 compression = b'bzip2'
289 295 version = spec
290 296 else:
291 297 raise error.UnsupportedBundleSpecification(
292 298 _(b'%s is not a recognized bundle specification') % spec
293 299 )
294 300
295 301 # Bundle version 1 only supports a known set of compression engines.
296 302 if version == b'v1' and compression not in _bundlespecv1compengines:
297 303 raise error.UnsupportedBundleSpecification(
298 304 _(b'compression engine %s is not supported on v1 bundles')
299 305 % compression
300 306 )
301 307
302 308 # The specification for packed1 can optionally declare the data formats
303 309 # required to apply it. If we see this metadata, compare against what the
304 310 # repo supports and error if the bundle isn't compatible.
305 311 if version == b'packed1' and b'requirements' in params:
306 312 requirements = set(cast(bytes, params[b'requirements']).split(b','))
307 313 missingreqs = requirements - requirementsmod.STREAM_FIXED_REQUIREMENTS
308 314 if missingreqs:
309 315 raise error.UnsupportedBundleSpecification(
310 316 _(b'missing support for repository features: %s')
311 317 % b', '.join(sorted(missingreqs))
312 318 )
313 319
314 320 # Compute contentopts based on the version
315 321 if b"stream" in params:
316 322 # This case is fishy as this mostly derails the version selection
317 323 # mechanism. `stream` bundles are quite specific and used differently
318 324 # as "normal" bundles.
319 325 #
320 326 # (we should probably define a cleaner way to do this and raise a
321 327 # warning when the old way is encountered)
322 328 if params[b"stream"] == b"v2":
323 329 version = b"streamv2"
324 330 if params[b"stream"] == b"v3-exp":
325 331 version = b"streamv3-exp"
326 332 contentopts = _bundlespeccontentopts.get(version, {}).copy()
327 333 if version == b"streamv2" or version == b"streamv3-exp":
328 334 # streamv2 have been reported as "v2" for a while.
329 335 version = b"v2"
330 336
331 337 engine = util.compengines.forbundlename(compression)
332 338 compression, wirecompression = engine.bundletype()
333 339 wireversion = _bundlespeccontentopts[version][b'cg.version']
334 340
335 341 return bundlespec(
336 342 compression, wirecompression, version, wireversion, params, contentopts
337 343 )
338 344
339 345
340 346 def parseclonebundlesmanifest(repo, s):
341 347 """Parses the raw text of a clone bundles manifest.
342 348
343 349 Returns a list of dicts. The dicts have a ``URL`` key corresponding
344 350 to the URL and other keys are the attributes for the entry.
345 351 """
346 352 m = []
347 353 for line in s.splitlines():
348 354 fields = line.split()
349 355 if not fields:
350 356 continue
351 357 attrs = {b'URL': fields[0]}
352 358 for rawattr in fields[1:]:
353 359 key, value = rawattr.split(b'=', 1)
354 360 key = util.urlreq.unquote(key)
355 361 value = util.urlreq.unquote(value)
356 362 attrs[key] = value
357 363
358 364 # Parse BUNDLESPEC into components. This makes client-side
359 365 # preferences easier to specify since you can prefer a single
360 366 # component of the BUNDLESPEC.
361 367 if key == b'BUNDLESPEC':
362 368 try:
363 369 bundlespec = parsebundlespec(repo, value)
364 370 attrs[b'COMPRESSION'] = bundlespec.compression
365 371 attrs[b'VERSION'] = bundlespec.version
366 372 except error.InvalidBundleSpecification:
367 373 pass
368 374 except error.UnsupportedBundleSpecification:
369 375 pass
370 376
371 377 m.append(attrs)
372 378
373 379 return m
374 380
375 381
376 382 def isstreamclonespec(bundlespec):
377 383 # Stream clone v1
378 384 if bundlespec.wirecompression == b'UN' and bundlespec.wireversion == b's1':
379 385 return True
380 386
381 387 # Stream clone v2
382 388 if (
383 389 bundlespec.wirecompression == b'UN'
384 390 and bundlespec.wireversion == b'02'
385 391 and (
386 392 bundlespec.contentopts.get(b'streamv2')
387 393 or bundlespec.contentopts.get(b'streamv3-exp')
388 394 )
389 395 ):
390 396 return True
391 397
392 398 return False
393 399
394 400
395 401 def filterclonebundleentries(
396 402 repo, entries, streamclonerequested=False, pullbundles=False
397 403 ):
398 404 """Remove incompatible clone bundle manifest entries.
399 405
400 406 Accepts a list of entries parsed with ``parseclonebundlesmanifest``
401 407 and returns a new list consisting of only the entries that this client
402 408 should be able to apply.
403 409
404 410 There is no guarantee we'll be able to apply all returned entries because
405 411 the metadata we use to filter on may be missing or wrong.
406 412 """
407 413 newentries = []
408 414 for entry in entries:
409 415 url = entry.get(b'URL')
410 416 if not pullbundles and not any(
411 417 [url.startswith(scheme) for scheme in SUPPORTED_CLONEBUNDLE_SCHEMES]
412 418 ):
413 419 repo.ui.debug(
414 420 b'filtering %s because not a supported clonebundle scheme\n'
415 421 % url
416 422 )
417 423 continue
418 424
419 425 spec = entry.get(b'BUNDLESPEC')
420 426 if spec:
421 427 try:
422 428 bundlespec = parsebundlespec(repo, spec, strict=True)
423 429
424 430 # If a stream clone was requested, filter out non-streamclone
425 431 # entries.
426 432 if streamclonerequested and not isstreamclonespec(bundlespec):
427 433 repo.ui.debug(
428 434 b'filtering %s because not a stream clone\n' % url
429 435 )
430 436 continue
431 437
432 438 except error.InvalidBundleSpecification as e:
433 439 repo.ui.debug(stringutil.forcebytestr(e) + b'\n')
434 440 continue
435 441 except error.UnsupportedBundleSpecification as e:
436 442 repo.ui.debug(
437 443 b'filtering %s because unsupported bundle '
438 444 b'spec: %s\n' % (url, stringutil.forcebytestr(e))
439 445 )
440 446 continue
441 447 # If we don't have a spec and requested a stream clone, we don't know
442 448 # what the entry is so don't attempt to apply it.
443 449 elif streamclonerequested:
444 450 repo.ui.debug(
445 451 b'filtering %s because cannot determine if a stream '
446 452 b'clone bundle\n' % url
447 453 )
448 454 continue
449 455
450 456 if b'REQUIRESNI' in entry and not sslutil.hassni:
451 457 repo.ui.debug(b'filtering %s because SNI not supported\n' % url)
452 458 continue
453 459
454 460 if b'REQUIREDRAM' in entry:
455 461 try:
456 462 requiredram = util.sizetoint(entry[b'REQUIREDRAM'])
457 463 except error.ParseError:
458 464 repo.ui.debug(
459 465 b'filtering %s due to a bad REQUIREDRAM attribute\n' % url
460 466 )
461 467 continue
462 468 actualram = repo.ui.estimatememory()
463 469 if actualram is not None and actualram * 0.66 < requiredram:
464 470 repo.ui.debug(
465 471 b'filtering %s as it needs more than 2/3 of system memory\n'
466 472 % url
467 473 )
468 474 continue
469 475
470 476 newentries.append(entry)
471 477
472 478 return newentries
473 479
474 480
475 481 class clonebundleentry:
476 482 """Represents an item in a clone bundles manifest.
477 483
478 484 This rich class is needed to support sorting since sorted() in Python 3
479 485 doesn't support ``cmp`` and our comparison is complex enough that ``key=``
480 486 won't work.
481 487 """
482 488
483 489 def __init__(self, value, prefers):
484 490 self.value = value
485 491 self.prefers = prefers
486 492
487 493 def _cmp(self, other):
488 494 for prefkey, prefvalue in self.prefers:
489 495 avalue = self.value.get(prefkey)
490 496 bvalue = other.value.get(prefkey)
491 497
492 498 # Special case for b missing attribute and a matches exactly.
493 499 if avalue is not None and bvalue is None and avalue == prefvalue:
494 500 return -1
495 501
496 502 # Special case for a missing attribute and b matches exactly.
497 503 if bvalue is not None and avalue is None and bvalue == prefvalue:
498 504 return 1
499 505
500 506 # We can't compare unless attribute present on both.
501 507 if avalue is None or bvalue is None:
502 508 continue
503 509
504 510 # Same values should fall back to next attribute.
505 511 if avalue == bvalue:
506 512 continue
507 513
508 514 # Exact matches come first.
509 515 if avalue == prefvalue:
510 516 return -1
511 517 if bvalue == prefvalue:
512 518 return 1
513 519
514 520 # Fall back to next attribute.
515 521 continue
516 522
517 523 # If we got here we couldn't sort by attributes and prefers. Fall
518 524 # back to index order.
519 525 return 0
520 526
521 527 def __lt__(self, other):
522 528 return self._cmp(other) < 0
523 529
524 530 def __gt__(self, other):
525 531 return self._cmp(other) > 0
526 532
527 533 def __eq__(self, other):
528 534 return self._cmp(other) == 0
529 535
530 536 def __le__(self, other):
531 537 return self._cmp(other) <= 0
532 538
533 539 def __ge__(self, other):
534 540 return self._cmp(other) >= 0
535 541
536 542 def __ne__(self, other):
537 543 return self._cmp(other) != 0
538 544
539 545
540 546 def sortclonebundleentries(ui, entries):
541 547 prefers = ui.configlist(b'ui', b'clonebundleprefers')
542 548 if not prefers:
543 549 return list(entries)
544 550
545 551 def _split(p):
546 552 if b'=' not in p:
547 553 hint = _(b"each comma separated item should be key=value pairs")
548 554 raise error.Abort(
549 555 _(b"invalid ui.clonebundleprefers item: %s") % p, hint=hint
550 556 )
551 557 return p.split(b'=', 1)
552 558
553 559 prefers = [_split(p) for p in prefers]
554 560
555 561 items = sorted(clonebundleentry(v, prefers) for v in entries)
556 562 return [i.value for i in items]
General Comments 0
You need to be logged in to leave comments. Login now