UNCLASSIFIED

"...replicator/dcar/image.py" did not exist on "37e4fe6335eb735101b4b65853a22728315d5b55"
image.py 18.1 KB
Newer Older
Ian Dunbar-Hall's avatar
Ian Dunbar-Hall committed
1 2 3 4 5 6 7 8 9
"""
This module is used to interact with images on the DCAR
website.
"""
import json
import sys
import os
import tarfile
import shutil
Zachary Prebosnyak's avatar
Zachary Prebosnyak committed
10 11
import requests
import docker
Ian Dunbar-Hall's avatar
Ian Dunbar-Hall committed
12

Zachary Prebosnyak's avatar
Zachary Prebosnyak committed
13
from typing import Tuple
Ian Dunbar-Hall's avatar
Ian Dunbar-Hall committed
14 15
from datetime import timezone
from datetime import timedelta
Zachary Prebosnyak's avatar
Zachary Prebosnyak committed
16
from subprocess import run, PIPE
Ian Dunbar-Hall's avatar
Ian Dunbar-Hall committed
17 18 19
from time import sleep
from typing import Dict

Ian Dunbar-Hall's avatar
Ian Dunbar-Hall committed
20
from dateutil.parser import parse
21
from requests.exceptions import ProxyError, ReadTimeout
Ian Dunbar-Hall's avatar
Ian Dunbar-Hall committed
22 23

from dcar.request import Session
Zachary Prebosnyak's avatar
Zachary Prebosnyak committed
24
from dcar.utils import validate_argument, populate_image_name
Ian Dunbar-Hall's avatar
Ian Dunbar-Hall committed
25 26 27 28 29 30 31 32

def website_find_all(session) -> dict:
    """
    This function uses the DCAR website and python beautiful soup to collect
    metadata.
    """
    validate_argument("session", session, Session)

Ian Dunbar-Hall's avatar
Ian Dunbar-Hall committed
33
    image_details_dict: Dict[str, Dict] = {}
Ian Dunbar-Hall's avatar
Ian Dunbar-Hall committed
34

Zachary Prebosnyak's avatar
Zachary Prebosnyak committed
35
    vendor_level_request = session.get("/data/")
Ian Dunbar-Hall's avatar
Ian Dunbar-Hall committed
36
    vendors = vendor_level_request.json()
Ian Dunbar-Hall's avatar
Ian Dunbar-Hall committed
37

Zachary Prebosnyak's avatar
Zachary Prebosnyak committed
38
    # iterate through all vendor pages starting on https://dcar.dso.mil/
Ian Dunbar-Hall's avatar
Ian Dunbar-Hall committed
39 40
    for vendor in vendors['folders']:
        try:
Zachary Prebosnyak's avatar
Zachary Prebosnyak committed
41
            product_level_request = session.get("/data/{}".format(vendor))
Ian Dunbar-Hall's avatar
Ian Dunbar-Hall committed
42 43 44 45
            products = product_level_request.json()
        except ProxyError:
            print("Proxy Error Occurred, unable to pull vendor data for {}".format(vendor), flush=True, file=sys.stderr)
            continue
Ian Dunbar-Hall's avatar
Ian Dunbar-Hall committed
46 47

        # iterate through all product pages for each vendor page
Ian Dunbar-Hall's avatar
Ian Dunbar-Hall committed
48 49
        for product in products['folders']:
            try:
Zachary Prebosnyak's avatar
Zachary Prebosnyak committed
50
                image_level_request = session.get("/data/{}/{}".format(vendor, product))
Ian Dunbar-Hall's avatar
Ian Dunbar-Hall committed
51
                images = image_level_request.json()
52 53 54
            except ReadTimeout:
                print("Time Out Occurred, unable to pull image data for {}".format(product), flush=True, file=sys.stderr)
                continue
Ian Dunbar-Hall's avatar
Ian Dunbar-Hall committed
55 56 57
            except ProxyError:
                print("Proxy Error Occurred, unable to pull image data for {}".format(product), flush=True, file=sys.stderr)
                continue
