# Copyright (c) Cloud Linux Software, Inc
# Licensed under CLOUD LINUX LICENSE AGREEMENT
# http://cloudlinux.com/docs/LICENCE.TXT

"""Doctor v2 vitals collector (KPT-5984).

Python port of the kcdoctor.sh (1.0-8) vitals collection: gathers the
same data set into a DataPackage-based archive processed by the patch
server generic uploads pipeline.

Scalar and short values are consolidated into a single ``doctor.json``
(the eportal doctor precedent), one field each, instead of a tiny
archive entry per value; bulk data (logs, command dumps, package lists,
sysfs tunables, crash artifacts) stays as separate archive entries. The
1:1 inventory of collected items lives in
docs/features/KPT-5984-doctor-v2/parity-inventory.md.
"""

import fnmatch
import glob
import os
import re
import socket

from kcarectl import auth, config, delivery_kit, http_utils, kcare, log_utils, platform_utils, serverid, utils
from kcarectl.process_utils import run_command
from kcarectl.py23 import HTTPError, URLError, httplib

if False:  # pragma: no cover
    from typing import Any, Callable, Dict, List, Optional  # noqa: F401

DOCTOR_VERSION = '2.0-1'

GRUB2_CFG = '/boot/grub2/grub.cfg'
KDUMP_CONF = '/etc/kdump.conf'
DPKG_LOG = '/var/log/dpkg.log'
APT_SOURCES_GLOB = '/etc/apt/sources.list*'
YUM_REPOS_GLOB = '/etc/yum.repos.d/*'
LIBCARE_LOGS_GLOB = '/var/log/libcare/*.log'
LOG_TAIL_LINES = 10000

SYSFS_ROOT = '/sys'
SYSFS_NAME_PATTERNS = ('enable*', 'nr_*', 'max_*', '*cnt*')
# plain prefix match, mirroring the kcdoctor.sh get_sysfs_info_detail()
# `! -path "<prefix>*"` find filters
SYSFS_EXCLUDE_PREFIXES = (
    '/sys/kernel/debug/tracing/events',
    '/sys/kernel/tracing/events',
    '/sys/devices',
)

CRASH_DUMP_PATTERNS = ('[0-9]*.log', 'kmsg*log')


class KcarectlDoctorPackage(delivery_kit.DataPackage):
    data_type = 'kcarectl-doctor'
    upload_uri = '/upload/kcarectl-doctor/'

    @property
    def max_size(self):
        # type: () -> int
        return config.DOCTOR_REPORT_MAX_SIZE_BYTES


# --- bulk items: one archive file each ---


class CollectionSpec(object):
    """A single bulk vitals item ported from kcdoctor.sh.

    Collection failures are recorded in errors.log and never abort the
    report: an unhandled exception inside the package context manager
    would remove the whole archive.
    """

    def __init__(self, arcname, collect):
        # type: (str, Callable[[delivery_kit.DataPackage, str], None]) -> None
        self.arcname = arcname
        self._collect = collect

    def apply(self, data_package):
        # type: (delivery_kit.DataPackage) -> None
        try:
            self._collect(data_package, self.arcname)
        except Exception as e:
            data_package.log_error('failed to collect {0}: {1}'.format(self.arcname, e))


def run_spec(arcname, cmd):
    # type: (str, str) -> CollectionSpec
    """`run "<cmd>"` kcdoctor.sh helper (no shell features)"""

    def collect(data_package, arcname):
        # type: (delivery_kit.DataPackage, str) -> None
        data_package.add_stdout(arcname, cmd)

    return CollectionSpec(arcname, collect)


def dump_spec(arcname, path):
    # type: (str, str) -> CollectionSpec
    """`dump "<path>"` kcdoctor.sh helper for regular files"""

    def collect(data_package, arcname):
        # type: (delivery_kit.DataPackage, str) -> None
        data_package.add_file(arcname, src_path=path)

    return CollectionSpec(arcname, collect)


def proc_dump_spec(arcname, path):
    # type: (str, str) -> CollectionSpec
    """`dump "<path>"` for /proc and /sys files.

    tar reports them as 0-size so they are first read into memory
    (see the same approach in anomaly.prepare_kernel_anomaly_report).
    """

    def collect(data_package, arcname):
        # type: (delivery_kit.DataPackage, str) -> None
        if not os.path.exists(path):
            data_package.log_error('file not found: {0}'.format(path))
            return

        with open(path, 'rb') as f:
            data_package.add_file(arcname, data_bytes=f.read())

    return CollectionSpec(arcname, collect)


