Improve error handling in urlopen / socket read (#48707)
* Backward compat with Python 3.9 for socket.timeout * Forward compat with Python [unknown] as HTTPResponse.geturl is deprecated * Catch timeout etc from .read() * Some minor simplifications: json.load(...) takes file object in binary mode. * Fix CDash code which does error handling wrong: non-2XX responses raise.
This commit is contained in:
		@@ -802,7 +802,7 @@ def url_read_method(url):
 | 
			
		||||
        try:
 | 
			
		||||
            _, _, spec_file = web_util.read_from_url(url)
 | 
			
		||||
            contents = codecs.getreader("utf-8")(spec_file).read()
 | 
			
		||||
        except web_util.SpackWebError as e:
 | 
			
		||||
        except (web_util.SpackWebError, OSError) as e:
 | 
			
		||||
            tty.error(f"Error reading specfile: {url}: {e}")
 | 
			
		||||
        return contents
 | 
			
		||||
 | 
			
		||||
@@ -2010,7 +2010,7 @@ def fetch_url_to_mirror(url):
 | 
			
		||||
 | 
			
		||||
                # Download the config = spec.json and the relevant tarball
 | 
			
		||||
                try:
 | 
			
		||||
                    manifest = json.loads(response.read())
 | 
			
		||||
                    manifest = json.load(response)
 | 
			
		||||
                    spec_digest = spack.oci.image.Digest.from_string(manifest["config"]["digest"])
 | 
			
		||||
                    tarball_digest = spack.oci.image.Digest.from_string(
 | 
			
		||||
                        manifest["layers"][-1]["digest"]
 | 
			
		||||
@@ -2596,11 +2596,14 @@ def try_direct_fetch(spec, mirrors=None):
 | 
			
		||||
        )
 | 
			
		||||
        try:
 | 
			
		||||
            _, _, fs = web_util.read_from_url(buildcache_fetch_url_signed_json)
 | 
			
		||||
            specfile_contents = codecs.getreader("utf-8")(fs).read()
 | 
			
		||||
            specfile_is_signed = True
 | 
			
		||||
        except web_util.SpackWebError as e1:
 | 
			
		||||
        except (web_util.SpackWebError, OSError) as e1:
 | 
			
		||||
            try:
 | 
			
		||||
                _, _, fs = web_util.read_from_url(buildcache_fetch_url_json)
 | 
			
		||||
            except web_util.SpackWebError as e2:
 | 
			
		||||
                specfile_contents = codecs.getreader("utf-8")(fs).read()
 | 
			
		||||
                specfile_is_signed = False
 | 
			
		||||
            except (web_util.SpackWebError, OSError) as e2:
 | 
			
		||||
                tty.debug(
 | 
			
		||||
                    f"Did not find {specfile_name} on {buildcache_fetch_url_signed_json}",
 | 
			
		||||
                    e1,
 | 
			
		||||
@@ -2610,7 +2613,6 @@ def try_direct_fetch(spec, mirrors=None):
 | 
			
		||||
                    f"Did not find {specfile_name} on {buildcache_fetch_url_json}", e2, level=2
 | 
			
		||||
                )
 | 
			
		||||
                continue
 | 
			
		||||
        specfile_contents = codecs.getreader("utf-8")(fs).read()
 | 
			
		||||
 | 
			
		||||
        # read the spec from the build cache file. All specs in build caches
 | 
			
		||||
        # are concrete (as they are built) so we need to mark this spec
 | 
			
		||||
@@ -2704,8 +2706,9 @@ def get_keys(install=False, trust=False, force=False, mirrors=None):
 | 
			
		||||
 | 
			
		||||
        try:
 | 
			
		||||
            _, _, json_file = web_util.read_from_url(keys_index)
 | 
			
		||||
            json_index = sjson.load(codecs.getreader("utf-8")(json_file))
 | 
			
		||||
        except web_util.SpackWebError as url_err:
 | 
			
		||||
            json_index = sjson.load(json_file)
 | 
			
		||||
        except (web_util.SpackWebError, OSError, ValueError) as url_err:
 | 
			
		||||
            # TODO: avoid repeated request
 | 
			
		||||
            if web_util.url_exists(keys_index):
 | 
			
		||||
                tty.error(
 | 
			
		||||
                    f"Unable to find public keys in {url_util.format(fetch_url)},"
 | 
			
		||||
@@ -2955,11 +2958,11 @@ def get_remote_hash(self):
 | 
			
		||||
        url_index_hash = url_util.join(self.url, BUILD_CACHE_RELATIVE_PATH, INDEX_HASH_FILE)
 | 
			
		||||
        try:
 | 
			
		||||
            response = self.urlopen(urllib.request.Request(url_index_hash, headers=self.headers))
 | 
			
		||||
        except (TimeoutError, urllib.error.URLError):
 | 
			
		||||
            remote_hash = response.read(64)
 | 
			
		||||
        except OSError:
 | 
			
		||||
            return None
 | 
			
		||||
 | 
			
		||||
        # Validate the hash
 | 
			
		||||
        remote_hash = response.read(64)
 | 
			
		||||
        if not re.match(rb"[a-f\d]{64}$", remote_hash):
 | 
			
		||||
            return None
 | 
			
		||||
        return remote_hash.decode("utf-8")
 | 
			
		||||
@@ -2977,13 +2980,13 @@ def conditional_fetch(self) -> FetchIndexResult:
 | 
			
		||||
 | 
			
		||||
        try:
 | 
			
		||||
            response = self.urlopen(urllib.request.Request(url_index, headers=self.headers))
 | 
			
		||||
        except (TimeoutError, urllib.error.URLError) as e:
 | 
			
		||||
            raise FetchIndexError("Could not fetch index from {}".format(url_index), e) from e
 | 
			
		||||
        except OSError as e:
 | 
			
		||||
            raise FetchIndexError(f"Could not fetch index from {url_index}", e) from e
 | 
			
		||||
 | 
			
		||||
        try:
 | 
			
		||||
            result = codecs.getreader("utf-8")(response).read()
 | 
			
		||||
        except ValueError as e:
 | 
			
		||||
            raise FetchIndexError("Remote index {} is invalid".format(url_index), e) from e
 | 
			
		||||
        except (ValueError, OSError) as e:
 | 
			
		||||
            raise FetchIndexError(f"Remote index {url_index} is invalid") from e
 | 
			
		||||
 | 
			
		||||
        computed_hash = compute_hash(result)
 | 
			
		||||
 | 
			
		||||
@@ -3027,12 +3030,12 @@ def conditional_fetch(self) -> FetchIndexResult:
 | 
			
		||||
                # Not modified; that means fresh.
 | 
			
		||||
                return FetchIndexResult(etag=None, hash=None, data=None, fresh=True)
 | 
			
		||||
            raise FetchIndexError(f"Could not fetch index {url}", e) from e
 | 
			
		||||
        except (TimeoutError, urllib.error.URLError) as e:
 | 
			
		||||
        except OSError as e:  # URLError, socket.timeout, etc.
 | 
			
		||||
            raise FetchIndexError(f"Could not fetch index {url}", e) from e
 | 
			
		||||
 | 
			
		||||
        try:
 | 
			
		||||
            result = codecs.getreader("utf-8")(response).read()
 | 
			
		||||
        except ValueError as e:
 | 
			
		||||
        except (ValueError, OSError) as e:
 | 
			
		||||
            raise FetchIndexError(f"Remote index {url} is invalid", e) from e
 | 
			
		||||
 | 
			
		||||
        headers = response.headers
 | 
			
		||||
@@ -3064,11 +3067,11 @@ def conditional_fetch(self) -> FetchIndexResult:
 | 
			
		||||
                    headers={"Accept": "application/vnd.oci.image.manifest.v1+json"},
 | 
			
		||||
                )
 | 
			
		||||
            )
 | 
			
		||||
        except (TimeoutError, urllib.error.URLError) as e:
 | 
			
		||||
        except OSError as e:
 | 
			
		||||
            raise FetchIndexError(f"Could not fetch manifest from {url_manifest}", e) from e
 | 
			
		||||
 | 
			
		||||
        try:
 | 
			
		||||
            manifest = json.loads(response.read())
 | 
			
		||||
            manifest = json.load(response)
 | 
			
		||||
        except Exception as e:
 | 
			
		||||
            raise FetchIndexError(f"Remote index {url_manifest} is invalid", e) from e
 | 
			
		||||
 | 
			
		||||