Ian Dunbar-Hall's avatar
Ian Dunbar-Hall committed
58 59

            # iterate through all image pages for each product page
Ian Dunbar-Hall's avatar
Ian Dunbar-Hall committed
60 61
            for image in images['folders']:
                try:
Zachary Prebosnyak's avatar
Zachary Prebosnyak committed
62
                    image_detail_request = session.get("/data/{}/{}/{}".format(vendor, product, image))
Ian Dunbar-Hall's avatar
Ian Dunbar-Hall committed
63
                    image_details = image_detail_request.json()
64 65 66
                except ReadTimeout:
                    print("Time Out Occurred, unable to pull image data for {}".format(image), flush=True, file=sys.stderr)
                    continue
Ian Dunbar-Hall's avatar
Ian Dunbar-Hall committed
67 68 69 70 71 72 73 74 75 76 77 78 79
                except ProxyError:
                    print("Proxy Error Occurred, unable to pull image data for {}".format(image), flush=True, file=sys.stderr)
                    continue

                print("- found " + vendor + "/" + product + "/" + image, flush=True)

                try:
                    for build in image_details['builds']:
                        image_full_name = "{}/{}/{}:{}".format(vendor, product, image, build['tag'])

                        # Found instances of empty builds in API
                        if build['name'] == '':
                            print("Found invalid build for {}".format(image_full_name), flush=True, file=sys.stderr)
Ian Dunbar-Hall's avatar
Ian Dunbar-Hall committed
80 81
                            continue

Ian Dunbar-Hall's avatar
Ian Dunbar-Hall committed
82 83
                        build_date_string = build['buildDateAndNumber'].split("_")[0]
                        build_date = parse(build_date_string).replace(tzinfo=timezone.utc)
Ian Dunbar-Hall's avatar
Ian Dunbar-Hall committed
84 85 86 87 88

                        # DCAR image pages have multiple jenkins runs for each tag. For example
                        # ubi:8.1 may have two jenkins runs listed, we want to use the latest.
                        # only keep the latest jenkins run for a image.
                        if image_full_name in image_details_dict.keys():
Ian Dunbar-Hall's avatar
Ian Dunbar-Hall committed
89 90 91 92 93 94 95 96 97 98 99
                            image_date = parse(image_details_dict[image_full_name]['lastUpdate']).replace(tzinfo=timezone.utc)

                            # Drop builds that are older and the current image tag is approved
                            if (image_date >= build_date
                               and image_details_dict[image_full_name]['approval'] == 'approved'):
                                continue

                            # Drop if the build are older and neither are approved
                            if (image_date >= build_date
                               and image_details_dict[image_full_name]['approval'] != 'approved'
                               and build['approvalStatus'] != 'approved'):
Ian Dunbar-Hall's avatar
Ian Dunbar-Hall committed
100 101 102
                                continue

                        image_details_dict[image_full_name] = {
Ian Dunbar-Hall's avatar
Ian Dunbar-Hall committed
103 104 105 106 107 108 109
                            "vendor" : vendor,
                            "product" : product,
                            "image" : image,
                            "tag" : build['tag'],
                            "lastUpdate": build_date_string,
                            "approval": build['approvalStatus'],
                            "imageTar" : build['tar'],
Zachary Prebosnyak's avatar
Zachary Prebosnyak committed
110 111 112 113
                            "reportsSignatureTar" : build['allFilesTar'],
                            "buildNumber" : build['buildNumber'],
                            "sha" : build['sha'],
                            "publicKey" : build['publicKey']
Ian Dunbar-Hall's avatar
Ian Dunbar-Hall committed
114
                        }
Ian Dunbar-Hall's avatar
Ian Dunbar-Hall committed
115 116 117 118
                except KeyError as e:
                    print("Error {} key not found, likely from malformed build".format(e), flush=True, file=sys.stderr)
                except Exception as unkown_exception:
                    print("Error unkown exception {}".format(unkown_exception), flush=True, file=sys.stderr)
Zachary Prebosnyak's avatar
Zachary Prebosnyak committed
119