def callable_spec(arcname, fn):
    # type: (str, Callable[[delivery_kit.DataPackage, str], None]) -> CollectionSpec
    return CollectionSpec(arcname, fn)


# --- scalar/short items: one doctor.json field each ---


class FieldSpec(object):
    """A single doctor.json field ported from kcdoctor.sh.

    Collection failures are recorded in errors.log and never abort the
    report; a collector returning None omits the field (e.g. a
    distro-specific source file absent on this system).
    """

    def __init__(self, key, collect):
        # type: (str, Callable[[], Any]) -> None
        self.key = key
        self._collect = collect

    def apply(self, data_package, report):
        # type: (delivery_kit.DataPackage, Dict[str, Any]) -> None
        try:
            value = self._collect()
        except Exception as e:
            data_package.log_error('failed to collect {0}: {1}'.format(self.key, e))
            return

        if value is not None:
            report[self.key] = value


def read_field(path):
    # type: (str) -> Callable[[], Optional[str]]
    """`dump "<path>"` as a doctor.json string field.

    Read in binary (non-utf8 safe) and decoded leniently; an absent
    distro-specific source file simply omits the field (None).
    """

    def collect():
        # type: () -> Optional[str]
        if not os.path.exists(path):
            return None

        with open(path, 'rb') as f:
            return f.read().decode('utf-8', 'replace').strip()

    return collect


def grep_field(path, pattern):
    # type: (str, str) -> Callable[[], Optional[List[str]]]
    """`grep <pattern> <path>` as a doctor.json list-of-lines field.

    Match on bytes (like collect_apt_sources) so a non-utf8 byte cannot
    lose a line; the matched lines are decoded leniently for the JSON.
    """
    regex = re.compile(utils.bstr(pattern))

    def collect():
        # type: () -> Optional[List[str]]
        if not os.path.exists(path):
            return None

        with open(path, 'rb') as f:
            lines = f.read().splitlines()

        return [line.decode('utf-8', 'replace') for line in lines if regex.search(line)]

    return collect


def cmd_field(argv, as_lines=False):
    # type: (List[str], bool) -> Callable[[], Any]
    """`run "<cmd>"` captured as a doctor.json field instead of a file."""

    def collect():
        # type: () -> Any
        _, stdout, _ = run_command(argv, catch_stdout=True, catch_stderr=True)
        text = (stdout or '').strip()
        return text.splitlines() if as_lines else text

    return collect


def collect_doctor_version():
    # type: () -> str
    return DOCTOR_VERSION


@utils.cached
def get_main_ip():
    # type: () -> str
    """the public IP of the machine as seen by the patch server.

    Cached: the main_ip and server_id fields share one HTTP roundtrip
    per run.
    """
    try:
        response = http_utils.urlopen(utils.get_patch_server_url('myip'))
        return utils.nstr(response.read()).strip()
    except Exception:
        return 'NA'


def collect_server_id():
    # type: () -> str
    server_id = serverid.get_serverid()
    if not server_id:
        # like kcdoctor.sh: fall back to the main IP with dots replaced
        server_id = get_main_ip().replace('.', '_')

    return utils.nstr(server_id)


def collect_kernel_id():
    # type: () -> str
    # kcdoctor.sh runs `sha1sum /proc/version`; get_kernel_hash() is the
    # same digest without the trailing file name
    return kcare.get_kernel_hash()


def collect_uname():
    # type: () -> Dict[str, str]
    result = {}  # type: Dict[str, str]
    for flag in ('a', 'r', 'm', 'p', 'o'):
        _, stdout, _ = run_command(['uname', '-' + flag], catch_stdout=True, catch_stderr=True)
        result[flag] = (stdout or '').strip()

    return result


def collect_grub2_entries():
    # type: () -> Optional[List[str]]
    """`grep vmlinuz /boot/grub2/grub.cfg | sed 's/root=.*//'`"""
    if not os.path.exists(GRUB2_CFG):
        return None

    with open(GRUB2_CFG, 'rb') as f:
        text = f.read().decode('utf-8', 'replace')

    return [re.sub(r'root=.*', '', line) for line in text.splitlines() if 'vmlinuz' in line]