@@ -3083,14 +3086,16 @@ def conditional_fetch(self) -> FetchIndexResult:
 | 
			
		||||
            return FetchIndexResult(etag=None, hash=None, data=None, fresh=True)
 | 
			
		||||
 | 
			
		||||
        # Otherwise fetch the blob / index.json
 | 
			
		||||
        try:
 | 
			
		||||
            response = self.urlopen(
 | 
			
		||||
                urllib.request.Request(
 | 
			
		||||
                    url=self.ref.blob_url(index_digest),
 | 
			
		||||
                    headers={"Accept": "application/vnd.oci.image.layer.v1.tar+gzip"},
 | 
			
		||||
                )
 | 
			
		||||
            )
 | 
			
		||||
 | 
			
		||||
            result = codecs.getreader("utf-8")(response).read()
 | 
			
		||||
        except (OSError, ValueError) as e:
 | 
			
		||||
            raise FetchIndexError(f"Remote index {url_manifest} is invalid", e) from e
 | 
			
		||||
 | 
			
		||||
        # Make sure the blob we download has the advertised hash
 | 
			
		||||
        if compute_hash(result) != index_digest.digest:
 | 
			
		||||
 
 | 
			
		||||
@@ -14,7 +14,6 @@
 | 
			
		||||
import zipfile
 | 
			
		||||
from collections import namedtuple
 | 
			
		||||
from typing import Callable, Dict, List, Set
 | 
			
		||||
from urllib.error import HTTPError, URLError
 | 
			
		||||
from urllib.request import HTTPHandler, Request, build_opener
 | 
			
		||||
 | 
			
		||||
import llnl.util.filesystem as fs
 | 
			
		||||
@@ -472,12 +471,9 @@ def generate_pipeline(env: ev.Environment, args) -> None:
 | 
			
		||||
    # Use all unpruned specs to populate the build group for this set
 | 
			
		||||
    cdash_config = cfg.get("cdash")
 | 
			
		||||
    if options.cdash_handler and options.cdash_handler.auth_token:
 | 
			
		||||
        try:
 | 
			
		||||
        options.cdash_handler.populate_buildgroup(
 | 
			
		||||
            [options.cdash_handler.build_name(s) for s in pipeline_specs]
 | 
			
		||||
        )
 | 
			
		||||
        except (SpackError, HTTPError, URLError, TimeoutError) as err:
 | 
			
		||||
            tty.warn(f"Problem populating buildgroup: {err}")
 | 
			
		||||
    elif cdash_config:
 | 
			
		||||
        # warn only if there was actually a CDash configuration.
 | 
			
		||||
        tty.warn("Unable to populate buildgroup without CDash credentials")
 | 
			
		||||
 
 | 
			
		||||
