from copy import deepcopy
import logging
from typing import List, cast, Optional
import requests
from .quay_client import QuayClient
from .types import ManifestList, Manifest
LOG = logging.getLogger("pubtools.quay")
[docs]class ManifestListMerger:
    """Class containing logic for merging manifest lists of two images."""
[docs]    def __init__(
        self,
        src_image: str,
        dest_image: str,
        src_quay_host: Optional[str] = None,
        src_quay_username: Optional[str] = None,
        src_quay_password: Optional[str] = None,
        dest_quay_username: Optional[str] = None,
        dest_quay_password: Optional[str] = None,
        host: Optional[str] = None,
    ) -> None:
        """
        Initialize.
        Args:
            src_image (str):
                Address to a new image whose manifest list contains the newer data.
            dest_image (str):
                Address to an older image whose data will be overwritten.
            src_quay_host (str):
                Custom hostname to connect to use for pulling src_image.
            src_quay_username (str):
                Quay username to get src_image. If ommited, external client instance should be set.
            src_quay_password (str):
                Quay password to get src_image. If ommited, external client instance should be set.
            dest_quay_username (str):
                Quay username to get dest_image. If ommited, external client instance should be set.
            dest_quay_password (str):
                Quay password to get dest_image. If ommited, external client instance should be set.
            host (str):
                Custom hostname to connect to. If ommited, standard quay.io will be used.
        """
        self.src_image = src_image
        self.dest_image = dest_image
        if src_quay_username and src_quay_password:
            self._src_quay_client: Optional[QuayClient] = QuayClient(
                src_quay_username, src_quay_password, src_quay_host or host
            )
        else:
            self._src_quay_client = None
        if dest_quay_username and dest_quay_password:
            self._dest_quay_client: Optional[QuayClient] = QuayClient(
                dest_quay_username, dest_quay_password, host
            )
        else:
            self._dest_quay_client = None 
[docs]    def set_quay_clients(self, src_quay_client: QuayClient, dest_quay_client: QuayClient) -> None:
        """
        Set client instances to be used for the HTTP API operations.
        Args:
            src_quay_client (QuayClient):
                Instance of QuayClient.
            dest_quay_client (QuayClient):
                Instance of QuayClient.
        """
        self._src_quay_client = src_quay_client
        self._dest_quay_client = dest_quay_client 
[docs]    def merge_manifest_lists(self) -> None:
        """Merge manifest lists and upload to Quay. Main entrypoint method."""
        if not self._src_quay_client or not self._dest_quay_client:
            raise RuntimeError("QuayClient instance must be set for both source and dest images")
        LOG.info(
            "Merging manifest lists of images '{0}' and '{1}'".format(
                self.src_image, self.dest_image
            )
        )
        src_manifest_list = cast(
            ManifestList,
            self._src_quay_client.get_manifest(
                self.src_image, media_type=QuayClient.MANIFEST_LIST_TYPE
            ),
        )
        dest_manifest_list = cast(
            ManifestList,
            self._dest_quay_client.get_manifest(
                self.dest_image, media_type=QuayClient.MANIFEST_LIST_TYPE
            ),
        )
        missing_archs = self.get_missing_architectures(src_manifest_list, dest_manifest_list)
        new_manifest_list = self._add_missing_architectures(src_manifest_list, missing_archs)
        LOG.info("Uploading the new manifest list to '{0}'".format(self.dest_image))
        self._dest_quay_client.upload_manifest(new_manifest_list, self.dest_image)
        LOG.info("Merging manifests lists: complete.") 
[docs]    @staticmethod
    def get_missing_architectures(
        src_manifest_list: ManifestList, dest_manifest_list: ManifestList
    ) -> List[Manifest]:
        """
        Get architectures which are missing from the new source image.
        NOTE: this method assumes that images are built only for one OS. The following logic
        would need to be overwritten if multiple OS builds started to be made.
        Args:
            src_manifest_list (dict):
                Manifest list of the source image.
            dest_manifest_list (dict):
                Manifest list of the destination image.
        Returns ([dict]):
            List of arch manifest data present in destination image but missing from source.
        """
        missing_archs: List[Manifest] = []
        missing_archs_log = []
        src_archs = [arch["platform"]["architecture"] for arch in src_manifest_list["manifests"]]
        for dest_arch_dict in dest_manifest_list["manifests"]:
            if dest_arch_dict["platform"]["architecture"] not in src_archs:
                missing_archs.append(deepcopy(dest_arch_dict))
                missing_archs_log.append(dest_arch_dict["platform"]["architecture"])
        LOG.info(
            "Architectures missing from the new image: {0}".format(", ".join(missing_archs_log))
        )
        return missing_archs 
[docs]    def _add_missing_architectures(
        self, src_manifest_list: ManifestList, missing_archs: List[Manifest]
    ) -> ManifestList:
        """
        Add missing architectures to the source manifest list.
        Args:
            src_manifest_list (dict):
                Source manifest list.
            missing_archs ([dict]):
                Manifest data of missing architectures.
        Retuns (dict):
            New manifest list containing all the architectures.
        """
        new_manifest_list = deepcopy(src_manifest_list)
        new_manifest_list["manifests"] = new_manifest_list["manifests"] + missing_archs
        return new_manifest_list 
[docs]    def merge_manifest_lists_selected_architectures(
        self, eligible_archs: List[str]
    ) -> ManifestList:
        """
        Merge manifests lists. Only specified archs are eligible for merging.
        This is an alternate workflow used in 'tag-docker'.
        Args:
            eligible_archs ([str]):
                Archs eligible for merging with the old manifest list.
        Returns (dict):
            New manifest list.
        """
        if not self._src_quay_client or not self._dest_quay_client:
            raise RuntimeError("QuayClient instance must be set for both source and dest images")
        src_manifest_list = cast(ManifestList, self._src_quay_client.get_manifest(self.src_image))
        # It's possible that destination doesn't exist in this workflow. ML merging logic is still
        # necessary due to only some archs being eligible
        try:
            dest_manifest_list = cast(
                ManifestList,
                self._dest_quay_client.get_manifest(self.dest_image),
            )
        except requests.exceptions.HTTPError as e:
            if e.response.status_code == 404 or e.response.status_code == 401:
                dest_manifest_list = None
            else:
                raise
        archs_to_add = []
        manifests_to_add = []
        for src_arch_dict in src_manifest_list["manifests"]:
            if src_arch_dict["platform"]["architecture"] in eligible_archs:
                manifests_to_add.append(deepcopy(src_arch_dict))
                archs_to_add.append(src_arch_dict["platform"]["architecture"])
        manifests_to_keep = []
        if dest_manifest_list:
            for dest_arch_dict in dest_manifest_list["manifests"]:
                if dest_arch_dict["platform"]["architecture"] not in archs_to_add:
                    manifests_to_keep.append(deepcopy(dest_arch_dict))
        new_manifest_list = deepcopy(src_manifest_list)
        new_manifest_list["manifests"] = manifests_to_add + manifests_to_keep
        return new_manifest_list