def collect_yum_repos():
    # type: () -> List[str]
    # `echo /etc/yum.repos.d/*`: the repo files present (empty when none)
    return sorted(glob.glob(YUM_REPOS_GLOB))


def collect_control_panel():
    # type: () -> Dict[str, Any]
    """presence-only port of the kcdoctor.sh detect_cp() probes"""
    panels = [name for name, probe in platform_utils.CONTROL_PANEL_PROBES if probe()]
    return {'cp': panels, 'softaculous': platform_utils.has_softaculous()}


# --- bulk collectors (one archive file each) ---


def collect_ipcs(data_package, arcname):
    # type: (delivery_kit.DataPackage, str) -> None
    """`ipcs -m | sed -e s/-/=/g`"""
    _, stdout, _ = run_command(['ipcs', '-m'], catch_stdout=True, catch_stderr=True)
    data_package.add_file(arcname, data_bytes=utils.bstr((stdout or '').replace('-', '='), encoding='utf-8'))


def collect_dpkg_install_log(data_package, arcname):
    # type: (delivery_kit.DataPackage, str) -> None
    """`grep ' install ' /var/log/dpkg.log`"""
    if not os.path.exists(DPKG_LOG):
        data_package.log_error('file not found: {0}'.format(DPKG_LOG))
        return

    with open(DPKG_LOG, 'rb') as f:
        lines = [line for line in f.read().splitlines() if b' install ' in line]

    data_package.add_file(arcname, data_bytes=b'\n'.join(lines) + b'\n')


def collect_packages(data_package, arcname):
    # type: (delivery_kit.DataPackage, str) -> None
    # same package list commands as anomaly.prepare_kernel_anomaly_report
    if os.path.exists('/usr/bin/rpm') or os.path.exists('/bin/rpm'):
        packages_cmd = r'rpm -q -a --queryformat="%{N}|%{V}-%{R}|%{arch}|%{INSTALLTIME:date}\n"'
    elif os.path.exists('/usr/bin/dpkg'):
        packages_cmd = r'/usr/bin/dpkg-query -W -f "${binary:Package}|${Version}|${Architecture}\n"'
        # an unreadable dpkg.log must not lose the package list below
        # (partial results, like the other multi-source collectors)
        try:
            collect_dpkg_install_log(data_package, 'dpkg.log')
        except Exception as e:
            data_package.log_error('failed to collect dpkg.log: {0}'.format(e))
    else:
        packages_cmd = 'echo "unknown package manager"'

    data_package.add_stdout(arcname, packages_cmd)


def collect_apt_sources(data_package, arcname):
    # type: (delivery_kit.DataPackage, str) -> None
    """`grep -rE '^(deb|URIs)' /etc/apt/sources.list*`"""
    regex = re.compile(br'^(deb|URIs)')

    files = []  # type: List[str]
    for path in sorted(glob.glob(APT_SOURCES_GLOB)):
        if os.path.isdir(path):
            for root, _, names in os.walk(path):
                files.extend(os.path.join(root, name) for name in sorted(names))
        else:
            files.append(path)

    # like the shell `grep`: report the no-match case in errors.log
    # instead of storing an empty entry
    if not files:
        data_package.log_error('file not found: {0}'.format(APT_SOURCES_GLOB))
        return

    lines = []  # type: List[bytes]
    for path in files:
        # an unreadable file must not lose the lines of the others
        try:
            with open(path, 'rb') as f:
                content = f.read()
        except Exception as e:
            data_package.log_error('failed to read {0}: {1}'.format(path, e))
            continue

        prefix = utils.bstr('{0}:'.format(path), encoding='utf-8')
        lines.extend(prefix + line for line in content.splitlines() if regex.search(line))

    data_package.add_file(arcname, data_bytes=b'\n'.join(lines) + b'\n')


def collect_libcare_logs(data_package, arcname):
    # type: (delivery_kit.DataPackage, str) -> None
    """`tail -n10000 /var/log/libcare/*.log`"""
    paths = sorted(glob.glob(LIBCARE_LOGS_GLOB))
    # like the shell `tail`: report the no-match case in errors.log
    # instead of storing an empty entry
    if not paths:
        data_package.log_error('file not found: {0}'.format(LIBCARE_LOGS_GLOB))
        return

    chunks = []  # type: List[bytes]
    for path in paths:
        # an unreadable log must not lose the tails of the others
        try:
            with open(path, 'rb') as f:
                lines = f.read().splitlines()[-LOG_TAIL_LINES:]
        except Exception as e:
            data_package.log_error('failed to read {0}: {1}'.format(path, e))
            continue

        header = utils.bstr('==> {0} <==\n'.format(path), encoding='utf-8')
        chunks.append(header + b'\n'.join(lines))

    data_package.add_file(arcname, data_bytes=b'\n'.join(chunks) + b'\n')