@@ -1,23 +1,21 @@
 | 
			
		||||
# Copyright Spack Project Developers. See COPYRIGHT file for details.
 | 
			
		||||
#
 | 
			
		||||
# SPDX-License-Identifier: (Apache-2.0 OR MIT)
 | 
			
		||||
import codecs
 | 
			
		||||
import copy
 | 
			
		||||
import json
 | 
			
		||||
import os
 | 
			
		||||
import re
 | 
			
		||||
import ssl
 | 
			
		||||
import sys
 | 
			
		||||
import time
 | 
			
		||||
from collections import deque
 | 
			
		||||
from enum import Enum
 | 
			
		||||
from typing import Dict, Generator, List, Optional, Set, Tuple
 | 
			
		||||
from urllib.parse import quote, urlencode, urlparse
 | 
			
		||||
from urllib.request import HTTPHandler, HTTPSHandler, Request, build_opener
 | 
			
		||||
from urllib.request import Request
 | 
			
		||||
 | 
			
		||||
import llnl.util.filesystem as fs
 | 
			
		||||
import llnl.util.tty as tty
 | 
			
		||||
from llnl.util.lang import Singleton, memoized
 | 
			
		||||
from llnl.util.lang import memoized
 | 
			
		||||
 | 
			
		||||
import spack.binary_distribution as bindist
 | 
			
		||||
import spack.config as cfg
 | 
			
		||||
@@ -35,32 +33,11 @@
 | 
			
		||||
from spack.reporters.cdash import SPACK_CDASH_TIMEOUT
 | 
			
		||||