Ian Dunbar-Hall's avatar
Ian Dunbar-Hall committed
120 121 122
    return image_details_dict


Zachary Prebosnyak's avatar
Zachary Prebosnyak committed
123
def compare_image(image_info, acceptable_image_delta_min: int = 60) -> Tuple[str, bool]:
Ian Dunbar-Hall's avatar
Ian Dunbar-Hall committed
124 125 126
    """
    This function compares DCAR image data to a container image on a registry.
    """
Zachary Prebosnyak's avatar
Zachary Prebosnyak committed
127

Ian Dunbar-Hall's avatar
Ian Dunbar-Hall committed
128 129
    validate_argument("acceptable_image_delta_min", acceptable_image_delta_min, int)

Zachary Prebosnyak's avatar
Zachary Prebosnyak committed
130 131 132 133 134 135 136 137 138 139
    image_name = image_info[0]
    image_details = image_info[1]
    registry_user = os.environ["REGISTRY_USER"]
    registry_password = os.environ["REGISTRY_PASSWORD"]
    registry_image_name = populate_image_name(template_string=os.environ["REGISTRY_IMAGE_TEMPLATE"],
                                                   vendor=image_details["vendor"],
                                                   product=image_details["product"],
                                                   image=image_details["image"],
                                                   tag=image_details["tag"])

Ian Dunbar-Hall's avatar
Ian Dunbar-Hall committed
140 141 142 143 144 145
    # We assume that skopeo is installed, and we can executing it
    # through a subprocess. We'll use it to "inspect" the container registry's image
    # and find the image metadata to compare to metadata scrapped from DCAR.
    command_string = "skopeo inspect --creds=\"{}\":\"{}\" docker://{}".format(registry_user,
                                                                               registry_password,
                                                                               registry_image_name)
Zachary Prebosnyak's avatar
Zachary Prebosnyak committed
146 147 148
    # The line commented out below works for python 3.5 and lower. Command two lines down works for python 3.6 and up
    # command_result = run(command_string, shell=True, check=False, capture_output=True)
    command_result = run(command_string, shell=True, check=False, stdout=PIPE, stderr=PIPE)
Ian Dunbar-Hall's avatar
Ian Dunbar-Hall committed
149 150 151 152 153 154 155 156 157 158 159
    if command_result.returncode == 0:
        # TODO switch to manifest digests when available. Currently the
        # only way to compare an image from DCAR and an internal registry is by
        # comparing when the DCAR jenkins build ran and the creation date of
        # the image.
        container_image_created = parse(json.loads(command_result.stdout)['Created']).replace(tzinfo=timezone.utc)
        dcar_update = parse(image_details["lastUpdate"]).replace(tzinfo=timezone.utc)

        image_delta_time = dcar_update - container_image_created

        if timedelta(minutes=acceptable_image_delta_min) > image_delta_time:
Zachary Prebosnyak's avatar
Zachary Prebosnyak committed
160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184
            return (image_name, True)
    return (image_name, False)


def process_image(image_data: tuple,
                 max_retries: int = 3) -> Tuple[str, bool]:
    mirror_result = mirror_image(image_data, max_retries)
    cleanup_image(image_data)
    return mirror_result


def cleanup_image(image_data: tuple):
    image_name = image_data[0]
    image_details = image_data[1]
    registry_image_name = populate_image_name(template_string=os.environ["REGISTRY_IMAGE_TEMPLATE"],
                                                vendor=image_details["vendor"],
                                                product=image_details["product"],
                                                image=image_details["image"],
                                                tag=image_details["tag"])
    # Try and remove the local image with Docker
    if "PUSH_W_DOCKER" in os.environ:
        command_string = "docker rmi {}".format(registry_image_name)
        command_result = run(command_string, check=False, shell=True)
    else:
        pass
Ian Dunbar-Hall's avatar
Ian Dunbar-Hall committed
185