def collect_crash_dumps(data_package, arcname):
    # type: (delivery_kit.DataPackage, str) -> None
    """crashreporter artifacts from /var/cache/kcare/dumps"""
    if not os.path.isdir(config.KDUMPS_DIR):
        return

    data_package.add_stdout(arcname, 'ls -lR {0}'.format(config.KDUMPS_DIR))
    for root, _, names in os.walk(config.KDUMPS_DIR):
        for name in sorted(names):
            if any(fnmatch.fnmatch(name, pattern) for pattern in CRASH_DUMP_PATTERNS):
                path = os.path.join(root, name)
                # keep the path relative to KDUMPS_DIR in the arcname so
                # same-named dumps from different subdirectories do not
                # overwrite each other in the archive
                rel_path = os.path.relpath(path, config.KDUMPS_DIR)
                data_package.add_file('crashreporter/{0}'.format(rel_path), src_path=path)


def collect_kdump(data_package, arcname):
    # type: (delivery_kit.DataPackage, str) -> None
    # kcdoctor.sh gates kdump collection on `[ -e /etc/kdump.conf ]`; without
    # it get_kdump_root() defaults to /var/crash, so an unconfigured host with
    # a stale crash dir would otherwise be collected.
    if not os.path.exists(KDUMP_CONF):
        return

    kdump_root = kcare.get_kdump_root()
    if not os.path.isdir(kdump_root):
        return

    data_package.add_stdout(arcname, 'ls -lR {0}'.format(kdump_root))
    for evt in sorted(glob.glob(os.path.join(kdump_root, '*'))):
        # only crash event directories can hold a vmcore-dmesg.txt;
        # skip regular files to keep errors.log free of noise
        if not os.path.isdir(evt):
            continue

        data_package.add_file(
            'kdump/{0}/vmcore-dmesg.txt'.format(os.path.basename(evt)),
            src_path=os.path.join(evt, 'vmcore-dmesg.txt'),
        )


def collect_sysfs_config(data_package, arcname):
    # type: (delivery_kit.DataPackage, str) -> None
    """tunables gathered by the kcdoctor.sh get_sysfs_info() `find /sys` calls"""
    chunks = []
    for root, dirs, names in os.walk(SYSFS_ROOT):
        if root.startswith(SYSFS_EXCLUDE_PREFIXES):
            dirs[:] = []  # do not descend into excluded subtrees
            continue

        for name in sorted(names):
            if any(fnmatch.fnmatch(name, pattern) for pattern in SYSFS_NAME_PATTERNS):
                path = os.path.join(root, name)
                try:
                    with open(path, 'rb') as f:
                        content = f.read()
                except Exception:
                    content = b''

                chunks.append(utils.bstr('cat {0}\n'.format(path)) + content + b'\n')

    data_package.add_file(arcname, data_bytes=b''.join(chunks))


# the full kcdoctor.sh (1.0-8) vitals set, in the script order; see
# docs/features/KPT-5984-doctor-v2/parity-inventory.md for the mapping

# scalar/short items, consolidated into doctor.json (one field each)
FIELD_SPECS = [
    FieldSpec('doctor_version', collect_doctor_version),
    FieldSpec('virt_what', cmd_field(['/usr/libexec/kcare/virt-what'], as_lines=True)),
    FieldSpec('main_ip', get_main_ip),
    FieldSpec('server_id', collect_server_id),
    FieldSpec('kernel_id', collect_kernel_id),
    FieldSpec('date', cmd_field(['date'])),
    FieldSpec('uname', collect_uname),
    FieldSpec('redhat_release', read_field('/etc/redhat-release')),
    FieldSpec('debian_version', read_field('/etc/debian_version')),
    FieldSpec('os_release', read_field('/etc/os-release')),
    FieldSpec('issue', read_field('/etc/issue')),
    FieldSpec('sysconfig_kernel', read_field('/etc/sysconfig/kernel')),
    FieldSpec('proc_uptime', read_field('/proc/uptime')),
    FieldSpec('proc_loadavg', read_field('/proc/loadavg')),
    FieldSpec('proc_cmdline', read_field('/proc/cmdline')),
    FieldSpec('proc_version', read_field('/proc/version')),
    FieldSpec('default_grub', grep_field('/etc/default/grub', 'DEFAULT')),
    FieldSpec('grub2_entries', collect_grub2_entries),
    FieldSpec('sshd_port', grep_field('/etc/ssh/sshd_config', 'Port')),
    FieldSpec('yum_repos_d', collect_yum_repos),
    FieldSpec('control_panel', collect_control_panel),
]

