mercurial/bundlecaches.py
changeset 45784 74271829ddc0
child 45785 80f32ec8653a
equal deleted inserted replaced
45783:88a47cbf063c 45784:74271829ddc0
       
     1 # bundlecaches.py - utility to deal with pre-computed bundle for servers
       
     2 #
       
     3 # This software may be used and distributed according to the terms of the
       
     4 # GNU General Public License version 2 or any later version.
       
     5 
       
     6 from .i18n import _
       
     7 
       
     8 from .thirdparty import attr
       
     9 
       
    10 from . import (
       
    11     error,
       
    12     sslutil,
       
    13     util,
       
    14 )
       
    15 from .utils import stringutil
       
    16 
       
    17 urlreq = util.urlreq
       
    18 
       
    19 
       
    20 @attr.s
       
    21 class bundlespec(object):
       
    22     compression = attr.ib()
       
    23     wirecompression = attr.ib()
       
    24     version = attr.ib()
       
    25     wireversion = attr.ib()
       
    26     params = attr.ib()
       
    27     contentopts = attr.ib()
       
    28 
       
    29 
       
    30 # Maps bundle version human names to changegroup versions.
       
    31 _bundlespeccgversions = {
       
    32     b'v1': b'01',
       
    33     b'v2': b'02',
       
    34     b'packed1': b's1',
       
    35     b'bundle2': b'02',  # legacy
       
    36 }
       
    37 
       
    38 # Maps bundle version with content opts to choose which part to bundle
       
    39 _bundlespeccontentopts = {
       
    40     b'v1': {
       
    41         b'changegroup': True,
       
    42         b'cg.version': b'01',
       
    43         b'obsolescence': False,
       
    44         b'phases': False,
       
    45         b'tagsfnodescache': False,
       
    46         b'revbranchcache': False,
       
    47     },
       
    48     b'v2': {
       
    49         b'changegroup': True,
       
    50         b'cg.version': b'02',
       
    51         b'obsolescence': False,
       
    52         b'phases': False,
       
    53         b'tagsfnodescache': True,
       
    54         b'revbranchcache': True,
       
    55     },
       
    56     b'packed1': {b'cg.version': b's1'},
       
    57 }
       
    58 _bundlespeccontentopts[b'bundle2'] = _bundlespeccontentopts[b'v2']
       
    59 
       
    60 _bundlespecvariants = {
       
    61     b"streamv2": {
       
    62         b"changegroup": False,
       
    63         b"streamv2": True,
       
    64         b"tagsfnodescache": False,
       
    65         b"revbranchcache": False,
       
    66     }
       
    67 }
       
    68 
       
    69 # Compression engines allowed in version 1. THIS SHOULD NEVER CHANGE.
       
    70 _bundlespecv1compengines = {b'gzip', b'bzip2', b'none'}
       
    71 
       
    72 
       
    73 def parsebundlespec(repo, spec, strict=True):
       
    74     """Parse a bundle string specification into parts.
       
    75 
       
    76     Bundle specifications denote a well-defined bundle/exchange format.
       
    77     The content of a given specification should not change over time in
       
    78     order to ensure that bundles produced by a newer version of Mercurial are
       
    79     readable from an older version.
       
    80 
       
    81     The string currently has the form:
       
    82 
       
    83        <compression>-<type>[;<parameter0>[;<parameter1>]]
       
    84 
       
    85     Where <compression> is one of the supported compression formats
       
    86     and <type> is (currently) a version string. A ";" can follow the type and
       
    87     all text afterwards is interpreted as URI encoded, ";" delimited key=value
       
    88     pairs.
       
    89 
       
    90     If ``strict`` is True (the default) <compression> is required. Otherwise,
       
    91     it is optional.
       
    92 
       
    93     Returns a bundlespec object of (compression, version, parameters).
       
    94     Compression will be ``None`` if not in strict mode and a compression isn't
       
    95     defined.
       
    96 
       
    97     An ``InvalidBundleSpecification`` is raised when the specification is
       
    98     not syntactically well formed.
       
    99 
       
   100     An ``UnsupportedBundleSpecification`` is raised when the compression or
       
   101     bundle type/version is not recognized.
       
   102 
       
   103     Note: this function will likely eventually return a more complex data
       
   104     structure, including bundle2 part information.
       
   105     """
       
   106 
       
   107     def parseparams(s):
       
   108         if b';' not in s:
       
   109             return s, {}
       
   110 
       
   111         params = {}
       
   112         version, paramstr = s.split(b';', 1)
       
   113 
       
   114         for p in paramstr.split(b';'):
       
   115             if b'=' not in p:
       
   116                 raise error.InvalidBundleSpecification(
       
   117                     _(
       
   118                         b'invalid bundle specification: '
       
   119                         b'missing "=" in parameter: %s'
       
   120                     )
       
   121                     % p
       
   122                 )
       
   123 
       
   124             key, value = p.split(b'=', 1)
       
   125             key = urlreq.unquote(key)
       
   126             value = urlreq.unquote(value)
       
   127             params[key] = value
       
   128 
       
   129         return version, params
       
   130 
       
   131     if strict and b'-' not in spec:
       
   132         raise error.InvalidBundleSpecification(
       
   133             _(
       
   134                 b'invalid bundle specification; '
       
   135                 b'must be prefixed with compression: %s'
       
   136             )
       
   137             % spec
       
   138         )
       
   139 
       
   140     if b'-' in spec:
       
   141         compression, version = spec.split(b'-', 1)
       
   142 
       
   143         if compression not in util.compengines.supportedbundlenames:
       
   144             raise error.UnsupportedBundleSpecification(
       
   145                 _(b'%s compression is not supported') % compression
       
   146             )
       
   147 
       
   148         version, params = parseparams(version)
       
   149 
       
   150         if version not in _bundlespeccgversions:
       
   151             raise error.UnsupportedBundleSpecification(
       
   152                 _(b'%s is not a recognized bundle version') % version
       
   153             )
       
   154     else:
       
   155         # Value could be just the compression or just the version, in which
       
   156         # case some defaults are assumed (but only when not in strict mode).
       
   157         assert not strict
       
   158 
       
   159         spec, params = parseparams(spec)
       
   160 
       
   161         if spec in util.compengines.supportedbundlenames:
       
   162             compression = spec
       
   163             version = b'v1'
       
   164             # Generaldelta repos require v2.
       
   165             if b'generaldelta' in repo.requirements:
       
   166                 version = b'v2'
       
   167             # Modern compression engines require v2.
       
   168             if compression not in _bundlespecv1compengines:
       
   169                 version = b'v2'
       
   170         elif spec in _bundlespeccgversions:
       
   171             if spec == b'packed1':
       
   172                 compression = b'none'
       
   173             else:
       
   174                 compression = b'bzip2'
       
   175             version = spec
       
   176         else:
       
   177             raise error.UnsupportedBundleSpecification(
       
   178                 _(b'%s is not a recognized bundle specification') % spec
       
   179             )
       
   180 
       
   181     # Bundle version 1 only supports a known set of compression engines.
       
   182     if version == b'v1' and compression not in _bundlespecv1compengines:
       
   183         raise error.UnsupportedBundleSpecification(
       
   184             _(b'compression engine %s is not supported on v1 bundles')
       
   185             % compression
       
   186         )
       
   187 
       
   188     # The specification for packed1 can optionally declare the data formats
       
   189     # required to apply it. If we see this metadata, compare against what the
       
   190     # repo supports and error if the bundle isn't compatible.
       
   191     if version == b'packed1' and b'requirements' in params:
       
   192         requirements = set(params[b'requirements'].split(b','))
       
   193         missingreqs = requirements - repo.supportedformats
       
   194         if missingreqs:
       
   195             raise error.UnsupportedBundleSpecification(
       
   196                 _(b'missing support for repository features: %s')
       
   197                 % b', '.join(sorted(missingreqs))
       
   198             )
       
   199 
       
   200     # Compute contentopts based on the version
       
   201     contentopts = _bundlespeccontentopts.get(version, {}).copy()
       
   202 
       
   203     # Process the variants
       
   204     if b"stream" in params and params[b"stream"] == b"v2":
       
   205         variant = _bundlespecvariants[b"streamv2"]
       
   206         contentopts.update(variant)
       
   207 
       
   208     engine = util.compengines.forbundlename(compression)
       
   209     compression, wirecompression = engine.bundletype()
       
   210     wireversion = _bundlespeccgversions[version]
       
   211 
       
   212     return bundlespec(
       
   213         compression, wirecompression, version, wireversion, params, contentopts
       
   214     )
       
   215 
       
   216 
       
   217 def parseclonebundlesmanifest(repo, s):
       
   218     """Parses the raw text of a clone bundles manifest.
       
   219 
       
   220     Returns a list of dicts. The dicts have a ``URL`` key corresponding
       
   221     to the URL and other keys are the attributes for the entry.
       
   222     """
       
   223     m = []
       
   224     for line in s.splitlines():
       
   225         fields = line.split()
       
   226         if not fields:
       
   227             continue
       
   228         attrs = {b'URL': fields[0]}
       
   229         for rawattr in fields[1:]:
       
   230             key, value = rawattr.split(b'=', 1)
       
   231             key = util.urlreq.unquote(key)
       
   232             value = util.urlreq.unquote(value)
       
   233             attrs[key] = value
       
   234 
       
   235             # Parse BUNDLESPEC into components. This makes client-side
       
   236             # preferences easier to specify since you can prefer a single
       
   237             # component of the BUNDLESPEC.
       
   238             if key == b'BUNDLESPEC':
       
   239                 try:
       
   240                     bundlespec = parsebundlespec(repo, value)
       
   241                     attrs[b'COMPRESSION'] = bundlespec.compression
       
   242                     attrs[b'VERSION'] = bundlespec.version
       
   243                 except error.InvalidBundleSpecification:
       
   244                     pass
       
   245                 except error.UnsupportedBundleSpecification:
       
   246                     pass
       
   247 
       
   248         m.append(attrs)
       
   249 
       
   250     return m
       
   251 
       
   252 
       
   253 def isstreamclonespec(bundlespec):
       
   254     # Stream clone v1
       
   255     if bundlespec.wirecompression == b'UN' and bundlespec.wireversion == b's1':
       
   256         return True
       
   257 
       
   258     # Stream clone v2
       
   259     if (
       
   260         bundlespec.wirecompression == b'UN'
       
   261         and bundlespec.wireversion == b'02'
       
   262         and bundlespec.contentopts.get(b'streamv2')
       
   263     ):
       
   264         return True
       
   265 
       
   266     return False
       
   267 
       
   268 
       
   269 def filterclonebundleentries(repo, entries, streamclonerequested=False):
       
   270     """Remove incompatible clone bundle manifest entries.
       
   271 
       
   272     Accepts a list of entries parsed with ``parseclonebundlesmanifest``
       
   273     and returns a new list consisting of only the entries that this client
       
   274     should be able to apply.
       
   275 
       
   276     There is no guarantee we'll be able to apply all returned entries because
       
   277     the metadata we use to filter on may be missing or wrong.
       
   278     """
       
   279     newentries = []
       
   280     for entry in entries:
       
   281         spec = entry.get(b'BUNDLESPEC')
       
   282         if spec:
       
   283             try:
       
   284                 bundlespec = parsebundlespec(repo, spec, strict=True)
       
   285 
       
   286                 # If a stream clone was requested, filter out non-streamclone
       
   287                 # entries.
       
   288                 if streamclonerequested and not isstreamclonespec(bundlespec):
       
   289                     repo.ui.debug(
       
   290                         b'filtering %s because not a stream clone\n'
       
   291                         % entry[b'URL']
       
   292                     )
       
   293                     continue
       
   294 
       
   295             except error.InvalidBundleSpecification as e:
       
   296                 repo.ui.debug(stringutil.forcebytestr(e) + b'\n')
       
   297                 continue
       
   298             except error.UnsupportedBundleSpecification as e:
       
   299                 repo.ui.debug(
       
   300                     b'filtering %s because unsupported bundle '
       
   301                     b'spec: %s\n' % (entry[b'URL'], stringutil.forcebytestr(e))
       
   302                 )
       
   303                 continue
       
   304         # If we don't have a spec and requested a stream clone, we don't know
       
   305         # what the entry is so don't attempt to apply it.
       
   306         elif streamclonerequested:
       
   307             repo.ui.debug(
       
   308                 b'filtering %s because cannot determine if a stream '
       
   309                 b'clone bundle\n' % entry[b'URL']
       
   310             )
       
   311             continue
       
   312 
       
   313         if b'REQUIRESNI' in entry and not sslutil.hassni:
       
   314             repo.ui.debug(
       
   315                 b'filtering %s because SNI not supported\n' % entry[b'URL']
       
   316             )
       
   317             continue
       
   318 
       
   319         if b'REQUIREDRAM' in entry:
       
   320             try:
       
   321                 requiredram = util.sizetoint(entry[b'REQUIREDRAM'])
       
   322             except error.ParseError:
       
   323                 repo.ui.debug(
       
   324                     b'filtering %s due to a bad REQUIREDRAM attribute\n'
       
   325                     % entry[b'URL']
       
   326                 )
       
   327                 continue
       
   328             actualram = repo.ui.estimatememory()
       
   329             if actualram is not None and actualram * 0.66 < requiredram:
       
   330                 repo.ui.debug(
       
   331                     b'filtering %s as it needs more than 2/3 of system memory\n'
       
   332                     % entry[b'URL']
       
   333                 )
       
   334                 continue
       
   335 
       
   336         newentries.append(entry)
       
   337 
       
   338     return newentries
       
   339 
       
   340 
       
   341 class clonebundleentry(object):
       
   342     """Represents an item in a clone bundles manifest.
       
   343 
       
   344     This rich class is needed to support sorting since sorted() in Python 3
       
   345     doesn't support ``cmp`` and our comparison is complex enough that ``key=``
       
   346     won't work.
       
   347     """
       
   348 
       
   349     def __init__(self, value, prefers):
       
   350         self.value = value
       
   351         self.prefers = prefers
       
   352 
       
   353     def _cmp(self, other):
       
   354         for prefkey, prefvalue in self.prefers:
       
   355             avalue = self.value.get(prefkey)
       
   356             bvalue = other.value.get(prefkey)
       
   357 
       
   358             # Special case for b missing attribute and a matches exactly.
       
   359             if avalue is not None and bvalue is None and avalue == prefvalue:
       
   360                 return -1
       
   361 
       
   362             # Special case for a missing attribute and b matches exactly.
       
   363             if bvalue is not None and avalue is None and bvalue == prefvalue:
       
   364                 return 1
       
   365 
       
   366             # We can't compare unless attribute present on both.
       
   367             if avalue is None or bvalue is None:
       
   368                 continue
       
   369 
       
   370             # Same values should fall back to next attribute.
       
   371             if avalue == bvalue:
       
   372                 continue
       
   373 
       
   374             # Exact matches come first.
       
   375             if avalue == prefvalue:
       
   376                 return -1
       
   377             if bvalue == prefvalue:
       
   378                 return 1
       
   379 
       
   380             # Fall back to next attribute.
       
   381             continue
       
   382 
       
   383         # If we got here we couldn't sort by attributes and prefers. Fall
       
   384         # back to index order.
       
   385         return 0
       
   386 
       
   387     def __lt__(self, other):
       
   388         return self._cmp(other) < 0
       
   389 
       
   390     def __gt__(self, other):
       
   391         return self._cmp(other) > 0
       
   392 
       
   393     def __eq__(self, other):
       
   394         return self._cmp(other) == 0
       
   395 
       
   396     def __le__(self, other):
       
   397         return self._cmp(other) <= 0
       
   398 
       
   399     def __ge__(self, other):
       
   400         return self._cmp(other) >= 0
       
   401 
       
   402     def __ne__(self, other):
       
   403         return self._cmp(other) != 0
       
   404 
       
   405 
       
   406 def sortclonebundleentries(ui, entries):
       
   407     prefers = ui.configlist(b'ui', b'clonebundleprefers')
       
   408     if not prefers:
       
   409         return list(entries)
       
   410 
       
   411     def _split(p):
       
   412         if b'=' not in p:
       
   413             hint = _(b"each comma separated item should be key=value pairs")
       
   414             raise error.Abort(
       
   415                 _(b"invalid ui.clonebundleprefers item: %s") % p, hint=hint
       
   416             )
       
   417         return p.split(b'=', 1)
       
   418 
       
   419     prefers = [_split(p) for p in prefers]
       
   420 
       
   421     items = sorted(clonebundleentry(v, prefers) for v in entries)
       
   422     return [i.value for i in items]