From dc03390e41e94f57823f7a813fad584c3a2b2e37 Mon Sep 17 00:00:00 2001 From: Nick Barrett Date: Wed, 11 Nov 2020 15:16:49 +0000 Subject: [PATCH 1/4] Extract git deploy functions into util. --- kubetools/deploy/commands/deploy.py | 55 +++-------------------------- kubetools/deploy/commands/util.py | 49 +++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 50 deletions(-) create mode 100644 kubetools/deploy/commands/util.py diff --git a/kubetools/deploy/commands/deploy.py b/kubetools/deploy/commands/deploy.py index ed52c516..9b8da832 100644 --- a/kubetools/deploy/commands/deploy.py +++ b/kubetools/deploy/commands/deploy.py @@ -1,14 +1,9 @@ from os import path from kubetools.config import load_kubetools_config -from kubetools.constants import ( - GIT_BRANCH_ANNOTATION_KEY, - GIT_COMMIT_ANNOTATION_KEY, - GIT_TAG_ANNOTATION_KEY, - ROLE_LABEL_KEY, -) +from kubetools.constants import ROLE_LABEL_KEY from kubetools.deploy.image import ensure_docker_images -from kubetools.deploy.util import log_actions, run_shell_command +from kubetools.deploy.util import log_actions from kubetools.exceptions import KubeBuildError from kubetools.kubernetes.api import ( create_deployment, @@ -32,47 +27,7 @@ generate_namespace_config, ) - -def _is_git_committed(app_dir): - git_status = run_shell_command( - 'git', 'status', '--porcelain', - cwd=app_dir, - ).strip().decode() - - if git_status: - return False - return True - - -def _get_git_info(app_dir): - git_annotations = {} - - commit_hash = run_shell_command( - 'git', 'rev-parse', '--short=7', 'HEAD', - cwd=app_dir, - ).strip().decode() - git_annotations[GIT_COMMIT_ANNOTATION_KEY] = commit_hash - - branch_name = run_shell_command( - 'git', 'rev-parse', '--abbrev-ref', 'HEAD', - cwd=app_dir, - ).strip().decode() - - if branch_name != 'HEAD': - git_annotations[GIT_BRANCH_ANNOTATION_KEY] = branch_name - - try: - git_tag = run_shell_command( - 'git', 'tag', '--points-at', commit_hash, - cwd=app_dir, - ).strip().decode() - except KubeBuildError: - pass - else: - if git_tag: - git_annotations[GIT_TAG_ANNOTATION_KEY] = git_tag - - return commit_hash, git_annotations +from .util import get_git_info, is_git_committed # Deploy/upgrade @@ -110,10 +65,10 @@ def get_deploy_objects( for app_dir in app_dirs: if path.exists(path.join(app_dir, '.git')): - if not _is_git_committed(app_dir) and not ignore_git_changes: + if not is_git_committed(app_dir) and not ignore_git_changes: raise KubeBuildError(f'{app_dir} contains uncommitted changes, refusing to deploy!') - commit_hash, git_annotations = _get_git_info(app_dir) + commit_hash, git_annotations = get_git_info(app_dir) annotations.update(git_annotations) else: raise KubeBuildError(f'{app_dir} is not a valid git repository!') diff --git a/kubetools/deploy/commands/util.py b/kubetools/deploy/commands/util.py new file mode 100644 index 00000000..4bcc1c75 --- /dev/null +++ b/kubetools/deploy/commands/util.py @@ -0,0 +1,49 @@ +from kubetools.constants import ( + GIT_BRANCH_ANNOTATION_KEY, + GIT_COMMIT_ANNOTATION_KEY, + GIT_TAG_ANNOTATION_KEY, +) +from kubetools.deploy.util import run_shell_command +from kubetools.exceptions import KubeBuildError + + +def is_git_committed(app_dir): + git_status = run_shell_command( + 'git', 'status', '--porcelain', + cwd=app_dir, + ).strip().decode() + + if git_status: + return False + return True + + +def get_git_info(app_dir): + git_annotations = {} + + commit_hash = run_shell_command( + 'git', 'rev-parse', '--short=7', 'HEAD', + cwd=app_dir, + ).strip().decode() + git_annotations[GIT_COMMIT_ANNOTATION_KEY] = commit_hash + + branch_name = run_shell_command( + 'git', 'rev-parse', '--abbrev-ref', 'HEAD', + cwd=app_dir, + ).strip().decode() + + if branch_name != 'HEAD': + git_annotations[GIT_BRANCH_ANNOTATION_KEY] = branch_name + + try: + git_tag = run_shell_command( + 'git', 'tag', '--points-at', commit_hash, + cwd=app_dir, + ).strip().decode() + except KubeBuildError: + pass + else: + if git_tag: + git_annotations[GIT_TAG_ANNOTATION_KEY] = git_tag + + return commit_hash, git_annotations From 8144e986ceae3cb91f856bd74588c1e5b7511c3b Mon Sep 17 00:00:00 2001 From: Nick Barrett Date: Wed, 11 Nov 2020 15:17:17 +0000 Subject: [PATCH 2/4] Improve logging of job create actions. --- kubetools/deploy/commands/deploy.py | 1 + kubetools/deploy/util.py | 18 +++++++++++++----- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/kubetools/deploy/commands/deploy.py b/kubetools/deploy/commands/deploy.py index 9b8da832..17c8fdcc 100644 --- a/kubetools/deploy/commands/deploy.py +++ b/kubetools/deploy/commands/deploy.py @@ -155,6 +155,7 @@ def log_deploy_changes( log_actions(build, 'CREATE', 'deployment', new_deployments, name_formatter) log_actions(build, 'UPDATE', 'service', update_services, name_formatter) log_actions(build, 'UPDATE', 'deployment', update_deployments, name_formatter) + log_actions(build, 'CREATE', 'job', jobs, name_formatter) def execute_deploy(build, namespace, services, deployments, jobs, delete_completed_jobs=True): diff --git a/kubetools/deploy/util.py b/kubetools/deploy/util.py index 4bbb51fc..5b13934f 100644 --- a/kubetools/deploy/util.py +++ b/kubetools/deploy/util.py @@ -36,11 +36,19 @@ def run_shell_command(*command, **kwargs): )) -def log_actions(build, action, object_type, names, name_formatter): - for name in names: - if not isinstance(name, str): - name = get_object_name(name) - build.log_info(f'{action} {object_type} {name_formatter(name)}') +def log_actions(build, action, object_type, objects_or_names, name_formatter): + for object_or_name in objects_or_names: + if isinstance(object_or_name, str): + name = name_formatter(object_or_name) + else: + name = name_formatter(get_object_name(object_or_name)) + + if object_type == 'job': + command = object_or_name['spec']['template']['spec']['containers'][0]['command'] + command = ' '.join(command) + name = f'{name} ({command})' + + build.log_info(f'{action} {object_type} {name}') def delete_objects(build, objects, delete_function): From 1d9d9089abe31e085d60c841d8084b4bd6af7af2 Mon Sep 17 00:00:00 2001 From: Nick Barrett Date: Wed, 11 Nov 2020 15:29:31 +0000 Subject: [PATCH 3/4] Add wait kwarg to `create_job`. --- kubetools/kubernetes/api.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/kubetools/kubernetes/api.py b/kubetools/kubernetes/api.py index 0d0c20e0..87a3f464 100644 --- a/kubetools/kubernetes/api.py +++ b/kubetools/kubernetes/api.py @@ -271,14 +271,15 @@ def delete_job(env, namespace, job): _wait_for_no_object(k8s_batch_api, 'read_namespaced_job', namespace, job) -def create_job(env, namespace, job): +def create_job(env, namespace, job, wait=True): k8s_batch_api = _get_k8s_batch_api(env) k8s_job = k8s_batch_api.create_namespaced_job( body=job, namespace=namespace, ) - wait_for_job(env, namespace, k8s_job) + if wait: + wait_for_job(env, namespace, k8s_job) return k8s_job From 9d96b4240ca835a3386c1cec8c00af21fdaa424d Mon Sep 17 00:00:00 2001 From: Nick Barrett Date: Wed, 11 Nov 2020 15:30:02 +0000 Subject: [PATCH 4/4] Implement `kubetools run NAMESPACE APP_DIR CONTAINER COMMAND`. --- kubetools/cli/deploy.py | 122 +++++++++++++++++++++++++++++++ kubetools/deploy/commands/run.py | 117 +++++++++++++++++++++++++++++ 2 files changed, 239 insertions(+) create mode 100644 kubetools/deploy/commands/run.py diff --git a/kubetools/cli/deploy.py b/kubetools/cli/deploy.py index 3716ca4c..fe3a7b34 100644 --- a/kubetools/cli/deploy.py +++ b/kubetools/cli/deploy.py @@ -25,6 +25,11 @@ get_restart_objects, log_restart_changes, ) +from kubetools.deploy.commands.run import ( + execute_run, + get_run_objects, + log_run_changes, +) from kubetools.kubernetes.api import get_object_name @@ -201,6 +206,123 @@ def deploy( ) +@cli_bootstrap.command(help_priority=0) +@click.option( + '--default-registry', + help='Default registry for apps that do not specify.', +) +@click.option( + '-y', '--yes', + is_flag=True, + default=False, + help='Flag to auto-yes remove confirmation step.', +) +@click.option( + 'envvars', '-e', '--envvar', + multiple=True, + callback=_validate_key_value_argument, + help='Extra environment variables to apply to Kubernetes objects, format: key=value.', +) +@click.option( + '-f', '--file', + nargs=1, + help='Specify a non-default Kubetools yml file to deploy from.', + type=click.Path(exists=True), +) +@click.option( + '--ignore-git-changes', + is_flag=True, + default=False, + help='Flag to ignore un-committed changes in git.', +) +@click.option( + 'wait_for_job', '--wait', + is_flag=True, + default=False, + help='Whether to wait for the job to complete.', +) +@click.option( + 'delete_completed_job', '--delete', + is_flag=True, + default=False, + help='Delete jobs after they complete (requires `--wait`).', +) +@click.argument('namespace') +@click.argument( + 'app_dir', + type=click.Path(exists=True, file_okay=False), +) +@click.argument('container_context') +@click.argument( + 'command', + nargs=-1, +) +@click.pass_context +def run( + ctx, + default_registry, + yes, + envvars, + file, + ignore_git_changes, + wait_for_job, + delete_completed_job, + namespace, + app_dir, + container_context, + command, +): + ''' + Run a command for a given app in Kubernetes. + ''' + + if not wait_for_job and delete_completed_job: + raise click.BadParameter('Cannot have `--delete-job` without `--wait`!') + + build = Build( + env=ctx.meta['kube_context'], + namespace=namespace, + ) + + if file: + custom_config_file = click.format_filename(file) + else: + custom_config_file = None + + namespace, job = get_run_objects( + build, app_dir, container_context, command, + default_registry=default_registry, + extra_envvars=envvars, + ignore_git_changes=ignore_git_changes, + custom_config_file=custom_config_file, + ) + + if not any((namespace, job)): + click.echo('Nothing to do!') + return + + log_run_changes( + build, namespace, job, + message='Executing changes:' if yes else 'Proposed changes:', + name_formatter=lambda name: click.style(name, bold=True), + ) + + if not yes: + click.confirm(click.style(( + 'Are you sure you wish to CREATE the above resource? ' + 'This cannot be undone.' + )), abort=True) + click.echo() + + execute_run( + build, + namespace, + job, + wait_for_job=wait_for_job, + delete_completed_job=delete_completed_job, + ) + + @cli_bootstrap.command(help_priority=1) @click.option( '-y', '--yes', diff --git a/kubetools/deploy/commands/run.py b/kubetools/deploy/commands/run.py new file mode 100644 index 00000000..64046b11 --- /dev/null +++ b/kubetools/deploy/commands/run.py @@ -0,0 +1,117 @@ +from os import path + +from kubetools.config import load_kubetools_config +from kubetools.deploy.image import ensure_docker_images +from kubetools.deploy.util import log_actions +from kubetools.exceptions import KubeBuildError +from kubetools.kubernetes.api import ( + create_job, + create_namespace, + delete_job, + get_object_name, + list_namespaces, + namespace_exists, + update_namespace, +) +from kubetools.kubernetes.config import generate_namespace_config +from kubetools.kubernetes.config.job import make_job_config + +from .util import get_git_info, is_git_committed + + +# Run +# Create a Kubernetes job from an app + container context + +def get_run_objects( + build, + app_dir, + container_context, + command, + default_registry=None, + extra_envvars=None, + extra_annotations=None, + ignore_git_changes=False, + custom_config_file=False, +): + envvars = { + 'KUBE_ENV': build.env, + 'KUBE_NAMESPACE': build.namespace, + } + if extra_envvars: + envvars.update(extra_envvars) + + annotations = { + 'kubetools/env': build.env, + 'kubetools/namespace': build.namespace, + } + if extra_annotations: + annotations.update(extra_annotations) + + namespace = generate_namespace_config(build.namespace, base_annotations=annotations) + + if path.exists(path.join(app_dir, '.git')): + if not is_git_committed(app_dir) and not ignore_git_changes: + raise KubeBuildError(f'{app_dir} contains uncommitted changes, refusing to deploy!') + + commit_hash, git_annotations = get_git_info(app_dir) + annotations.update(git_annotations) + else: + raise KubeBuildError(f'{app_dir} is not a valid git repository!') + + kubetools_config = load_kubetools_config( + app_dir, + env=build.env, + namespace=build.namespace, + app_name=app_dir, + custom_config_file=custom_config_file, + ) + + context_to_image = ensure_docker_images( + kubetools_config, build, app_dir, + commit_hash=commit_hash, + default_registry=default_registry, + ) + + job = make_job_config({ + 'image': context_to_image[container_context], + 'command': command, + }) + + return namespace, job + + +def log_run_changes( + build, namespace, job, + message='Executing changes:', + name_formatter=lambda name: name, +): + existing_namespace_names = set( + get_object_name(namespace) + for namespace in list_namespaces(build.env) + ) + + deploy_namespace_name = set((build.namespace,)) + + new_namespace = deploy_namespace_name - existing_namespace_names + + with build.stage(message): + log_actions(build, 'CREATE', 'namespace', new_namespace, name_formatter) + log_actions(build, 'CREATE', 'job', [job], name_formatter) + + +def execute_run(build, namespace, job, wait_for_job=False, delete_completed_job=False): + if namespace: + with build.stage('Create and/or update namespace'): + if namespace_exists(build.env, namespace): + build.log_info(f'Update namespace: {get_object_name(namespace)}') + update_namespace(build.env, namespace) + else: + build.log_info(f'Create namespace: {get_object_name(namespace)}') + create_namespace(build.env, namespace) + + with build.stage('Execute job'): + build.log_info(f'Create job: {get_object_name(job)}') + create_job(build.env, build.namespace, job, wait=wait_for_job) + if delete_completed_job: + build.log_info(f'Delete job: {get_object_name(job)}') + delete_job(build.env, build.namespace, job)