from spack.reporters.cdash import build_stamp as cdash_build_stamp
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def _urlopen():
 | 
			
		||||
    error_handler = web_util.SpackHTTPDefaultErrorHandler()
 | 
			
		||||
 | 
			
		||||
    # One opener with HTTPS ssl enabled
 | 
			
		||||
    with_ssl = build_opener(
 | 
			
		||||
        HTTPHandler(), HTTPSHandler(context=web_util.ssl_create_default_context()), error_handler
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    # One opener with HTTPS ssl disabled
 | 
			
		||||
    without_ssl = build_opener(
 | 
			
		||||
        HTTPHandler(), HTTPSHandler(context=ssl._create_unverified_context()), error_handler
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    # And dynamically dispatch based on the config:verify_ssl.
 | 
			
		||||
    def dispatch_open(fullurl, data=None, timeout=None, verify_ssl=True):
 | 
			
		||||
        opener = with_ssl if verify_ssl else without_ssl
 | 
			
		||||
        timeout = timeout or cfg.get("config:connect_timeout", 1)
 | 
			
		||||
        return opener.open(fullurl, data, timeout)
 | 
			
		||||
 | 
			
		||||
    return dispatch_open
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
IS_WINDOWS = sys.platform == "win32"
 | 
			
		||||
SPACK_RESERVED_TAGS = ["public", "protected", "notary"]
 | 
			
		||||
_dyn_mapping_urlopener = Singleton(_urlopen)
 | 
			
		||||
 | 
			
		||||
# this exists purely for testing purposes
 | 
			
		||||
_urlopen = web_util.urlopen
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def copy_files_to_artifacts(src, artifacts_dir):
 | 
			
		||||
@@ -279,26 +256,25 @@ def copy_test_results(self, source, dest):
 | 
			
		||||
        reports = fs.join_path(source, "*_Test*.xml")
 | 
			
		||||
        copy_files_to_artifacts(reports, dest)
 | 
			
		||||
 | 
			
		||||
    def create_buildgroup(self, opener, headers, url, group_name, group_type):
 | 
			
		||||
    def create_buildgroup(self, headers, url, group_name, group_type):
 | 
			
		||||
        data = {"newbuildgroup": group_name, "project": self.project, "type": group_type}
 | 
			
		||||
 | 
			
		||||
        enc_data = json.dumps(data).encode("utf-8")
 | 
			
		||||
 | 
			
		||||
        request = Request(url, data=enc_data, headers=headers)
 | 
			
		||||
 | 
			
		||||
        response = opener.open(request, timeout=SPACK_CDASH_TIMEOUT)
 | 
			
		||||
        response_code = response.getcode()
 | 
			
		||||
 | 
			
		||||
        if response_code not in [200, 201]:
 | 
			
		||||
            msg = f"Creating buildgroup failed (response code = {response_code})"
 | 
			
		||||
            tty.warn(msg)
 | 
			
		||||
        try:
 | 
			
		||||
            response_text = _urlopen(request, timeout=SPACK_CDASH_TIMEOUT).read()
 | 
			
		||||
        except OSError as e:
 | 
			
		||||
            tty.warn(f"Failed to create CDash buildgroup: {e}")
 | 
			
		||||
            return None
 | 
			
		||||
 | 
			
		||||
        response_text = response.read()
 | 
			
		||||
        try:
 | 
			
		||||
            response_json = json.loads(response_text)
 | 
			
		||||
        build_group_id = response_json["id"]
 | 
			
		||||
 | 
			
		||||
        return build_group_id
 | 
			
		||||
            return response_json["id"]
 | 
			
		||||
        except (json.JSONDecodeError, KeyError) as e:
 | 
			
		||||
            tty.warn(f"Failed to parse CDash response: {e}")
 | 
			
		||||
            return None
 | 
			
		||||
 | 
			
		||||
    def populate_buildgroup(self, job_names):
 | 
			
		||||
        url = f"{self.url}/api/v1/buildgroup.php"
 | 
			
		||||
@@ -308,16 +284,11 @@ def populate_buildgroup(self, job_names):
 | 
			
		||||
            "Content-Type": "application/json",
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        opener = build_opener(HTTPHandler)
 | 
			
		||||
 | 
			
		||||
        parent_group_id = self.create_buildgroup(opener, headers, url, self.build_group, "Daily")
 | 
			
		||||
        group_id = self.create_buildgroup(
 | 
			
		||||
            opener, headers, url, f"Latest {self.build_group}", "Latest"
 | 
			
		||||
        )
 | 
			
		||||
        parent_group_id = self.create_buildgroup(headers, url, self.build_group, "Daily")
 | 
			
		||||
        group_id = self.create_buildgroup(headers, url, f"Latest {self.build_group}", "Latest")
 | 
			
		||||
 | 
			
		||||
        if not parent_group_id or not group_id:
 | 
			
		||||
            msg = f"Failed to create or retrieve buildgroups for {self.build_group}"
 | 
			
		||||
            tty.warn(msg)
 | 
			
		||||
            tty.warn(f"Failed to create or retrieve buildgroups for {self.build_group}")
 | 
			
		||||
            return
 | 
			
		||||
 | 
			
		||||
        data = {
 | 
			
		||||
@@ -329,15 +300,12 @@ def populate_buildgroup(self, job_names):
 | 
			
		||||
 | 
			
		||||
        enc_data = json.dumps(data).encode("utf-8")
 | 
			
		||||
 | 
			
		||||
        request = Request(url, data=enc_data, headers=headers)
 | 
			
		||||
        request.get_method = lambda: "PUT"
 | 
			
		||||
        request = Request(url, data=enc_data, headers=headers, method="PUT")
 | 
			
		||||
 | 
			
		||||
        response = opener.open(request, timeout=SPACK_CDASH_TIMEOUT)
 | 
			
		||||
        response_code = response.getcode()
 | 
			
		||||
 | 
			
		||||
        if response_code != 200:
 | 
			
		||||
            msg = f"Error response code ({response_code}) in populate_buildgroup"
 | 
			
		||||
            tty.warn(msg)
 | 
			
		||||
        try:
 | 
			
		||||
            _urlopen(request, timeout=SPACK_CDASH_TIMEOUT)
 | 
			
		||||
        except OSError as e:
 | 
			
		||||
            tty.warn(f"Failed to populate CDash buildgroup: {e}")
 | 
			
		||||
 | 
			
		||||
    def report_skipped(self, spec: spack.spec.Spec, report_dir: str, reason: Optional[str]):
 | 
			
		||||
        """Explicitly report skipping testing of a spec (e.g., it's CI
 | 
			
		||||
@@ -735,9 +703,6 @@ def _apply_section(dest, src):
 | 
			
		||||
                for value in header.values():
 | 
			
		||||
                    value = os.path.expandvars(value)
 | 
			
		||||
 | 
			
		||||
                verify_ssl = mapping.get("verify_ssl", spack.config.get("config:verify_ssl", True))
 | 
			
		||||
                timeout = mapping.get("timeout", spack.config.get("config:connect_timeout", 1))
 | 
			
		||||
 | 
			
		||||
                required = mapping.get("require", [])
 | 
			
		||||
                allowed = mapping.get("allow", [])
 | 
			
		||||
                ignored = mapping.get("ignore", [])
 | 
			
		||||
@@ -771,19 +736,15 @@ def job_query(job):
 | 
			
		||||
                        endpoint_url._replace(query=query).geturl(), headers=header, method="GET"
 | 
			
		||||
                    )
 | 
			
		||||
                    try:
 | 
			
		||||
                        response = _dyn_mapping_urlopener(
 | 
			
		||||
                            request, verify_ssl=verify_ssl, timeout=timeout
 | 
			
		||||
                        )
 | 
			
		||||
                        response = _urlopen(request)
 | 
			
		||||
                        config = json.load(response)
 | 
			
		||||
                    except Exception as e:
 | 
			
		||||
                        # For now just ignore any errors from dynamic mapping and continue
 | 
			
		||||
                        # This is still experimental, and failures should not stop CI
 | 
			
		||||
                        # from running normally
 | 
			
		||||
                        tty.warn(f"Failed to fetch dynamic mapping for query:\n\t{query}")
 | 
			
		||||
                        tty.warn(f"{e}")
 | 
			
		||||
                        tty.warn(f"Failed to fetch dynamic mapping for query:\n\t{query}: {e}")
 | 
			
		||||
                        continue
 | 
			
		||||
 | 
			
		||||
                    config = json.load(codecs.getreader("utf-8")(response))
 | 
			
		||||
 | 
			
		||||
                    # Strip ignore keys
 | 
			
		||||
                    if ignored:
 | 
			
		||||
                        for key in ignored:
 | 
			
		||||
 
 | 
			
		||||
@@ -321,9 +321,15 @@ def _fetch_urllib(self, url):
 | 
			
		||||
 | 
			
		||||
        request = urllib.request.Request(url, headers={"User-Agent": web_util.SPACK_USER_AGENT})
 | 
			
		||||
 | 
			
		||||
        if os.path.lexists(save_file):
 | 
			
		||||
            os.remove(save_file)
 | 
			
		||||
 | 
			
		||||
        try:
 | 
			
		||||
            response = web_util.urlopen(request)
 | 
			
		||||
        except (TimeoutError, urllib.error.URLError) as e:
 | 
			
		||||
            tty.msg(f"Fetching {url}")
 | 
			
		||||
            with open(save_file, "wb") as f:
 | 
			
		||||
                shutil.copyfileobj(response, f)
 | 
			
		||||
        except OSError as e:
 | 
			
		||||
            # clean up archive on failure.
 | 
			
		||||
            if self.archive_file:
 | 
			
		||||
                os.remove(self.archive_file)
 | 
			
		||||
@@ -331,14 +337,6 @@ def _fetch_urllib(self, url):
 | 
			
		||||
                os.remove(save_file)
 | 
			
		||||
            raise FailedDownloadError(e) from e
 | 
			
		||||
 | 
			
		||||
        tty.msg(f"Fetching {url}")
 | 
			
		||||
 | 
			
		||||
        if os.path.lexists(save_file):
 | 
			
		||||
            os.remove(save_file)
 | 
			
		||||
 | 
			
		||||
        with open(save_file, "wb") as f:
 | 
			
		||||
            shutil.copyfileobj(response, f)
 | 
			
		||||
 | 
			
		||||
        # Save the redirected URL for error messages. Sometimes we're redirected to an arbitrary
 | 
			
		||||
        # mirror that is broken, leading to spurious download failures. In that case it's helpful
 | 
			
		||||
        # for users to know which URL was actually fetched.
 | 
			
		||||
@@ -535,11 +533,16 @@ def __init__(self, *, url: str, checksum: Optional[str] = None, **kwargs):
 | 
			
		||||
    @_needs_stage
 | 
			
		||||
    def fetch(self):
 | 
			
		||||
        file = self.stage.save_filename
 | 
			
		||||
        tty.msg(f"Fetching {self.url}")
 | 
			
		||||
 | 
			
		||||
        if os.path.lexists(file):
 | 
			
		||||
            os.remove(file)
 | 
			
		||||
 | 
			
		||||
        try:
 | 
			
		||||
            response = self._urlopen(self.url)
 | 
			
		||||
        except (TimeoutError, urllib.error.URLError) as e:
 | 
			
		||||
            tty.msg(f"Fetching {self.url}")
 | 
			
		||||
            with open(file, "wb") as f:
 | 
			
		||||
                shutil.copyfileobj(response, f)
 | 
			
		||||
        except OSError as e:
 | 
			
		||||
            # clean up archive on failure.
 | 
			
		||||
            if self.archive_file:
 | 
			
		||||
                os.remove(self.archive_file)
 | 
			
		||||
@@ -547,12 +550,6 @@ def fetch(self):
 | 
			
		||||
                os.remove(file)
 | 
			
		||||
            raise FailedDownloadError(e) from e
 | 
			
		||||
 | 
			
		||||
        if os.path.lexists(file):
 | 
			
		||||
            os.remove(file)
 | 
			
		||||
 | 
			
		||||
        with open(file, "wb") as f:
 | 
			
		||||
            shutil.copyfileobj(response, f)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class VCSFetchStrategy(FetchStrategy):
 | 
			
		||||
    """Superclass for version control system fetch strategies.
 | 
			
		||||
 
 | 
			
		||||
@@ -7,6 +7,7 @@
 | 
			
		||||
import base64
 | 
			
		||||
import json
 | 
			
		||||
import re
 | 
			
		||||
import socket
 | 
			
		||||
import time
 | 
			
		||||
import urllib.error
 | 
			
		||||
import urllib.parse
 | 
			
		||||
@@ -410,7 +411,7 @@ def wrapper(*args, **kwargs):
 | 
			
		||||
        for i in range(retries):
 | 
			
		||||
            try:
 | 
			
		||||
                return f(*args, **kwargs)
 | 
			
		||||
            except (urllib.error.URLError, TimeoutError) as e:
 | 
			
		||||
            except OSError as e:
 | 
			
		||||
                # Retry on internal server errors, and rate limit errors
 | 
			
		||||
                # Potentially this could take into account the Retry-After header
 | 
			
		||||
                # if registries support it
 | 
			
		||||
@@ -420,9 +421,10 @@ def wrapper(*args, **kwargs):
 | 
			
		||||
                        and (500 <= e.code < 600 or e.code == 429)
 | 
			
		||||
                    )
 | 
			
		||||
                    or (
 | 
			
		||||
                        isinstance(e, urllib.error.URLError) and isinstance(e.reason, TimeoutError)
 | 
			
		||||
                        isinstance(e, urllib.error.URLError)
 | 
			
		||||
                        and isinstance(e.reason, socket.timeout)
 | 
			
		||||
                    )
 | 
			
		||||
                    or isinstance(e, TimeoutError)
 | 
			
		||||
                    or isinstance(e, socket.timeout)
 | 
			
		||||
                ):
 | 
			
		||||
                    # Exponential backoff
 | 
			
		||||
                    sleep(2**i)
 | 
			
		||||
 
 | 
			
		||||
@@ -5,7 +5,6 @@
 | 
			
		||||
import os
 | 
			
		||||
import pathlib
 | 
			
		||||
import shutil
 | 
			
		||||
from io import BytesIO
 | 
			
		||||
from typing import NamedTuple
 | 
			
		||||
 | 
			
		||||
import jsonschema
 | 
			
		||||
@@ -32,6 +31,7 @@
 | 
			
		||||
from spack.schema.buildcache_spec import schema as specfile_schema
 | 
			
		||||
from spack.schema.database_index import schema as db_idx_schema
 | 
			
		||||
from spack.spec import Spec
 | 
			
		||||
from spack.test.conftest import MockHTTPResponse
 | 
			
		||||
 | 
			
		||||
config_cmd = spack.main.SpackCommand("config")
 | 
			
		||||
ci_cmd = spack.main.SpackCommand("ci")
 | 
			
		||||
@@ -239,7 +239,7 @@ def test_ci_generate_with_cdash_token(ci_generate_test, tmp_path, mock_binary_in
 | 
			
		||||
    # That fake token should have resulted in being unable to
 | 
			
		||||
    # register build group with cdash, but the workload should
 | 
			
		||||
    # still have been generated.
 | 
			
		||||
    assert "Problem populating buildgroup" in output
 | 
			
		||||
    assert "Failed to create or retrieve buildgroups" in output
 | 
			
		||||
    expected_keys = ["rebuild-index", "stages", "variables", "workflow"]
 | 
			
		||||
    assert all([key in yaml_contents.keys() for key in expected_keys])
 | 
			
		||||
 | 
			
		||||
@@ -1548,10 +1548,10 @@ def test_ci_dynamic_mapping_empty(
 | 
			
		||||
    ci_base_environment,
 | 
			
		||||
):
 | 
			
		||||
    # The test will always return an empty dictionary
 | 
			
		||||
    def fake_dyn_mapping_urlopener(*args, **kwargs):
 | 
			
		||||
        return BytesIO("{}".encode())
 | 
			
		||||
    def _urlopen(*args, **kwargs):
 | 
			
		||||
        return MockHTTPResponse.with_json(200, "OK", headers={}, body={})
 | 
			
		||||
 | 
			
		||||
    monkeypatch.setattr(ci.common, "_dyn_mapping_urlopener", fake_dyn_mapping_urlopener)
 | 
			
		||||
    monkeypatch.setattr(ci.common, "_urlopen", _urlopen)
 | 
			
		||||
 | 
			
		||||
    _ = dynamic_mapping_setup(tmpdir)
 | 
			
		||||
    with tmpdir.as_cwd():
 | 
			
		||||
@@ -1572,15 +1572,15 @@ def test_ci_dynamic_mapping_full(
 | 
			
		||||
    monkeypatch,
 | 
			
		||||
    ci_base_environment,
 | 
			
		||||
):
 | 
			
		||||
    # The test will always return an empty dictionary
 | 
			
		||||
    def fake_dyn_mapping_urlopener(*args, **kwargs):
 | 
			
		||||
        return BytesIO(
 | 
			
		||||
            json.dumps(
 | 
			
		||||
                {"variables": {"MY_VAR": "hello"}, "ignored_field": 0, "unallowed_field": 0}
 | 
			
		||||
            ).encode()
 | 
			
		||||
    def _urlopen(*args, **kwargs):
 | 
			
		||||
        return MockHTTPResponse.with_json(
 | 
			
		||||
            200,
 | 
			
		||||
            "OK",
 | 
			
		||||
            headers={},
 | 
			
		||||
            body={"variables": {"MY_VAR": "hello"}, "ignored_field": 0, "unallowed_field": 0},
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    monkeypatch.setattr(ci.common, "_dyn_mapping_urlopener", fake_dyn_mapping_urlopener)
 | 
			
		||||
    monkeypatch.setattr(ci.common, "_urlopen", _urlopen)
 | 
			
		||||
 | 
			
		||||
    label = dynamic_mapping_setup(tmpdir)
 | 
			
		||||
    with tmpdir.as_cwd():
 | 
			
		||||
 
 | 
			
		||||
@@ -4,9 +4,11 @@
 | 
			
		||||
 | 
			
		||||
import collections
 | 
			
		||||
import datetime
 | 
			
		||||
import email.message
 | 
			
		||||
import errno
 | 
			
		||||
import functools
 | 
			
		||||
import inspect
 | 
			
		||||
import io
 | 
			
		||||
import itertools
 | 
			
		||||
import json
 | 
			
		||||
import os
 | 
			
		||||
@@ -2128,3 +2130,46 @@ def mock_test_cache(tmp_path_factory):
 | 
			
		||||
    cache_dir = tmp_path_factory.mktemp("cache")
 | 
			
		||||
    print(cache_dir)
 | 
			
		||||
    return spack.util.file_cache.FileCache(str(cache_dir))
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class MockHTTPResponse(io.IOBase):
 | 
			
		||||
    """This is a mock HTTP response, which implements part of http.client.HTTPResponse"""
 | 
			
		||||
 | 
			
		||||
    def __init__(self, status, reason, headers=None, body=None):
 | 
			
		||||
        self.msg = None
 | 
			
		||||
        self.version = 11
 | 
			
		||||
        self.url = None
 | 
			
		||||
        self.headers = email.message.EmailMessage()
 | 
			
		||||
        self.status = status
 | 
			
		||||
        self.code = status
 | 
			
		||||
        self.reason = reason
 | 
			
		||||
        self.debuglevel = 0
 | 
			
		||||
        self._body = body
 | 
			
		||||
 | 
			
		||||
        if headers is not None:
 | 
			
		||||
            for key, value in headers.items():
 | 
			
		||||
                self.headers[key] = value
 | 
			
		||||
 | 
			
		||||
    @classmethod
 | 
			
		||||
    def with_json(cls, status, reason, headers=None, body=None):
 | 
			
		||||
        """Create a mock HTTP response with JSON string as body"""
 | 
			
		||||
        body = io.BytesIO(json.dumps(body).encode("utf-8"))
 | 
			
		||||
        return cls(status, reason, headers, body)
 | 
			
		||||
 | 
			
		||||
    def read(self, *args, **kwargs):
 | 
			
		||||
        return self._body.read(*args, **kwargs)
 | 
			
		||||
 | 
			
		||||
    def getheader(self, name, default=None):
 | 
			
		||||
        self.headers.get(name, default)
 | 
			
		||||
 | 
			
		||||
    def getheaders(self):
 | 
			
		||||
        return self.headers.items()
 | 
			
		||||
 | 
			
		||||
    def fileno(self):
 | 
			
		||||
        return 0
 | 
			
		||||
 | 
			
		||||
    def getcode(self):
 | 
			
		||||
        return self.status
 | 
			
		||||
 | 
			
		||||
    def info(self):
 | 
			
		||||
        return self.headers
 | 
			
		||||
 
 | 
			
		||||
@@ -4,7 +4,6 @@
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
import base64
 | 
			
		||||
import email.message
 | 
			
		||||
import hashlib
 | 
			
		||||
import io
 | 
			
		||||
import json
 | 
			
		||||
@@ -19,49 +18,7 @@
 | 
			
		||||
import spack.oci.oci
 | 
			
		||||
from spack.oci.image import Digest
 | 
			
		||||
from spack.oci.opener import OCIAuthHandler
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class MockHTTPResponse(io.IOBase):
 | 
			
		||||
    """This is a mock HTTP response, which implements part of http.client.HTTPResponse"""
 | 
			
		||||
 | 
			
		||||
    def __init__(self, status, reason, headers=None, body=None):
 | 
			
		||||
        self.msg = None
 | 
			
		||||
        self.version = 11
 | 
			
		||||
        self.url = None
 | 
			
		||||
        self.headers = email.message.EmailMessage()
 | 
			
		||||
        self.status = status
 | 
			
		||||
        self.code = status
 | 
			
		||||
        self.reason = reason
 | 
			
		||||
        self.debuglevel = 0
 | 
			
		||||
        self._body = body
 | 
			
		||||
 | 
			
		||||
        if headers is not None:
 | 
			
		||||
            for key, value in headers.items():
 | 
			
		||||
                self.headers[key] = value
 | 
			
		||||
 | 
			
		||||
    @classmethod
 | 
			
		||||
    def with_json(cls, status, reason, headers=None, body=None):
 | 
			
		||||
        """Create a mock HTTP response with JSON string as body"""
 | 
			
		||||
        body = io.BytesIO(json.dumps(body).encode("utf-8"))
 | 
			
		||||
        return cls(status, reason, headers, body)
 | 
			
		||||
 | 
			
		||||
    def read(self, *args, **kwargs):
 | 
			
		||||
        return self._body.read(*args, **kwargs)
 | 
			
		||||
 | 
			
		||||
    def getheader(self, name, default=None):
 | 
			
		||||
        self.headers.get(name, default)
 | 
			
		||||
 | 
			
		||||
    def getheaders(self):
 | 
			
		||||
        return self.headers.items()
 | 
			
		||||
 | 
			
		||||
    def fileno(self):
 | 
			
		||||
        return 0
 | 
			
		||||
 | 
			
		||||
    def getcode(self):
 | 
			
		||||
        return self.status
 | 
			
		||||
 | 
			
		||||
    def info(self):
 | 
			
		||||
        return self.headers
 | 
			
		||||
from spack.test.conftest import MockHTTPResponse
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class MiddlewareError(Exception):
 | 
			
		||||
 
 | 
			
		||||
@@ -32,6 +32,7 @@
 | 
			
		||||
    get_bearer_challenge,
 | 
			
		||||
    parse_www_authenticate,
 | 
			
		||||
)
 | 
			
		||||
from spack.test.conftest import MockHTTPResponse
 | 
			
		||||
from spack.test.oci.mock_registry import (
 | 
			
		||||
    DummyServer,
 | 
			
		||||
    DummyServerUrllibHandler,
 | 
			
		||||
@@ -39,7 +40,6 @@
 | 
			
		||||
    InMemoryOCIRegistryWithAuth,
 | 
			
		||||
    MiddlewareError,
 | 
			
		||||
    MockBearerTokenServer,
 | 
			
		||||
    MockHTTPResponse,
 | 
			
		||||
    create_opener,
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -354,21 +354,6 @@ def test_url_missing_curl(mutable_config, missing_curl, monkeypatch):
 | 
			
		||||
        web_util.url_exists("https://example.com/")
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def test_url_fetch_text_urllib_bad_returncode(mutable_config, monkeypatch):
 | 
			
		||||
    class response:
 | 
			
		||||
        def getcode(self):
 | 
			
		||||
            return 404
 | 
			
		||||
 | 
			
		||||
    def _read_from_url(*args, **kwargs):
 | 
			
		||||
        return None, None, response()
 | 
			
		||||
 | 
			
		||||
    monkeypatch.setattr(web_util, "read_from_url", _read_from_url)
 | 
			
		||||
    mutable_config.set("config:url_fetch_method", "urllib")
 | 
			
		||||
 | 
			
		||||
    with pytest.raises(spack.error.FetchError, match="failed with error code"):
 | 
			
		||||
        web_util.fetch_url_text("https://example.com/")
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def test_url_fetch_text_urllib_web_error(mutable_config, monkeypatch):
 | 
			
		||||
    def _raise_web_error(*args, **kwargs):
 | 
			
		||||
        raise web_util.SpackWebError("bad url")
 | 
			
		||||
@@ -376,5 +361,5 @@ def _raise_web_error(*args, **kwargs):
 | 
			
		||||
    monkeypatch.setattr(web_util, "read_from_url", _raise_web_error)
 | 
			
		||||
    mutable_config.set("config:url_fetch_method", "urllib")
 | 
			
		||||
 | 
			
		||||
    with pytest.raises(spack.error.FetchError, match="fetch failed to verify"):
 | 
			
		||||
    with pytest.raises(spack.error.FetchError, match="fetch failed"):
 | 
			
		||||
        web_util.fetch_url_text("https://example.com/")
 | 
			
		||||
 
 | 
			
		||||
@@ -209,7 +209,7 @@ def read_from_url(url, accept_content_type=None):
 | 
			
		||||
 | 
			
		||||
    try:
 | 
			
		||||
        response = urlopen(request)
 | 
			
		||||
    except (TimeoutError, URLError) as e:
 | 
			
		||||
    except OSError as e:
 | 
			
		||||
        raise SpackWebError(f"Download of {url.geturl()} failed: {e.__class__.__name__}: {e}")
 | 
			
		||||
 | 
			
		||||
    if accept_content_type:
 | 
			
		||||
@@ -227,7 +227,7 @@ def read_from_url(url, accept_content_type=None):
 | 
			
		||||
            tty.debug(msg)
 | 
			
		||||
            return None, None, None
 | 
			
		||||
 | 
			
		||||
    return response.geturl(), response.headers, response
 | 
			
		||||
    return response.url, response.headers, response
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def push_to_url(local_file_path, remote_path, keep_original=True, extra_args=None):
 | 
			
		||||
@@ -405,12 +405,6 @@ def fetch_url_text(url, curl: Optional[Executable] = None, dest_dir="."):
 | 
			
		||||
        try:
 | 
			
		||||
            _, _, response = read_from_url(url)
 | 
			
		||||
 | 
			
		||||
            returncode = response.getcode()
 | 
			
		||||
            if returncode and returncode != 200:
 | 
			
		||||
                raise spack.error.FetchError(
 | 
			
		||||
                    "Urllib failed with error code {0}".format(returncode)
 | 
			
		||||
                )
 | 
			
		||||
 | 
			
		||||
            output = codecs.getreader("utf-8")(response).read()
 | 
			
		||||
            if output:
 | 
			
		||||
                with working_dir(dest_dir, create=True):
 | 
			
		||||
@@ -419,8 +413,8 @@ def fetch_url_text(url, curl: Optional[Executable] = None, dest_dir="."):
 | 
			
		||||
 | 
			
		||||
                return path
 | 
			
		||||
 | 
			
		||||
        except SpackWebError as err:
 | 
			
		||||
            raise spack.error.FetchError("Urllib fetch failed to verify url: {0}".format(str(err)))
 | 
			
		||||
        except (SpackWebError, OSError, ValueError) as err:
 | 
			
		||||
            raise spack.error.FetchError(f"Urllib fetch failed: {err}")
 | 
			
		||||
 | 
			
		||||
    return None
 | 
			
		||||
 | 
			
		||||
@@ -464,7 +458,7 @@ def url_exists(url, curl=None):
 | 
			
		||||
            timeout=spack.config.get("config:connect_timeout", 10),
 | 
			
		||||
        )
 | 
			
		||||
        return True
 | 
			
		||||
    except (TimeoutError, URLError) as e:
 | 
			
		||||
    except OSError as e:
 | 
			
		||||
        tty.debug(f"Failure reading {url}: {e}")
 | 
			
		||||
        return False
 | 
			
		||||
 | 
			
		||||
@@ -746,7 +740,7 @@ def _spider(url: urllib.parse.ParseResult, collect_nested: bool, _visited: Set[s
 | 
			
		||||
                subcalls.append(abs_link)
 | 
			
		||||
                _visited.add(abs_link)
 | 
			
		||||
 | 
			
		||||
    except (TimeoutError, URLError) as e:
 | 
			
		||||
    except OSError as e:
 | 
			
		||||
        tty.debug(f"[SPIDER] Unable to read: {url}")
 | 
			
		||||
        tty.debug(str(e), level=2)
 | 
			
		||||
        if isinstance(e, URLError) and isinstance(e.reason, ssl.SSLError):
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user