diff --git a/utils/build_ds_container.py b/utils/build_ds_container.py new file mode 100755 index 000000000000..32e0e20be1d5 --- /dev/null +++ b/utils/build_ds_container.py @@ -0,0 +1,254 @@ +#!/usr/bin/env python3 + +import argparse +import logging as log +import os +import subprocess +import sys +import tempfile +import uuid + +from kubernetes import client, config +from kubernetes.client import exceptions + +config.load_kube_config() + + +DESCRIPTION = ''' +A tool for building compliance content for Kubernetes clusters. + +This tool supports building ComplianceAsCode content locally or remotely on an +existing kubernetes cluster. + +''' + + +parser = argparse.ArgumentParser( + formatter_class=argparse.ArgumentDefaultsHelpFormatter, + description=DESCRIPTION) +parser.add_argument( + '-n', '--namespace', + help='Build image in the given namespace.', + default='openshift-compliance') +parser.add_argument( + '-p', '--create-profile-bundles', + help='Create ProfileBundle objects for the image.', + action='store_true', + default=False) +parser.add_argument( + '-c', '--build-in-cluster', + help=( + 'Build content in-cluster. Note that this option ignores the ' + '--product and --debug flags.'), + action='store_true', + default=False) +parser.add_argument( + '-d', '--debug', + help=( + 'Provide debug output during the build process. This option is ' + 'ignored when building content in the cluster using ' + '--build-in-cluster.'), + action='store_true', + default=False) +parser.add_argument( + '-P', '--product', + help=( + 'The product(s) to build. This option can be provided multiple times. ' + 'This option is ignored when building content in the cluster using ' + '--build-in-cluster.'), + default=['ocp4', 'rhcos4'], + dest='products', + nargs='*') +parser.add_argument( + '-r', '--repository', + help=( + 'The container image repository to use for images containing built ' + 'content. Images pushed to this repository must have a tag, which ' + 'you can specify with the --container-image-tag argument. If you do ' + 'not supply a tag, one will be generated for you. It is recommended ' + 'that you properly tag built images for production use. ' + 'Auto-generated tags are primarily useful for development workflows. ' + 'This script assumes you have authenticated to the image repository ' + 'if necessary (e.g, `podman login`).')) +parser.add_argument( + '-t', '--container-image-tag', + help='A unique tag for the container image.') +args = parser.parse_args() + + +log.basicConfig( + format='%(asctime)s:%(levelname)s: %(message)s', level=log.INFO) + + +# FIXME(lbragstad): Remove this and replace it with operating system utils if +# possible. +result = subprocess.run( + ['git', 'rev-parse', '--show-toplevel'], capture_output=True, check=True) +# Convert the file path from bytes to unicode since we might manipulate it +# later. Also, strip off any newlines. +REPO_PATH = result.stdout.decode().strip() + + +def ensure_namespace_exists(namespace): + """Function that ensures there is a namespace for the content.""" + with client.ApiClient() as api_client: + body = client.V1Namespace( + api_version='v1', kind='Namespace', metadata={'name': namespace}) + + try: + api = client.CoreV1Api(api_client) + api.create_namespace(body) + log.info('Created namespace %s', namespace) + except exceptions.ApiException as err: + # HTTP 409 - Conflict tells the namespace already exists. Let's + # handle this gracefully and move on, instead of failing. Any other + # error codes should be bubbled up to the end user. + if err.status == 409: + log.info('Reusing existing namespace: %s', namespace) + else: + raise err + + +def supports_custom_resource(resource_name): + with client.ApiClient() as api_client: + api = client.ApiextensionsV1Api(api_client) + try: + api.read_custom_resource_definition(resource_name) + return True + except exceptions.ApiException: + log.warning( + 'Unable to determine if %s custom resources are supported in ' + 'this deployment', resource_name) + return False + + +def create_image_stream(): + with client.ApiClient() as api_client: + body = client.V1CustomResourceDefinition( + api_version='image.openshift.io/v1', + kind='ImageStream', + metadata={'name': 'openscap-ocp4-ds'}, + spec={'lookupPolicy': {'local': True}}) + + api = client.ApiextensionsV1Api(api_client) + api.create_custom_resource_definition(body) + + +def create_build_config(build_config): + # FIXME(lbragstad) This needs to be implemented against an OCP deployment. + pass + + +def build_content_locally(products, debug=False): + build_binary_path = os.path.join(REPO_PATH, 'build_product') + command = [build_binary_path] + products + capture_output = True + if debug: + command.append('--debug') + capture_output = False + subprocess.run(command, check=True, capture_output=capture_output) + log.info(f"Successfully built content for {', '.join(args.products)}") + + +def build_content_remotely(products): + pass + + +# FIXME(lbragstad): This should use something like podman-py. +def build_container(debug=False): + dockerfile = ( + REPO_PATH + '/Dockerfiles/compliance_operator_content.Dockerfile') + command = [ + 'podman', 'build', '-f', dockerfile, '-t', 'localcontentbuild:latest', + '.'] + capture_output = True + if debug: + capture_output = False + subprocess.run(command, check=True, capture_output=capture_output) + log.info('Successfully built container image') + + +# FIXME(lbragstad): This should use something like podman-py. +def push_container(repository, tag, debug=False): + repository_string = repository + ':' + tag + command = [ + 'podman', 'push', 'localhost/localcontentbuild:latest', + repository_string] + capture_output = True + if debug: + capture_output = False + result = subprocess.run(command, capture_output=capture_output) + if result.returncode == 0: + log.info(f'Pushed image to {repository}:{tag}') + elif result.returncode == 125: + log.error( + 'Failed to push container image due to authentication issues. ' + 'Make sure you have authenticated to the registry before ' + 'running this script.') + sys.exit(2) + else: + log.error('Failed to push container image') + sys.exit(2) + + +def create_profile_bundles(products, namespace): + for product in products: + metadata = {'name': 'upstream-' + product} + spec = { + 'contentImage': 'openscap-ocp4-ds:latest', + 'contentFile': 'ssg-' + product + '-ds.xml'} + + with client.ApiClient() as api_client: + body = { + 'apiVersion': 'compliance.openshift.io/v1alpha1', + 'kind': 'ProfileBundle', + 'metadata': metadata, + 'spec': spec} + api = client.CustomObjectsApi(api_client) + try: + api.create_namespaced_custom_object( + 'compliance.openshift.io', 'v1alpha1', namespace, + 'profilebundles', body) + log.info(f'Created profile bundle(s) for {product}') + except exceptions.ApiException as err: + if err.status == 409: + log.info(f'Reusing existing profile bundle for {product}') + continue + raise err + + +log.info(f'Building content for {", ".join(args.products)}') + +working_directory = os.getcwd() +if args.build_in_cluster: + output_directory = REPO_PATH +else: + output_directory = tempfile.mkdtemp() + +ensure_namespace_exists(args.namespace) + +if not supports_custom_resource('ImageStream') and args.build_in_cluster: + log.error( + 'ImageStream custom resources are not supported in this deployment, ' + 'unable to build content remotely') + sys.exit(1) + + +if args.build_in_cluster: + create_image_stream() + build_config = None + create_build_config(build_config) + build_content_remotely(args.products) +else: + build_content_locally(args.products, debug=args.debug) + +if args.repository: + build_container(debug=args.debug) + if args.container_image_tag: + tag = args.container_image_tag + else: + tag = uuid.uuid4().hex[:16] + push_container(args.repository, tag, debug=args.debug) + +if args.create_profile_bundles: + create_profile_bundles(args.products, args.namespace)