From b460191c5d2672746c6d6afd1bb5ba9a12a70b9d Mon Sep 17 00:00:00 2001 From: Kenny Lee Sin Cheong Date: Fri, 24 Apr 2020 14:39:09 -0400 Subject: [PATCH] Template upstream and downstream CSV manifests --- Makefile | 34 +++ scripts/Dockerfile | 6 + scripts/generate_csv.py | 216 ++++++++++++++++++ scripts/img/downstream_logo.png | Bin 0 -> 3558 bytes scripts/img/upstream_logo.png | Bin 0 -> 7102 bytes ...ty-operator.clusterserviceversion.yaml.jnj | 137 +++++++++++ ...vulns.secscan.quay.redhat.com.crd.yaml.jnj | 121 ++++++++++ 7 files changed, 514 insertions(+) create mode 100644 scripts/Dockerfile create mode 100644 scripts/generate_csv.py create mode 100644 scripts/img/downstream_logo.png create mode 100644 scripts/img/upstream_logo.png create mode 100644 scripts/templates/container-security-operator.clusterserviceversion.yaml.jnj create mode 100644 scripts/templates/imagemanifestvulns.secscan.quay.redhat.com.crd.yaml.jnj diff --git a/Makefile b/Makefile index f965336..52ffa4f 100644 --- a/Makefile +++ b/Makefile @@ -91,3 +91,37 @@ CODEGEN_IMAGE = container-security-operator:codegen .PHONY: BUILD_CODEGEN_IMAGE BUILD_CODEGEN_IMAGE: docker build -f Dockerfile.codegen -t $(CODEGEN_IMAGE) . + + +# ======================= +# CSV Manifest generation +# ======================= +MANIFESTGEN_IMAGE = container-security-operator:manifestgen + +MANIFESTGEN_WORKDIR ?= scripts +MANIFESTGEN_OUTPUT_DIR ?= deploy +MANIFESTGEN_VERSION ?= master +MANIFESTGEN_OPT_FLAGS ?= --upstream --skip-pull --yaml + +OPERATOR_IMAGE ?= quay.io/quay/container-security-operator +OPERATOR_IMAGE_REF ?= $(shell \ + docker pull $(OPERATOR_IMAGE):$(MANIFESTGEN_VERSION) > /dev/null && \ + docker inspect $(OPERATOR_IMAGE):$(MANIFESTGEN_VERSION) | jq '.[0].RepoDigests[] | select(. | startswith("$(OPERATOR_IMAGE)"))' \ +) + +.PHONY: BUILD_MANIFESTGEN_IMAGE +BUILD_MANIFEST_GEN_IMAGE: + docker build -t $(MANIFESTGEN_IMAGE) scripts + +.PHONY: manifestgen-container +manifestgen-container: BUILD_MANIFEST_GEN_IMAGE + docker run --rm --name manifestgen \ + -v $(PWD)/$(MANIFESTGEN_WORKDIR):/workspace/$(MANIFESTGEN_WORKDIR) \ + -v $(PWD)/$(MANIFESTGEN_OUTPUT_DIR):/workspace/$(MANIFESTGEN_OUTPUT_DIR) \ + $(MANIFESTGEN_IMAGE) \ + python $(MANIFESTGEN_WORKDIR)/generate_csv.py $(MANIFESTGEN_VERSION) $(MANIFESTGEN_PREVIOUS_VERSION) \ + --workdir $(MANIFESTGEN_WORKDIR) --output-dir $(MANIFESTGEN_OUTPUT_DIR) \ + --image $(OPERATOR_IMAGE_REF) $(MANIFESTGEN_OPT_FLAGS) + +# Example: +# $ OPERATOR_IMAGE_REF=quay.io/quay/container-security-operator:v1.0.0 MANIFESTGEN_OUTPUT_DIR=testingscript MANIFESTGEN_VERSION=v3.3.0 make manifestgen-container diff --git a/scripts/Dockerfile b/scripts/Dockerfile new file mode 100644 index 0000000..511624d --- /dev/null +++ b/scripts/Dockerfile @@ -0,0 +1,6 @@ +FROM python:3 + +WORKDIR /workspace +RUN pip install jinja2 pyyaml + +CMD ["python", "--version"] diff --git a/scripts/generate_csv.py b/scripts/generate_csv.py new file mode 100644 index 0000000..bc8506c --- /dev/null +++ b/scripts/generate_csv.py @@ -0,0 +1,216 @@ +import argparse +import base64 +import logging +import json +import os +import re +import subprocess +import sys +import yaml + +from datetime import datetime +from urllib.parse import urljoin + +from jinja2 import FileSystemLoader, Environment, StrictUndefined + + +logger = logging.getLogger(__name__) + +LOGO_DOWNSTREAM_FILE = "img/downstream_logo.png" +LOGO_UPSTREAM_FILE = "img/upstream_logo.png" + +PACKAGE_NAME = "container-security-operator" + +# Default location for the image +REGISTRY_HOST = "quay.io" +REGISTRY_API_BASE = REGISTRY_HOST + "/api/v1/" + +CSO_REPO = "projectquay/" + PACKAGE_NAME +CSO_IMAGE = REGISTRY_HOST + "/" + CSO_REPO +CSO_IMAGE_TAG = "master" + +CSO_CATALOG_REPO = "projectquay/cso-catalog" +CSO_CATALOG_IMAGE = REGISTRY_HOST + "/" + CSO_CATALOG_REPO +CSO_CATALOG_IMAGE_TAG = "master" + +# Default template values +K8S_API_VERSION = "v1alpha1" + +# Jinja templates +TEMPLATE_DIR = "templates" +CSV_TEMPLATE_FILE = PACKAGE_NAME + ".clusterserviceversion.yaml.jnj" +CRD_TEMPLATE_FILES = [ + "imagemanifestvulns.secscan.quay.redhat.com.crd.yaml.jnj" +] + +# Output +OUTPUT_MANIFEST_DIR = os.path.join("manifests", PACKAGE_NAME) +OUTPUT_CATALOG_FILE = "cso.catalogsource.yaml" + +MANIFEST_DIGEST_REGEX = re.compile(r"sha256:[a-z0-9]{64}") +ARGUMENT_REGEX = re.compile(r"(-[\w])|(--[\w]+)") +VERSION_REGEX = re.compile(r"^v(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(-(0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(\.(0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*)?(\+[0-9a-zA-Z-]+(\.[0-9a-zA-Z-]+)*)?$") +MASTER_VERSION_REGEX = re.compile(r"^master$") + + +def normalize_version(version): + if VERSION_REGEX.match(version): + return version[1:] + return version + + +def get_current_datetime(): + return datetime.now().strftime("%Y-%m-%d %H:%M:%S") + + +def get_image_manifest_digest(image_ref, cmd="docker"): + """ Return the repo and manifest digest of a given image reference. + e.g quay.io/namespace/repo:tag -> (quay.io/namespace/repo, sha256:123456) + """ + if len(image_ref.split("@")) == 2: + # Still pull for a digest ref, to make sure the image exists + repo, digest = image_ref.split("@") + pull_command = [cmd, "pull", repo+"@"+tag] + inspect_command = [cmd, "inspect", repo+"@"+tag] + else: + repo, tag = image_ref.split(":") + pull_command = [cmd, "pull", repo+":"+tag] + inspect_command = [cmd, "inspect", repo+":"+tag] + + try: + subprocess.run(pull_command, check=True) + out = subprocess.run(inspect_command, check=True, capture_output=True) + parsed = json.loads(out.stdout) + repo_digests = parsed[0]["RepoDigests"] + except subprocess.CalledProcessError as cpe: + logger.error("Error running docker commands for image %s:%s - %s", repo, tag, cpe) + return None, None + except ValueError as ve: + logger.error("Error parsing docker inspect output output - %s", ve) + return None, None + except Exception as e: + logger.error("Error getting the manifest digest for image %s:%s - %s", repo, tag, e) + return None, None + + repo_digests = list(filter(lambda repo_digest: repo_digest.startswith(repo),repo_digests)) + if len(repo_digests) == 0: + logger.error("Could not find the manifest digest for the given image %s:%s", repo, tag) + return None, None + + manifest_digest = repo_digests[0].split("@")[-1] + if not MANIFEST_DIGEST_REGEX.match(manifest_digest): + logger.error("Unknown manifest digest format for %s:%s -> %s", repo_digest, manifest_digest) + return None, None + + return repo, manifest_digest + + +def get_b64_logo_from_file(filepath): + with open(filepath, 'rb') as f: + data = f.read() + + return base64.b64encode(data).decode("ascii") + + +def parse_args(): + def version_arg_type(arg_value, pat=re.compile(VERSION_REGEX)): + if not pat.match(arg_value): + if MASTER_VERSION_REGEX.match(arg_value): + return arg_value + + if not pat.match("v"+arg_value): + raise argparse.ArgumentTypeError + + return "v"+arg_value + return arg_value + + desc = 'Generate CSVs for tagged versions.' + parser = argparse.ArgumentParser(description=desc) + parser.add_argument('version', help='Version to generate (SemVer). e.g v1.2.3', type=version_arg_type) + parser.add_argument('previous_version', help='Previous version.', type=version_arg_type, nargs='?') + parser.add_argument('--json', dest='yaml', help='Output json config (default).', action='store_false') + parser.add_argument('--yaml', dest='yaml', help='Output yaml config.', action='store_true') + parser.add_argument('--upstream', dest='downstream', help='Generate with upstream config.', action='store_false') + parser.add_argument('--downstream', dest='downstream', help='Generate with downstream config.', action='store_true') + parser.add_argument('--image', dest='image', help='Image to use in CSV.') + parser.add_argument('--workdir', dest='workdir', help='Work directory', default=".") + parser.add_argument('--output-dir', dest='output_dir', help='Output directory relative to the workdir', default="deploy") + parser.add_argument('--skip-pull', dest='skip_pull', help='Skip pulling the image for verification', action='store_true') + parser.set_defaults(yaml=True) + parser.set_defaults(downstream=False) + parser.set_defaults(previous_version=None) + parser.set_defaults(skip_pull=False) + + logger.debug('Parsing all args') + _, unknown = parser.parse_known_args() + + added_args_keys = set() + while (len(unknown) > 0 and ARGUMENT_REGEX.match(unknown[0]) and + ARGUMENT_REGEX.match(unknown[0]).end() == len(unknown[0])): + logger.info('Adding argument: %s', unknown[0]) + added_args_keys.add(unknown[0].lstrip('-')) + parser.add_argument(unknown[0]) + _, unknown = parser.parse_known_args() + + logger.debug('Parsing final set of args') + return parser.parse_args(), added_args_keys + + +def main(): + all_args, added_args_keys = parse_args() + template_kwargs = {key: getattr(all_args, key, None) for key in added_args_keys} + + ENV = Environment(loader=FileSystemLoader(os.path.join(all_args.workdir, TEMPLATE_DIR)), undefined=StrictUndefined) + ENV.filters['normalize_version'] = normalize_version + ENV.globals['get_current_datetime'] = get_current_datetime + + logo = (get_b64_logo_from_file(os.path.join(all_args.workdir, LOGO_DOWNSTREAM_FILE)) + if all_args.downstream else get_b64_logo_from_file(os.path.join(all_args.workdir,LOGO_UPSTREAM_FILE)) + ) + image_ref = all_args.image or CSO_IMAGE + ":" + CSO_IMAGE_TAG + + if not all_args.skip_pull: + repo, image_manifest_digest = get_image_manifest_digest(image_ref) + if not repo or not image_manifest_digest: + sys.exit(1) + + container_image = repo + "@" + image_manifest_digest + else: + container_image = image_ref + + template_kwargs["version"] = all_args.version + template_kwargs["previous_version"] = all_args.previous_version + template_kwargs["logo"] = logo + template_kwargs["container_image"] = container_image + template_kwargs["k8s_api_version"] = template_kwargs.setdefault("k8s_api_version", K8S_API_VERSION) + + manifest_output_dir = os.path.join(all_args.output_dir, OUTPUT_MANIFEST_DIR, normalize_version(all_args.version)) + os.makedirs(manifest_output_dir, exist_ok=True) + generated_files = {} + + assert CSV_TEMPLATE_FILE.endswith(".clusterserviceversion.yaml.jnj") + csv_template = ENV.get_template(CSV_TEMPLATE_FILE) + generated_csv = csv_template.render(**template_kwargs) + csv_filename = CSV_TEMPLATE_FILE.split(".") + csv_filename.insert(1, all_args.version) + csv_filename = ".".join(csv_filename[:-1]) + generated_files[os.path.join(manifest_output_dir, csv_filename)] = generated_csv + + for crd_template_file in filter(lambda filename: filename.endswith(".crd.yaml.jnj"), CRD_TEMPLATE_FILES): + crd_template = ENV.get_template(crd_template_file) + generated_crd = crd_template.render(**template_kwargs) + generated_files[os.path.join(manifest_output_dir, crd_template_file.rstrip(".jnj"))] = generated_crd + + if all_args.yaml: + for filepath, content in generated_files.items(): + with open(filepath, 'w') as f: + f.write(content) + else: + for filepath, content in generated_files.items(): + parsed = yaml.load(content, Loader=yaml.SafeLoader) + with open(filepath.rstrip("yaml")+"json", 'w') as f: + f.write(json.dumps(parsed, default=str)) + + +if __name__ == "__main__": + main() diff --git a/scripts/img/downstream_logo.png b/scripts/img/downstream_logo.png new file mode 100644 index 0000000000000000000000000000000000000000..a1ae74d8b455ef7b29fbe9ee5b1dd34050b5516f GIT binary patch literal 3558 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 literal 0 HcmV?d00001 diff --git a/scripts/img/upstream_logo.png b/scripts/img/upstream_logo.png new file mode 100644 index 0000000000000000000000000000000000000000..fc03ab882a326f281925f4efd6d3465515d36d63 GIT binary patch literal 7102 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