Zachary Prebosnyak's avatar
Zachary Prebosnyak committed
186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205
    # Get file and directory paths to clean up
    path = os.getcwd()
    absolute_dir = "{}/temp/{}/{}".format(path, image_name.split(":")[0], image_details["tag"])
    absolute_file = "{}{}-{}-reports-signature.tar.gz".format(absolute_dir,
                                                              image_details["image"],
                                                              image_details["tag"])

    # Remove tar files
    try:
        os.remove(absolute_file)
        print(f"Removed tar file: {absolute_file}")
    except:
        print(f"Failed to remove tar file: {absolute_file}")

    # Remove temp dir
    try:
        shutil.rmtree(absolute_dir)
        print(f"Removed temp dir: {absolute_dir}")
    except:
        print(f"Failed to remove tmp directory: {absolute_dir}")
Ian Dunbar-Hall's avatar
Ian Dunbar-Hall committed
206

Zachary Prebosnyak's avatar
Zachary Prebosnyak committed
207 208 209

def mirror_image(image_data: tuple,
                 max_retries: int = 3) -> Tuple[str, bool]:
Ian Dunbar-Hall's avatar
Ian Dunbar-Hall committed
210 211 212
    """
    This function takes DCAR image data and will download push an image to a container registry.
    """
Zachary Prebosnyak's avatar
Zachary Prebosnyak committed
213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236
    # Create neccessary variables
    image_name = image_data[0]
    image_details = image_data[1]
    registry_image_name = populate_image_name(template_string=os.environ["REGISTRY_IMAGE_TEMPLATE"],
                                                vendor=image_details["vendor"],
                                                product=image_details["product"],
                                                image=image_details["image"],
                                                tag=image_details["tag"])

    # Create session wrapper object
    session = Session(os.environ["DCAR_USERNAME"],
                      os.environ["DCAR_PASSWORD"],
                      os.environ["DCAR_TOTP_SEED"])

    registry_user = os.environ["REGISTRY_USER"]
    registry_password = os.environ["REGISTRY_PASSWORD"]

    # See if pushing with docker is an option
    if "PUSH_W_DOCKER" in os.environ:
        push_w_docker = True
        print("Pushing with docker selected")
    else:
        push_w_docker = False

Ian Dunbar-Hall's avatar
Ian Dunbar-Hall committed
237 238 239 240 241 242 243 244 245 246 247 248 249
    validate_argument("session", session, Session)
    validate_argument("registry_image_name", registry_image_name, str)
    validate_argument("registry_user", registry_user, str)
    validate_argument("registry_password", registry_password, str)
    validate_argument("image_name", image_name, str)
    validate_argument("image_details", image_details, dict)
    validate_argument("push_w_docker", push_w_docker, bool)
    validate_argument("max_retries", max_retries, int)

    print("\n- processing " + image_name, flush=True)

    # Create the download directory if it doesn't exist
    path = os.getcwd()
Zachary Prebosnyak's avatar
Zachary Prebosnyak committed
250
    absolute_dir = "{}/temp/{}/{}".format(path, image_name.split(":")[0], image_details["tag"])
Ian Dunbar-Hall's avatar
Ian Dunbar-Hall committed
251 252 253
    absolute_file = "{}{}-{}-reports-signature.tar.gz".format(absolute_dir,
                                                              image_details["image"],
                                                              image_details["tag"])
Zachary Prebosnyak's avatar
Zachary Prebosnyak committed
254

Ian Dunbar-Hall's avatar
Ian Dunbar-Hall committed
255 256 257 258
    try:
        os.makedirs(os.path.dirname(absolute_dir), exist_ok=True)
    except OSError:
        print("Creation of the directory {} failed".format(absolute_dir), flush=True, file=sys.stderr)
Zachary Prebosnyak's avatar
Zachary Prebosnyak committed
259
        return (image_name, False)
Ian Dunbar-Hall's avatar
Ian Dunbar-Hall committed
260

261 262
    try:
        # Download the tar.gz file