# bulk items, one archive file each
FILE_SPECS = [
    run_spec('syslog', 'tail -n{0} /var/log/syslog'.format(LOG_TAIL_LINES)),
    run_spec('dmesg', 'dmesg'),
    run_spec('messages', 'tail -n{0} /var/log/messages'.format(LOG_TAIL_LINES)),
    run_spec('ls_var_cache_kcare', 'ls -lR /var/cache/kcare/'),
    dump_spec('kcare.conf', '/etc/sysconfig/kcare/kcare.conf'),
    run_spec('cpuinfo', 'cat /proc/cpuinfo'),
    proc_dump_spec('proc_vmstat', '/proc/vmstat'),
    proc_dump_spec('proc_devices', '/proc/devices'),
    proc_dump_spec('proc_diskstats', '/proc/diskstats'),
    proc_dump_spec('proc_mdstat', '/proc/mdstat'),
    proc_dump_spec('proc_meminfo', '/proc/meminfo'),
    proc_dump_spec('proc_swaps', '/proc/swaps'),
    proc_dump_spec('proc_filesystems', '/proc/filesystems'),
    proc_dump_spec('proc_mounts', '/proc/mounts'),
    proc_dump_spec('proc_interrupts', '/proc/interrupts'),
    dump_spec('grub.conf', '/boot/grub/grub.conf'),
    proc_dump_spec('proc_modules', '/proc/modules'),
    dump_spec('grub2.cfg', GRUB2_CFG),
    proc_dump_spec('proc_zoneinfo', '/proc/zoneinfo'),
    run_spec('ls_boot_configs', 'ls /etc/grub.conf /boot/grub/grub.conf /boot/grub/menu.lst'),
    run_spec('ls_boot', 'ls -l /boot'),
    run_spec('printenv', 'printenv'),
    run_spec('dmidecode', 'dmidecode'),
    callable_spec('ipcs', collect_ipcs),
    run_spec('sysctl', 'sysctl -a'),
    dump_spec('sysctl.conf', '/etc/sysctl.conf'),
    callable_spec('packages.list', collect_packages),
    run_spec('lspci', 'lspci -vv'),
    run_spec('dpkg_l', 'dpkg -l'),
    callable_spec('apt_sources', collect_apt_sources),
    dump_spec('yum.conf', '/etc/yum.conf'),
    run_spec('yum_repolist', 'yum repolist'),
    callable_spec('libcare_logs', collect_libcare_logs),
    run_spec('kcarectl.log', 'tail -n{0} /var/log/kcarectl.log'.format(LOG_TAIL_LINES)),
    dump_spec('kdump.conf', KDUMP_CONF),
    dump_spec('kcare-cron', '/etc/cron.d/kcare-cron'),
    callable_spec('crashreporter/ls', collect_crash_dumps),
    callable_spec('kdump/ls', collect_kdump),
    run_spec('aa_status', 'aa-status'),
    run_spec('sestatus', 'sestatus'),
    run_spec('kprobes_list', 'cat /sys/kernel/debug/kprobes/list'),
    callable_spec('sysfs_config', collect_sysfs_config),
    run_spec('lsblk', 'lsblk -f'),
    run_spec('df', 'df -h'),
]


@utils.catch_errors(logger=log_utils.logwarn)
def prepare_doctor_report():
    # type: () -> Optional[KcarectlDoctorPackage]
    # None when collection fails (catch_errors): KPT-6064/KPT-6065
    # callers must handle it

    data_package = KcarectlDoctorPackage()

    with data_package:
        report = {}  # type: Dict[str, Any]
        for field in FIELD_SPECS:
            field.apply(data_package, report)

        data_package.add_json('doctor.json', report)

        for spec in FILE_SPECS:
            spec.apply(data_package)

    return data_package


