diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index b48c9521..5f477fdd 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -20,6 +20,45 @@ test_dev: extends: .acc_py_dev_test +# A development installation of CARA tested with pytest. +.test_openshift_config: + rules: + - if: '$OC_TOKEN && $CI_MERGE_REQUEST_SOURCE_BRANCH_NAME == $BRANCH' + allow_failure: false # The branch must represent what is deployed. + - if: '$OC_TOKEN && $CI_MERGE_REQUEST_EVENT_TYPE != "detached"' + allow_failure: true # Anything other than the branch may fail without blocking the pipeline. + image: registry.cern.ch/docker.io/mambaorg/micromamba + before_script: + - micromamba create --yes -p $HOME/env python=3.9 ruamel.yaml wget -c conda-forge + - export PATH=$HOME/env/bin/:$PATH + - wget https://github.com/openshift/origin/releases/download/v3.11.0/openshift-origin-client-tools-v3.11.0-0cbc58b-linux-64bit.tar.gz + - tar xzf ./openshift-origin-client-tools-v3.11.0-0cbc58b-linux-64bit.tar.gz + - mv openshift-origin-client-tools-v3.11.0-0cbc58b-linux-64bit/oc $HOME/env/bin/ + + script: + - cd ./app-config/openshift + - oc login ${OC_SERVER} --token="${OC_TOKEN}" + - python ./config-fetch.py ${CARA_INSTANCE} --output-directory ./${CARA_INSTANCE}/actual + - python ./config-generate.py ${CARA_INSTANCE} --output-directory ./${CARA_INSTANCE}/expected + - python ./config-normalise.py ./${CARA_INSTANCE}/actual ./${CARA_INSTANCE}/actual-normed + - python ./config-normalise.py ./${CARA_INSTANCE}/expected ./${CARA_INSTANCE}/expected-normed + - diff -u ./test-cara/actual-normed/ ./${CARA_INSTANCE}/expected-normed/ + + artifacts: + paths: + - ./app-config/openshift/${CARA_INSTANCE}/actual + - ./app-config/openshift/${CARA_INSTANCE}/expected + + +check_openshift_config_test-cara: + extends: .test_openshift_config + variables: + CARA_INSTANCE: 'test-cara' + BRANCH: 'live/test-cara' + OC_SERVER: openshift-dev.cern.ch + OC_TOKEN: "${OPENSHIFT_CONFIG_CHECKER_TOKEN_TEST_CARA}" + + # A development installation of CARA tested with pytest. test_dev-39: variables: @@ -86,3 +125,4 @@ oci_calculator: script: - echo "{\"auths\":{\"$CI_REGISTRY\":{\"username\":\"$CI_REGISTRY_USER\",\"password\":\"$CI_REGISTRY_PASSWORD\"}}}" > /kaniko/.docker/config.json - /kaniko/executor --context $CI_PROJECT_DIR --dockerfile $CI_PROJECT_DIR/Dockerfile --destination $CI_REGISTRY_IMAGE/calculator:latest + diff --git a/README.md b/README.md index 692c3ae6..ebf832bb 100644 --- a/README.md +++ b/README.md @@ -194,10 +194,12 @@ If you need to create the application in a new project, run: ```console $ cd app-config/openshift -$ oc process -f application.yaml --param PROJECT_NAME='test-cara' --param GIT_BRANCH='live/test-cara' | oc create -f - +$ oc process -f routes.yaml --param HOST='test-cara.web.cern.ch' | oc create -f - $ oc process -f configmap.yaml | oc create -f - $ oc process -f services.yaml | oc create -f - -$ oc process -f route.yaml --param HOST='test-cara.web.cern.ch' | oc create -f - +$ oc process -f imagestreams.yaml | oc create -f - +$ oc process -f buildconfig.yaml --param GIT_BRANCH='live/test-cara' | oc create -f - +$ oc process -f deploymentconfig.yaml --param PROJECT_NAME='test-cara' | oc create -f - ``` Then, create the webhook secret to be able to trigger automatic builds from GitLab. @@ -211,6 +213,17 @@ $ oc create secret generic \ gitlab-cara-webhook-secret ``` +For CI usage, we also suggest creating a service account: + +```console +oc create sa gitlab-config-checker +``` + +Under ``Resources`` -> ``Membership`` enable the ``View`` role for this new service account. + +To get this new user's authentication token go to ``Resources`` -> ``Secrets`` and locate the token in the newly +created secret associated with the user (in this case ``gitlab-config-checker-token-XXXX``). + ### CERN SSO integration The SSO integration uses OpenID credentials configured in [CERN Applications portal](https://application-portal.web.cern.ch/). @@ -261,7 +274,7 @@ $ cd app-config/openshift $ oc process -f configmap.yaml | oc replace -f - $ oc process -f services.yaml | oc replace -f - -$ oc process -f route.yaml --param HOST='test-cara.web.cern.ch' | oc replace -f - +$ oc process -f routes.yaml --param HOST='test-cara.web.cern.ch' | oc replace -f - $ oc process -f imagestreams.yaml | oc replace -f - $ oc process -f buildconfig.yaml --param GIT_BRANCH='live/test-cara' | oc replace -f - $ oc process -f deploymentconfig.yaml --param PROJECT_NAME='test-cara' | oc replace -f - @@ -269,4 +282,3 @@ $ oc process -f deploymentconfig.yaml --param PROJECT_NAME='test-cara' | oc repl Be aware that if you change/replace the **route** of the PROD instance, it will lose the annotation to be exposed outside CERN (not committed in this repo). - diff --git a/app-config/openshift/config-fetch.py b/app-config/openshift/config-fetch.py new file mode 100644 index 00000000..55a27230 --- /dev/null +++ b/app-config/openshift/config-fetch.py @@ -0,0 +1,72 @@ +import argparse +import pathlib +import subprocess +import sys +import typing + + +def configure_parser(parser: argparse.ArgumentParser) -> None: + parser.description = "Fetch the openshift config for CARA" + parser.set_defaults(handler=handler) + parser.add_argument( + "instance", choices=['cara', 'test-cara'], + help="Pick the instance for which you want to fetch the config", + ) + parser.add_argument( + "-o", "--output-directory", default='config', + help="Location to put the config files", + ) + + +def get_oc_server() -> typing.Optional[str]: + # Return the openshift server that is currently logged in, or None if not logged in + # (or other issues getting the information from the oc client). + try: + subprocess.check_output(['oc', 'whoami'], stderr=subprocess.PIPE) + except subprocess.CalledProcessError: + # User not logged on, or oc command missing. + return None + + return subprocess.run([ + 'oc', 'whoami', '--show-server' + ], check=True, stdout=subprocess.PIPE).stdout.decode().strip() + + +def fetch_config(output_directory: pathlib.Path): + output_directory.mkdir(exist_ok=True, parents=True) + + for component in ['routes', 'configmap', 'services', 'imagestreams', 'buildconfig', 'deploymentconfig']: + with (output_directory / f'{component}.yaml').open('wt') as fh: + cmd = ['oc', 'get', '--export', '-o', 'yaml', component] + print(f'Running: {" ".join(cmd)}') + subprocess.run(cmd, stdout=fh, check=True) + print(f'Config in: {output_directory.absolute()}') + + +def handler(args: argparse.ArgumentParser) -> None: + if args.instance == 'cara': + login_server = 'https://openshift.cern.ch:443' + project_name = 'cara' + elif args.instance == 'test-cara': + login_server = 'https://openshift-dev.cern.ch:443' + project_name = 'test-cara' + + actual_login_server = get_oc_server() + if actual_login_server != login_server: + print(f'\nPlease login to the correct openshift server with: \n\n oc login {login_server}\n', file=sys.stderr) + sys.exit(1) + + subprocess.run(['oc', 'project', project_name], stdout=subprocess.DEVNULL, check=True) + + fetch_config(pathlib.Path(args.output_directory)) + + +def main(): + parser = argparse.ArgumentParser() + configure_parser(parser) + args = parser.parse_args() + args.handler(args) + + +if __name__ == '__main__': + main() diff --git a/app-config/openshift/config-generate.py b/app-config/openshift/config-generate.py new file mode 100644 index 00000000..c17c411a --- /dev/null +++ b/app-config/openshift/config-generate.py @@ -0,0 +1,63 @@ +import argparse +import pathlib +import subprocess +import typing + + +def configure_parser(parser: argparse.ArgumentParser) -> None: + parser.description = "Generate the config files which can be later submitted to openshift" + parser.set_defaults(handler=handler) + parser.add_argument( + "instance", choices=['cara', 'test-cara'], + help="Pick the instance for which you want to generate the config", + ) + parser.add_argument( + "-o", "--output-directory", default='config', + help="Location to put the config files", + ) + + +def generate_config(output_directory: pathlib.Path, project_name: str, hostname: str, branch: str): + output_directory.mkdir(exist_ok=True, parents=True) + + def oc_process(component_name: str, context: typing.Optional[dict] = None): + cmd = ['oc', 'process', '--local', '-f', f'{component_name}.yaml', '-o', 'yaml'] + for ctx_name, ctx_value in (context or {}).items(): + cmd.extend(['--param', f'{ctx_name}={ctx_value}']) + with (output_directory / f'{component_name}.yaml').open('wt') as fh: + print(f'Running: {" ".join(cmd)}') + subprocess.run(cmd, stdout=fh, check=True) + + oc_process('routes', context={'HOST': hostname}) + oc_process('configmap') + oc_process('services') + oc_process('imagestreams') + oc_process('buildconfig', context={'GIT_BRANCH': branch}) + oc_process('deploymentconfig', context={'PROJECT_NAME': project_name}) + + print(f'Config in: {output_directory.absolute()}') + + +def handler(args: argparse.ArgumentParser) -> None: + if args.instance == 'cara': + project_name = 'cara' + branch = 'master' + hostname = 'cara.web.cern.ch' + elif args.instance == 'test-cara': + branch = 'live/test-cara' + project_name = 'test-cara' + hostname = 'test-cara.web.cern.ch' + + generate_config(pathlib.Path(args.output_directory), project_name, hostname, branch) + + +def main(): + parser = argparse.ArgumentParser() + configure_parser(parser) + args = parser.parse_args() + args.handler(args) + + +if __name__ == '__main__': + main() + diff --git a/app-config/openshift/config-normalise.py b/app-config/openshift/config-normalise.py new file mode 100644 index 00000000..4672a128 --- /dev/null +++ b/app-config/openshift/config-normalise.py @@ -0,0 +1,117 @@ +import argparse +import pathlib + +import ruamel.yaml + + +def configure_parser(parser: argparse.ArgumentParser) -> None: + parser.description = "Normalise openshift config files (by sorting and removing ephemeral values)" + parser.set_defaults(handler=handler) + parser.add_argument( + "config-directory", + help="The directory from which to find yaml files", + ) + parser.add_argument( + "output-directory", + help="The directory to put normalized files (can be the same as config-directory)", + ) + + +def clean_ephemeral_config(config: dict): + config = config.copy() + config.get('metadata', []).clear() + + METADATA_TO_PRESERVE = ['labels', 'name'] + + for item in config['items']: + item.pop('status', None) + + for key in list(item['metadata'].keys()): + if key not in METADATA_TO_PRESERVE: + del item['metadata'][key] + + item.get('spec', {}).pop('clusterIP', None) + + if item['kind'] == 'BuildConfig': + for trigger in item.get('spec', {}).get('triggers', []): + trigger.get('imageChange', {}).pop('lastTriggeredImageID', None) + + if item['kind'] == 'DeploymentConfig': + item['spec'].get('template', {}).get('metadata', {}).pop('creationTimestamp', None) + + for container in item['spec'].get('template', {}).get('spec', {}).get('containers', []): + # Drop the specific image name (and hash). + container.pop('image', None) + item['spec'].get('template', {}).get('metadata', {}).pop('creationTimestamp', None) + for trigger in item['spec'].get('triggers', []): + trigger.get('imageChangeParams', {}).pop('lastTriggeredImage', None) + + # Drop the template part of the config for now. + # TODO: Remove this constraint to ensure our deployments reflect the fact that they are templated. + r = item['metadata'].get('labels', {}).pop('template', None) + + if r is not None and not item['metadata']['labels']: + # Remove the empty labels dict if there is nothing left after popping the template item. + item['metadata'].pop('labels') + + return config + + +def deep_sort(item): + if isinstance(item, dict): + # Sort by the key. + return {k: deep_sort(v) for k, v in sorted(item.items(), key=lambda i: i[0])} + elif isinstance(item, list): + # Use the metadata/name and fallback to the str representation to give a sort order. + def sort_key(value): + if isinstance(value, dict): + return value.get('metadata', {}).get('name', '') or str(value) + else: + return str(value) + + return sorted( + [deep_sort(v) for v in item], + key=sort_key, + ) + else: + return item + + +def normalise_config(input_directory: pathlib.Path, output_directory: pathlib.Path): + output_directory.mkdir(exist_ok=True, parents=True) + + files = sorted(input_directory.glob('*.yaml')) + + yaml = ruamel.yaml.YAML(typ='safe') + + for file in files: + with file.open('rt') as fh: + content = yaml.load(fh) + + config = clean_ephemeral_config(content) + config = deep_sort(config) + + destination = output_directory / file.name + with destination.open('wt') as fh: + yaml.dump(config, fh) + print(f'Normalised {file.name} in {destination}') + print(f'Config in: {output_directory.absolute()}') + + +def handler(args: argparse.ArgumentParser) -> None: + + normalise_config( + pathlib.Path(getattr(args, 'config-directory')), + pathlib.Path(getattr(args, 'output-directory')), + ) + + +def main(): + parser = argparse.ArgumentParser() + configure_parser(parser) + args = parser.parse_args() + args.handler(args) + + +if __name__ == '__main__': + main() diff --git a/app-config/openshift/deploymentconfig.yaml b/app-config/openshift/deploymentconfig.yaml index 79b3611d..a473f6a6 100644 --- a/app-config/openshift/deploymentconfig.yaml +++ b/app-config/openshift/deploymentconfig.yaml @@ -207,7 +207,23 @@ - containerPort: 8080 protocol: TCP imagePullPolicy: Always - resources: {} + readinessProbe: + failureThreshold: 3 + httpGet: + path: /calculator-cern + port: 8080 + scheme: HTTP + initialDelaySeconds: 3 + periodSeconds: 10 + successThreshold: 1 + timeoutSeconds: 1 + resources: + limits: + cpu: '3' + memory: 3Gi + requests: + cpu: '1' + memory: 1Gi terminationMessagePath: /dev/termination-log terminationMessagePolicy: File dnsPolicy: ClusterFirst @@ -229,7 +245,6 @@ selector: app: cara-webservice triggers: - - type: ConfigChange - type: ImageChange imageChangeParams: automatic: true diff --git a/app-config/openshift/route.yaml b/app-config/openshift/routes.yaml similarity index 100% rename from app-config/openshift/route.yaml rename to app-config/openshift/routes.yaml