Ian Dunbar-Hall's avatar
Ian Dunbar-Hall committed
263 264 265 266 267
        with session.get(dcar_url=image_details["reportsSignatureTar"]["file"]) as response:
            signed_url = response.json()
            with requests.get(signed_url["signedUrl"], stream=True, verify=False) as response:
                with open(absolute_file, 'wb') as tar_download:
                    shutil.copyfileobj(response.raw, tar_download)
268
        print("\tdowloaded report", flush=True)
269 270 271
    except ReadTimeout:
        print("Time Out Error Occurred", flush=True, file=sys.stderr)
        return (image_name, False)
272 273
    except ProxyError:
        print("Proxy Error Occurred", flush=True, file=sys.stderr)
Zachary Prebosnyak's avatar
Zachary Prebosnyak committed
274
        return (image_name, False)
275 276
    except FileNotFoundError:
        print("Error saving file {}".format(absolute_file), flush=True, file=sys.stderr)
Zachary Prebosnyak's avatar
Zachary Prebosnyak committed
277
        return (image_name, False)
278 279
    except Exception as unkown_exception:
        print("Error unkown exception {}".format(unkown_exception), flush=True, file=sys.stderr)
Zachary Prebosnyak's avatar
Zachary Prebosnyak committed
280
        return (image_name, False)
Ian Dunbar-Hall's avatar
Ian Dunbar-Hall committed
281 282 283 284 285

    # The `reportsSignature.tar.gz` should be a tar file. If its not a tar file
    # then something went wrong, and we didn't download the exported container image.
    if not tarfile.is_tarfile(absolute_file):
        print("ERROR: {} is not a tarfile".format(absolute_file), flush=True, file=sys.stderr)
Zachary Prebosnyak's avatar
Zachary Prebosnyak committed
286
        return (image_name, False)
Ian Dunbar-Hall's avatar
Ian Dunbar-Hall committed
287 288 289 290 291

    tar_file = tarfile.open(absolute_file)
    tar_file.extractall(path=absolute_dir)

    # Check if the image tar is there. If not download the tar file separately
292 293
    image_tar_dict = absolute_dir + "/" + os.path.commonprefix(tar_file.getnames())
    image_tar = image_tar_dict + "/" + image_details["image"] + "-" + image_details["tag"] + ".tar"
Ian Dunbar-Hall's avatar
Ian Dunbar-Hall committed
294

295 296 297
    if not os.path.exists(image_tar):
        image_tar = image_tar_dict + "/" + image_details["image"] + "-" + image_details["buildNumber"] + ".tar"

Ian Dunbar-Hall's avatar
Ian Dunbar-Hall committed
298
    if not os.path.exists(image_tar):
299 300 301
        try:
            # Download the tar.gz file
            print("\timage tar was not present", flush=True)
Ian Dunbar-Hall's avatar
Ian Dunbar-Hall committed
302 303 304 305 306 307
            with session.get(dcar_url=image_details["imageTar"]["file"], stream=True) as response:
                signed_url = response.json()
                with requests.get(signed_url["signedUrl"], stream=True, verify=False) as response:
                    with open(image_tar, 'wb') as tar_download:
                        shutil.copyfileobj(response.raw, tar_download)
            print("\tdowloaded tar separately", flush=True)
308 309 310
        except ReadTimeout:
            print("Time Out Error Occurred", flush=True, file=sys.stderr)
            return (image_name, False)
311 312
        except ProxyError:
            print("Proxy Error Occurred", flush=True, file=sys.stderr)
Zachary Prebosnyak's avatar
Zachary Prebosnyak committed
313
            return (image_name, False)
314 315
        except FileNotFoundError:
            print("Error saving file {}".format(image_tar), flush=True, file=sys.stderr)
Zachary Prebosnyak's avatar
Zachary Prebosnyak committed
316
            return (image_name, False)
317 318
        except Exception as unkown_exception:
            print("Error unkown exception {}".format(unkown_exception), flush=True, file=sys.stderr)
Zachary Prebosnyak's avatar
Zachary Prebosnyak committed
319
            return (image_name, False)
320 321 322

    if not os.path.exists(image_tar):
        print("ERROR: Unable to download image tar {}".format(image_tar), flush=True, file=sys.stderr)