def send_doctor_report(fallback, force_fallback=False):
    # type: (Callable[[], None], bool) -> None
    """Collect and upload the doctor report via the generic uploads proxy.

    PUTs the archive to ``/upload/kcarectl-doctor/`` -- the same modern
    pipeline as ``kernel-anomaly`` -- which ePortal proxies transparently
    to the patch server, so no ePortal change is required. Any 2xx reply
    is success, including the ``REDUCED_REPORT`` ``204`` where the proxy
    intentionally drops the report (``send()`` returns without raising, so
    we do not fall back).

    Falls back to the legacy ``kcdoctor.sh`` flow (``fallback``) when:

    * ``force_fallback`` (the ``--doctor --fallback`` CLI flag) or
      ``config.FORCE_DOCTOR_FALLBACK`` is set -- the rollout kill-switch.
      ``--fallback`` is the one-off, per-invocation form; the config knob
      is settable per host in kcare.conf or pushed fleet-wide via the
      ``KC-Flag-Force-Doctor-Fallback`` feature flag. Either forces the
      legacy flow on every host, registered or not, before the new path
      is even considered;
    * the host is not registered, i.e. there is no auth string (OQ-4: the
      proxy upload requires auth, so the new path is not even attempted);
    * report collection failed (``prepare_doctor_report`` returned None);
    * the new route is unavailable on an older patch server / ePortal --
      a 404 / 405 / 403, surfaced as ``HTTPError``, or a connection-level
      failure. ``send()`` drives ``http_utils.upload_file`` (raw
      ``httplib``, not ``urllib``), so transport failures surface as an
      ``OSError`` subclass -- ``socket.timeout``, ``ConnectionRefused``,
      ``socket.gaierror`` (DNS), ``ssl.SSLError`` -- or an
      ``httplib.HTTPException``, *not* ``URLError``; all of these mean the
      new path is unusable. ``upload_file`` raises only on status >= 400,
      so a redirect is not treated as a failure; the proxy and patch
      server never redirect uploads.

    :param fallback: legacy ``kcdoctor.sh`` runner, injected by the caller
        (KPT-6065) to keep this module free of an ``__init__`` import cycle.
    :param force_fallback: when True (``--doctor --fallback``), skip the v2
        path and run ``fallback`` directly; the per-invocation form of
        ``config.FORCE_DOCTOR_FALLBACK``.
    """

    if force_fallback or config.FORCE_DOCTOR_FALLBACK:
        src = '--fallback requested' if force_fallback else 'FORCE_DOCTOR_FALLBACK is set'
        log_utils.loginfo(
            'doctor: {0}; using the legacy kcdoctor.sh flow'.format(src),
            print_msg=False,
        )
        fallback()
        return

    if auth.get_http_auth_string() is None:
        log_utils.loginfo(
            'doctor: host is not registered; using the legacy kcdoctor.sh flow',
            print_msg=False,
        )
        fallback()
        return

    data_package = prepare_doctor_report()
    if data_package is None:
        log_utils.logwarn('doctor: report collection failed; using the legacy kcdoctor.sh flow')
        fallback()
        return

    try:
        upload_name = data_package.send()
        log_utils.loginfo('doctor: report uploaded as {0}'.format(upload_name), print_msg=False)
        # surface the uploaded filename to the user, the same way the eportal
        # --doctor does (kc.eportal make_doctor_report -> utils.print_wrapper)
        # so support can locate the report; the legacy kcdoctor.sh fallback
        # prints its own "Key:" line instead. print_wrapper (not loginfo) so
        # the line is shown unconditionally -- loginfo is gated by PRINT_LEVEL
        # and would be dropped under --quiet/--auto-update, whereas the legacy
        # kcdoctor.sh echo is not.
        utils.print_wrapper('Please provide this filename to support: {0}'.format(upload_name))
    except (HTTPError, URLError, httplib.HTTPException, socket.error) as err:
        # upload_file drives httplib directly, so a transport failure
        # surfaces as socket.error (== OSError on py3: timeout, connection
        # refused, DNS, TLS) or httplib.HTTPException -- not URLError. An
        # HTTPError (route absent: 404 / 405 / 403) means the same thing:
        # the new path is unusable, so fall back to the legacy flow.
        log_utils.logwarn('doctor: report upload unavailable ({0}); using the legacy kcdoctor.sh flow'.format(err))
        fallback()
    finally:
        data_package.remove_archive()