Zachary Prebosnyak's avatar
Zachary Prebosnyak committed
323
        return (image_name, False)
Ian Dunbar-Hall's avatar
Ian Dunbar-Hall committed
324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342

    # Next we need to figure out what kind of image format we have ...
    container_type = None
    tar_file = tarfile.open(image_tar)

    try:
        tar_file.getmember("oci-layout")
        container_type = "oci-archive"
    except KeyError:
        pass

    try:
        tar_file.getmember("manifest.json")
        container_type = "docker-archive"
    except KeyError:
        pass

    if container_type is None:
        print("ERROR: Failed to determine image type", flush=True, file=sys.stderr)
Zachary Prebosnyak's avatar
Zachary Prebosnyak committed
343
        return (image_name, False)
Ian Dunbar-Hall's avatar
Ian Dunbar-Hall committed
344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367

    # If its an OCI archive, extract into a directory
    if container_type == "oci-archive":
        print("\timage tar is an oci-archive", flush=True)
        image_dir = absolute_dir + "/" + image_details["image"] + "-" + image_details["tag"]
        tar_file = tarfile.open(image_tar)
        tar_file.extractall(path=image_dir)

    # We're going to use skopeo to move the image. Lets start
    # building the process's shell command based on the archive type
    # and where it is going to be stored.
    if container_type == "oci-archive":
        archive_portion_command_string = "oci:{}".format(image_dir)
    elif container_type == "docker-archive":
        archive_portion_command_string = "docker-archive:{}".format(image_tar)

    # The prefered behavior is to use skopeo to push docker images to the container registry.
    # If the registry is not compatible with skopeo, use skopeo to load the image into internal
    # storage, and then use docker to push to the container registry.
    if push_w_docker:
        docker_portion_command_string = "docker-daemon:{}".format(registry_image_name)
    else:
        docker_portion_command_string = "docker://{}".format(registry_image_name)

Ian Dunbar-Hall's avatar
Ian Dunbar-Hall committed
368
    for attempt in range(max_retries):
Ian Dunbar-Hall's avatar
Ian Dunbar-Hall committed
369 370 371 372 373 374 375
        if container_type == "docker-archive":
            command_string = "skopeo copy --dest-creds=\"{}\":\"{}\" {} {}".format(registry_user, registry_password,
                                                                                   archive_portion_command_string,
                                                                                   docker_portion_command_string)
            command_result = run(command_string, check=False, shell=True)
            if command_result.returncode == 0:
                break
Ian Dunbar-Hall's avatar
Ian Dunbar-Hall committed
376
            elif attempt >= max_retries-1:
Zachary Prebosnyak's avatar
Zachary Prebosnyak committed
377
                return (image_name, False)
Ian Dunbar-Hall's avatar
Ian Dunbar-Hall committed
378 379 380 381 382 383 384 385

        # sleep 10 seconds between retries
        sleep(10)

    # The preferred behavior is to use skopeo to push docker images to the container registry.
    # If the registry is not compatible with skopeo, use skopeo to load the image into internal
    # storage, and then use docker to push to the container registry.
    if push_w_docker:
Ian Dunbar-Hall's avatar
Ian Dunbar-Hall committed
386
        for attempt in range(max_retries):
Ian Dunbar-Hall's avatar
Ian Dunbar-Hall committed
387 388 389 390
            command_string = "docker push {}".format(registry_image_name)
            command_result = run(command_string, check=False, shell=True)
            if command_result.returncode == 0:
                break
Ian Dunbar-Hall's avatar
Ian Dunbar-Hall committed
391
            elif attempt >= max_retries-1:
Zachary Prebosnyak's avatar
Zachary Prebosnyak committed
392 393 394
                return (image_name, False)
            # sleep 10 seconds between retries
            sleep(10)
Ian Dunbar-Hall's avatar
Ian Dunbar-Hall committed
395

Zachary Prebosnyak's avatar
Zachary Prebosnyak committed
396
    return (image_name, True)