From 076b9ba971d6e4670a0d5691a70a16d803a1e60d Mon Sep 17 00:00:00 2001 From: Liss Tarnell Date: Thu, 12 Oct 2017 15:55:31 +0100 Subject: [PATCH] fix default namespace detection --- Makefile | 4 + __main__.py | 11 +- deploy.py | 1260 ++++++++++++++++++++++++------------------------- deployment.py | 246 +++++----- kubeutil.py | 99 ++-- status.py | 234 ++++----- 6 files changed, 936 insertions(+), 918 deletions(-) diff --git a/Makefile b/Makefile index 5797910..013c2f3 100644 --- a/Makefile +++ b/Makefile @@ -42,4 +42,8 @@ docker-build: docker-push: docker push ${REPOSITORY}:${TAG} +testing: + ${MAKE} TAG=testing docker-build + ${MAKE} TAG=testing docker-push + .PHONY: default dist build push version.py diff --git a/__main__.py b/__main__.py index 880fcdf..243752a 100644 --- a/__main__.py +++ b/__main__.py @@ -40,7 +40,7 @@ def __call__(self, parser, namespace, values, option_string): help="Type program version and exit") parser.add_argument('-K', '--kubectl', type=str, metavar='PATH', help='Location of kubectl binary') -parser.add_argument('-n', '--namespace', type=str, default="default", +parser.add_argument('-n', '--namespace', type=str, help='Kubernetes namespace to deploy in') parser.add_argument('-S', '--server', type=str, metavar='URL', help="Kubernetes API server URL") @@ -74,6 +74,15 @@ def add_commands(cmds): stderr.write('install kubectl in $PATH or pass -K/path/to/kubectl.\n') exit(1) +# The Python client doesn't seem to pick up the namespace from kubeconfig, +# which breaks GitLab's automatic configuration. Try to guess what it should +# be. +if args.namespace is None: + if 'KUBE_NAMESPACE' in environ: + args.namespace = environ['KUBE_NAMESPACE'] + else: + args.namespace = 'default' + # Check for GitLab mode. if args.gitlab: if 'KUBECONFIG' in environ: diff --git a/deploy.py b/deploy.py index 871bc5c..7f95dc0 100644 --- a/deploy.py +++ b/deploy.py @@ -1,630 +1,630 @@ -#! /usr/bin/env python3 -# vim:set sw=4 ts=4 et: -# -# Copyright (c) 2016-2017 Torchbox Ltd. -# -# Permission is granted to anyone to use this software for any purpose, -# including commercial applications, and to alter it and redistribute it -# freely. This software is provided 'as-is', without any express or implied -# warranty. - -import subprocess, json, humanfriendly -from base64 import b64encode -from sys import stdin, stdout, stderr -from passlib.hash import md5_crypt - -import kubectl -from util import strip_hostname - - -# make_service: create a Service resource for the given arguments. -def make_service(args): - service = { - 'apiVersion': 'v1', - 'kind': 'Service', - 'metadata': { - 'name': args.name, - 'namespace': args.namespace, - }, - 'spec': { - 'ports': [ - { - 'name': 'http', - 'port': 80, - 'protocol': 'TCP', - 'targetPort': 'http', - }, - ], - 'selector': { - 'app': args.name - }, - 'type': 'ClusterIP', - }, - } - - return service - -# make_ingress: create an Ingress resource for the given arguments. -# returns: API object data structure -def make_ingress(args): - # The basic Ingress - ingress = { - 'apiVersion': 'extensions/v1beta1', - 'kind': 'Ingress', - 'metadata': { - 'name': args.name, - 'namespace': args.namespace, - 'annotations': {} - }, - 'spec': { - 'rules': [ - { - 'host': strip_hostname(hostname), - 'http': { - 'paths': [ - { - 'backend': { - 'serviceName': args.name, - 'servicePort': 80, - }, - }, - ], - }, - } for hostname in args.hostname - ], - }, - } - - # Add htauth - - secrets = [] - - if len(args.htauth_address): - ingress['metadata']['annotations']\ - ['ingress.kubernetes.io/whitelist-source-range'] = \ - ",".join(args.htauth_address) - - if len(args.htauth_user): - ingress['metadata']['annotations'].update({ - 'ingress.kubernetes.io/auth-type': 'basic', - 'ingress.kubernetes.io/auth-realm': args.htauth_realm, - 'ingress.kubernetes.io/auth-satisfy': args.htauth_satisfy, - 'ingress.kubernetes.io/auth-secret': args.name+'-htaccess', - }) - - htpasswd = "" - for auth in args.htauth_user: - (u,p) = auth.split(":", 1) - htpasswd += u + ":" + md5_crypt.hash(p) + "\n" - - secrets.append({ - 'apiVersion': 'v1', - 'kind': 'Secret', - 'metadata': { - 'name': args.name+'-htaccess', - 'namespace': args.namespace, - }, - 'type': 'Opaque', - 'data': { - 'auth': b64encode(htpasswd.encode('utf-8')).decode('ascii'), - }, - }) - - # Add ACME TLS - if args.acme: - ingress['metadata']['annotations']['kubernetes.io/tls-acme'] = 'true' - ingress['spec']['tls'] = [{ - 'hosts': [ strip_hostname(hostname) ], - 'secretName': strip_hostname(hostname) + '-tls', - } for hostname in args.hostname] - - return (ingress, secrets) - - -# make_pod: create a basic Pod for the given arguments -def make_pod(args): - pod = { - 'metadata': { - 'labels': { - 'app': args.name, - } - }, - 'spec': { - 'containers': [], - 'volumes': [], - }, - } - - return pod - - -# make_deployment: convert the given Pod into a Deployment for the given args. -def make_deployment(pod, args): - deployment = { - 'apiVersion': 'extensions/v1beta1', - 'kind': 'Deployment', - 'metadata': { - 'name': args.name, - 'namespace': args.namespace, - }, - 'spec': { - 'replicas': args.replicas, - 'selector': { - 'matchLabels': { - 'app': args.name, - }, - }, - 'template': pod, - }, - } - - if args.strategy == 'rollingupdate': - deployment['spec']['strategy'] = { - 'type': 'RollingUpdate', - 'rollingUpdate': { - 'maxSurge': 1, - 'maxUnavailable': 0, - }, - } - else: - deployment['spec']['strategy'] = { - 'type': 'Recreate', - } - - return deployment - - -# make_pvc: create a PVC from the given argument -def make_pvc(arg, args): - (volslug, path) = arg.split(':', 1) - name = args.name + '-' + volslug - - pvc = { - 'apiVersion': 'v1', - 'kind': 'PersistentVolumeClaim', - 'metadata': { - 'namespace': args.namespace, - 'name': name, - }, - 'spec': { - 'accessModes': [ 'ReadWriteMany' ], - 'resources': { - 'requests': { - 'storage': '1Gi', - }, - }, - }, - } - - pvcvolume = { - 'name': volslug, - 'persistentVolumeClaim': { - 'claimName': name, - } - } - - pvcmount = { - 'name': volslug, - 'mountPath': path, - } - - return (pvc, pvcvolume, pvcmount) - - -# make_app_container: create the application container. -def make_app_container(args): - # We add some empty values here so it's easier to modify this template later - app_container = { - 'name': 'app', - 'image': args.image, - 'imagePullPolicy': args.image_pull_policy, - 'resources': { - 'limits': {}, - 'requests': {}, - }, - 'volumeMounts': [], - 'env': [], - 'envFrom': [], - } - - # Resource limits - if args.cpu_limit: - app_container['resources']['limits']['cpu'] = args.cpu_limit - if args.cpu_request: - app_container['resources']['requests']['cpu'] = args.cpu_request - if args.memory_limit != 'none': - app_container['resources']['limits']['memory'] = \ - humanfriendly.parse_size(args.memory_limit, binary=True) - if args.memory_request != 'none': - app_container['resources']['requests']['memory'] = \ - humanfriendly.parse_size(args.memory_request, binary=True) - - return app_container - - -# make_redis_container: create a Redis container based on args. -def make_redis_container(args): - container = { - 'name': 'redis', - 'image': "redis:alpine", - 'imagePullPolicy': 'Always', - 'args': [ - '--maxmemory', args.redis_cache, - '--maxmemory-policy', 'allkeys-lru', - ], - } - - env = { - 'name': 'CACHE_URL', - 'value': 'redis://localhost:6379/0', - } - - return (container, env) - - -# make_postgres: create a Postgres container for the given args. -def make_postgres(args): - postgres = { - 'name': 'postgres', - 'image': "postgres:" + args.postgres + "-alpine", - 'imagePullPolicy': 'Always', - 'volumeMounts': [ - { - 'name': 'postgres', - 'mountPath': '/var/lib/postgresql/data', - }, - ], - } - - env = { - 'name': 'DATABASE_URL', - 'value': 'postgres://postgres:postgres@localhost/postgres', - } - - pvc = { - 'apiVersion': 'v1', - 'kind': 'PersistentVolumeClaim', - 'metadata': { - 'namespace': args.namespace, - 'name': args.name + '-postgres', - }, - 'spec': { - 'accessModes': [ 'ReadWriteMany' ], - 'resources': { - 'requests': { - 'storage': '1Gi', - }, - }, - }, - } - - volume = { - 'name': 'postgres', - 'persistentVolumeClaim': { - 'claimName': args.name + '-postgres', - } - } - - return (postgres, env, volume, pvc) - - -# make_secret: create a Secret object based on args. -def make_secret(args): - secret = { - 'apiVersion': 'v1', - 'kind': 'Secret', - 'metadata': { - 'name': args.name, - 'namespace': args.namespace, - }, - 'type': 'Opaque', - 'data': {} - } - - for s in args.secret: - (var, value) = s.split('=', 1) - secret['data'][var] = b64encode(value.encode('utf-8')).decode('ascii') - - return secret - - -# make_database: create a torchbox.com/v1.Database based on args. -def make_database(args): - # Due to Kubernetes bug #53379 (https://github.com/kubernetes/kubernetes/issues/53379) - # we cannot unconditionally include the database in the manifest; it will - # fail to apply correctly when the database provisioner is using CRD - # instead of TPR. As a workaround, attempt to check whether the database - # already exists. This is not a very good check because any failure of - # kubectl will be treated as the database not existing, but it will do to - # make deployments work until the Kubernetes bug is fixed. - # - # This should be removed once #53379 is fixed, and we will mark the - # affected Kubernetes releases as unsupported for -D. - provision_db = True - items = [] - - if args.undeploy == False: - stdout.write('checking if database already exists (bug #53379 workaround)...\n') - kargs = kubectl.get_kubectl_args(args) - kargs.extend([ 'get', 'database', args.name ]) - kubectl_p = subprocess.Popen(kargs, - stdin=subprocess.DEVNULL, - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL) - kubectl_p.communicate() - - if kubectl_p.returncode == 0: - stdout.write('database exists; will not replace\n') - provision_db = False - else: - stdout.write('database does not exist; will create\n') - - if provision_db: - items.append({ - 'apiVersion': 'torchbox.com/v1', - 'kind': 'Database', - 'metadata': { - 'namespace': args.namespace, - 'name': args.name, - }, - 'spec': { - 'class': 'default', - 'secretName': args.name+'-database', - 'type': args.database, - }, - }) - - env = { - 'name': 'DATABASE_URL', - 'valueFrom': { - 'secretKeyRef': { - 'name': args.name+'-database', - 'key': 'database-url', - }, - }, - } - - return (items, env) - - -# make_manifest: create a manifest based on our arguments. -def make_manifest(args): - items = [] - - pod = make_pod(args) - app = make_app_container(args) - pod['spec']['containers'].append(app) - - # Configure any requested PVCs - for vol in args.volume: - (pvc, pvcvolume, pvcmount) = make_pvc(vol, args) - items.append(pvc) - - app['volumeMounts'].append(pvcmount) - pod['spec']['volumes'].append(pvcvolume) - - # Add Secret environment variables - if len(args.secret) > 0: - secret = make_secret(args) - items.append(secret) - app['envFrom'].append({ - 'secretRef': { - 'name': args.name, - } - }) - - # Add (non-secret) environment variables - for env in args.env: - envbits = env.split('=', 1) - if len(envbits) == 1: - envbits.append(environ.get(envbits[0], '')) - app['env'].append({ - 'name': envbits[0], - 'value': envbits[1] - }) - - if args.database is not None: - (db_items, db_env) = make_database(args) - items.extend(db_items) - app['env'].append(db_env) - - # Add Redis container - if args.redis_cache is not None: - (redis, redis_env) = make_redis_container(args) - pod['spec']['containers'].append(redis) - app['env'].append(redis_env) - - # Add Postgres container - if args.postgres is not None: - (postgres, pg_env, pg_volume, pg_pvc) = make_postgres(args) - pod['spec']['containers'].append(postgres) - pod['spec']['volumes'].append(pg_volume) - app['env'].append(pg_env) - items.append(pg_pvc) - - # Create our deployment last, so it can reference other resources. - deployment = make_deployment(pod, args) - items.append(deployment) - - # If any hostnames are configured, create a Service and some Ingresses. - if len(args.hostname): - app['ports'] = [ - { - 'name': 'http', - 'containerPort': args.port, - 'protocol': 'TCP', - } - ] - items.append(make_service(args)) - (ingress, secrets) = make_ingress(args) - items.append(ingress) - items.extend(secrets) - - # Convert our items array into a List. - spec = { - 'apiVersion': 'v1', - 'kind': 'List', - 'items': items, - } - - return spec - - -# Deploy an application. -def deploy(args): - if args.manifest: - spec = load_manifest(args, args.manifest) - else: - spec = make_manifest(args) - - if args.json: - print(json.dumps(spec)) - exit(0) - else: - exit(kubectl.apply_manifest(spec, args)) - -deploy.help = "deploy an application" -deploy.arguments = ( - ( ('-H', '--hostname'), { - 'type': str, - 'action': 'append', - 'default': [], - 'help': 'Hostname to expose the application on' - }), - ( ('-A', '--acme'), { - 'action': 'store_true', - 'help': 'Issue Let\'s Encrypt (ACME) TLS certificate', - }), - ( ('-M', '--manifest'), { - 'type': str, - 'metavar': 'FILE', - 'help': 'Deploy from Kubernetes manifest with environment substitution', - }), - ( ('-r', '--replicas'), { - 'type': int, - 'default': 1, - 'help': 'Number of replicas to create', - }), - ( ('-P', '--image-pull-policy'), { - 'type': str, - 'choices': ('IfNotPresent', 'Always'), - 'default': 'IfNotPresent', - 'help': 'Image pull policy', - }), - ( ('-e', '--env'), { - 'type': str, - 'action': 'append', - 'default': [], - 'metavar': 'VARNAME=VALUE', - 'help': 'Set environment variable', - }), - ( ('-s', '--secret'), { - 'type': str, - 'action': 'append', - 'default': [], - 'metavar': 'VARNAME=VALUE', - 'help': 'Set secret environment variable', - }), - ( ('-v', '--volume'), { - 'type': str, - 'action': 'append', - 'default': [], - 'metavar': 'PATH', - 'help': 'Attach persistent filesystem storage at PATH', - }), - ( ('-p', '--port'), { - 'type': int, - 'default': 80, - 'help': 'HTTP port the application listens on', - }), - ( ('-j', '--json'), { - 'action': 'store_true', - 'help': 'Print JSON instead of applying to cluster', - }), - ( ('-U', '--undeploy'), { - 'action': 'store_true', - 'help': 'Remove existing application', - }), - ( ('-n', '--dry-run'), { - 'action': 'store_true', - 'help': 'Pass --dry-run to kubectl', - }), - ( ('-D', '--database'), { - 'type': str, - 'choices': ('mysql', 'postgresql'), - 'help': 'Provision database', - }), - ( ('--htauth-user',), { - 'type': str, - 'action': 'append', - 'default': [], - 'metavar': 'USERNAME:PASSWORD', - 'help': 'Add HTTP authentication username/password', - }), - ( ('--htauth-address',), { - 'type': str, - 'action': 'append', - 'default': [], - 'metavar': 'ipaddress[/prefix]', - 'help': 'Add HTTP authentication address', - }), - ( ('--htauth-satisfy',), { - 'type': str, - 'default': 'any', - 'choices': ('any', 'all'), - 'help': 'HTTP authentication satisfy policy', - }), - ( ('--htauth-realm',), { - 'type': str, - 'default': 'Authentication required', - 'help': 'HTTP authentication realm', - }), - ( ('--postgres',), { - 'type': str, - 'metavar': '9.6', - 'help': 'Attach PostgreSQL database at $DATABASE_URL', - }), - ( ('--redis-cache',), { - 'type': str, - 'metavar': '64m', - 'help': 'Attach Redis database at $CACHE_URL', - }), - ( ('--memory-request',), { - 'type': str, - 'default': 'none', - 'help': 'Required memory allocation', - }), - ( ('--memory-limit',), { - 'type': str, - 'default': 'none', - 'help': 'Memory limit', - }), - ( ('--cpu-request',), { - 'type': float, - 'default': 0, - 'help': 'Number of dedicated CPU cores', - }), - ( ('--cpu-limit',), { - 'type': float, - 'default': 0, - 'help': 'CPU core use limit', - }), - ( ('--strategy',), { - 'type': str, - 'choices': ('rollingupdate', 'recreate'), - 'default': 'rollingupdate', - 'help': 'Deployment update strategy', - }), - ( ('image',), { - 'type': str, - 'help': 'Docker image to deploy', - }), - ( ('name',), { - 'type': str, - 'help': 'Application name', - }) -) - -commands = { - 'deploy': deploy, -} +#! /usr/bin/env python3 +# vim:set sw=4 ts=4 et: +# +# Copyright (c) 2016-2017 Torchbox Ltd. +# +# Permission is granted to anyone to use this software for any purpose, +# including commercial applications, and to alter it and redistribute it +# freely. This software is provided 'as-is', without any express or implied +# warranty. + +import subprocess, json, humanfriendly +from base64 import b64encode +from sys import stdin, stdout, stderr +from passlib.hash import md5_crypt + +import kubectl +from util import strip_hostname + + +# make_service: create a Service resource for the given arguments. +def make_service(args): + service = { + 'apiVersion': 'v1', + 'kind': 'Service', + 'metadata': { + 'name': args.name, + 'namespace': args.namespace, + }, + 'spec': { + 'ports': [ + { + 'name': 'http', + 'port': 80, + 'protocol': 'TCP', + 'targetPort': 'http', + }, + ], + 'selector': { + 'app': args.name + }, + 'type': 'ClusterIP', + }, + } + + return service + +# make_ingress: create an Ingress resource for the given arguments. +# returns: API object data structure +def make_ingress(args): + # The basic Ingress + ingress = { + 'apiVersion': 'extensions/v1beta1', + 'kind': 'Ingress', + 'metadata': { + 'name': args.name, + 'namespace': args.namespace, + 'annotations': {} + }, + 'spec': { + 'rules': [ + { + 'host': strip_hostname(hostname), + 'http': { + 'paths': [ + { + 'backend': { + 'serviceName': args.name, + 'servicePort': 80, + }, + }, + ], + }, + } for hostname in args.hostname + ], + }, + } + + # Add htauth + + secrets = [] + + if len(args.htauth_address): + ingress['metadata']['annotations']\ + ['ingress.kubernetes.io/whitelist-source-range'] = \ + ",".join(args.htauth_address) + + if len(args.htauth_user): + ingress['metadata']['annotations'].update({ + 'ingress.kubernetes.io/auth-type': 'basic', + 'ingress.kubernetes.io/auth-realm': args.htauth_realm, + 'ingress.kubernetes.io/auth-satisfy': args.htauth_satisfy, + 'ingress.kubernetes.io/auth-secret': args.name+'-htaccess', + }) + + htpasswd = "" + for auth in args.htauth_user: + (u,p) = auth.split(":", 1) + htpasswd += u + ":" + md5_crypt.hash(p) + "\n" + + secrets.append({ + 'apiVersion': 'v1', + 'kind': 'Secret', + 'metadata': { + 'name': args.name+'-htaccess', + 'namespace': args.namespace, + }, + 'type': 'Opaque', + 'data': { + 'auth': b64encode(htpasswd.encode('utf-8')).decode('ascii'), + }, + }) + + # Add ACME TLS + if args.acme: + ingress['metadata']['annotations']['kubernetes.io/tls-acme'] = 'true' + ingress['spec']['tls'] = [{ + 'hosts': [ strip_hostname(hostname) ], + 'secretName': strip_hostname(hostname) + '-tls', + } for hostname in args.hostname] + + return (ingress, secrets) + + +# make_pod: create a basic Pod for the given arguments +def make_pod(args): + pod = { + 'metadata': { + 'labels': { + 'app': args.name, + } + }, + 'spec': { + 'containers': [], + 'volumes': [], + }, + } + + return pod + + +# make_deployment: convert the given Pod into a Deployment for the given args. +def make_deployment(pod, args): + deployment = { + 'apiVersion': 'extensions/v1beta1', + 'kind': 'Deployment', + 'metadata': { + 'name': args.name, + 'namespace': args.namespace, + }, + 'spec': { + 'replicas': args.replicas, + 'selector': { + 'matchLabels': { + 'app': args.name, + }, + }, + 'template': pod, + }, + } + + if args.strategy == 'rollingupdate': + deployment['spec']['strategy'] = { + 'type': 'RollingUpdate', + 'rollingUpdate': { + 'maxSurge': 1, + 'maxUnavailable': 0, + }, + } + else: + deployment['spec']['strategy'] = { + 'type': 'Recreate', + } + + return deployment + + +# make_pvc: create a PVC from the given argument +def make_pvc(arg, args): + (volslug, path) = arg.split(':', 1) + name = args.name + '-' + volslug + + pvc = { + 'apiVersion': 'v1', + 'kind': 'PersistentVolumeClaim', + 'metadata': { + 'namespace': args.namespace, + 'name': name, + }, + 'spec': { + 'accessModes': [ 'ReadWriteMany' ], + 'resources': { + 'requests': { + 'storage': '1Gi', + }, + }, + }, + } + + pvcvolume = { + 'name': volslug, + 'persistentVolumeClaim': { + 'claimName': name, + } + } + + pvcmount = { + 'name': volslug, + 'mountPath': path, + } + + return (pvc, pvcvolume, pvcmount) + + +# make_app_container: create the application container. +def make_app_container(args): + # We add some empty values here so it's easier to modify this template later + app_container = { + 'name': 'app', + 'image': args.image, + 'imagePullPolicy': args.image_pull_policy, + 'resources': { + 'limits': {}, + 'requests': {}, + }, + 'volumeMounts': [], + 'env': [], + 'envFrom': [], + } + + # Resource limits + if args.cpu_limit: + app_container['resources']['limits']['cpu'] = args.cpu_limit + if args.cpu_request: + app_container['resources']['requests']['cpu'] = args.cpu_request + if args.memory_limit != 'none': + app_container['resources']['limits']['memory'] = \ + humanfriendly.parse_size(args.memory_limit, binary=True) + if args.memory_request != 'none': + app_container['resources']['requests']['memory'] = \ + humanfriendly.parse_size(args.memory_request, binary=True) + + return app_container + + +# make_redis_container: create a Redis container based on args. +def make_redis_container(args): + container = { + 'name': 'redis', + 'image': "redis:alpine", + 'imagePullPolicy': 'Always', + 'args': [ + '--maxmemory', args.redis_cache, + '--maxmemory-policy', 'allkeys-lru', + ], + } + + env = { + 'name': 'CACHE_URL', + 'value': 'redis://localhost:6379/0', + } + + return (container, env) + + +# make_postgres: create a Postgres container for the given args. +def make_postgres(args): + postgres = { + 'name': 'postgres', + 'image': "postgres:" + args.postgres + "-alpine", + 'imagePullPolicy': 'Always', + 'volumeMounts': [ + { + 'name': 'postgres', + 'mountPath': '/var/lib/postgresql/data', + }, + ], + } + + env = { + 'name': 'DATABASE_URL', + 'value': 'postgres://postgres:postgres@localhost/postgres', + } + + pvc = { + 'apiVersion': 'v1', + 'kind': 'PersistentVolumeClaim', + 'metadata': { + 'namespace': args.namespace, + 'name': args.name + '-postgres', + }, + 'spec': { + 'accessModes': [ 'ReadWriteMany' ], + 'resources': { + 'requests': { + 'storage': '1Gi', + }, + }, + }, + } + + volume = { + 'name': 'postgres', + 'persistentVolumeClaim': { + 'claimName': args.name + '-postgres', + } + } + + return (postgres, env, volume, pvc) + + +# make_secret: create a Secret object based on args. +def make_secret(args): + secret = { + 'apiVersion': 'v1', + 'kind': 'Secret', + 'metadata': { + 'name': args.name, + 'namespace': args.namespace, + }, + 'type': 'Opaque', + 'data': {} + } + + for s in args.secret: + (var, value) = s.split('=', 1) + secret['data'][var] = b64encode(value.encode('utf-8')).decode('ascii') + + return secret + + +# make_database: create a torchbox.com/v1.Database based on args. +def make_database(args): + # Due to Kubernetes bug #53379 (https://github.com/kubernetes/kubernetes/issues/53379) + # we cannot unconditionally include the database in the manifest; it will + # fail to apply correctly when the database provisioner is using CRD + # instead of TPR. As a workaround, attempt to check whether the database + # already exists. This is not a very good check because any failure of + # kubectl will be treated as the database not existing, but it will do to + # make deployments work until the Kubernetes bug is fixed. + # + # This should be removed once #53379 is fixed, and we will mark the + # affected Kubernetes releases as unsupported for -D. + provision_db = True + items = [] + + if args.undeploy == False: + stdout.write('checking if database already exists (bug #53379 workaround)...\n') + kargs = kubectl.get_kubectl_args(args) + kargs.extend([ 'get', 'database', args.name ]) + kubectl_p = subprocess.Popen(kargs, + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL) + kubectl_p.communicate() + + if kubectl_p.returncode == 0: + stdout.write('database exists; will not replace\n') + provision_db = False + else: + stdout.write('database does not exist; will create\n') + + if provision_db: + items.append({ + 'apiVersion': 'torchbox.com/v1', + 'kind': 'Database', + 'metadata': { + 'namespace': args.namespace, + 'name': args.name, + }, + 'spec': { + 'class': 'default', + 'secretName': args.name+'-database', + 'type': args.database, + }, + }) + + env = { + 'name': 'DATABASE_URL', + 'valueFrom': { + 'secretKeyRef': { + 'name': args.name+'-database', + 'key': 'database-url', + }, + }, + } + + return (items, env) + + +# make_manifest: create a manifest based on our arguments. +def make_manifest(args): + items = [] + + pod = make_pod(args) + app = make_app_container(args) + pod['spec']['containers'].append(app) + + # Configure any requested PVCs + for vol in args.volume: + (pvc, pvcvolume, pvcmount) = make_pvc(vol, args) + items.append(pvc) + + app['volumeMounts'].append(pvcmount) + pod['spec']['volumes'].append(pvcvolume) + + # Add Secret environment variables + if len(args.secret) > 0: + secret = make_secret(args) + items.append(secret) + app['envFrom'].append({ + 'secretRef': { + 'name': args.name, + } + }) + + # Add (non-secret) environment variables + for env in args.env: + envbits = env.split('=', 1) + if len(envbits) == 1: + envbits.append(environ.get(envbits[0], '')) + app['env'].append({ + 'name': envbits[0], + 'value': envbits[1] + }) + + if args.database is not None: + (db_items, db_env) = make_database(args) + items.extend(db_items) + app['env'].append(db_env) + + # Add Redis container + if args.redis_cache is not None: + (redis, redis_env) = make_redis_container(args) + pod['spec']['containers'].append(redis) + app['env'].append(redis_env) + + # Add Postgres container + if args.postgres is not None: + (postgres, pg_env, pg_volume, pg_pvc) = make_postgres(args) + pod['spec']['containers'].append(postgres) + pod['spec']['volumes'].append(pg_volume) + app['env'].append(pg_env) + items.append(pg_pvc) + + # Create our deployment last, so it can reference other resources. + deployment = make_deployment(pod, args) + items.append(deployment) + + # If any hostnames are configured, create a Service and some Ingresses. + if len(args.hostname): + app['ports'] = [ + { + 'name': 'http', + 'containerPort': args.port, + 'protocol': 'TCP', + } + ] + items.append(make_service(args)) + (ingress, secrets) = make_ingress(args) + items.append(ingress) + items.extend(secrets) + + # Convert our items array into a List. + spec = { + 'apiVersion': 'v1', + 'kind': 'List', + 'items': items, + } + + return spec + + +# Deploy an application. +def deploy(args): + if args.manifest: + spec = load_manifest(args, args.manifest) + else: + spec = make_manifest(args) + + if args.json: + print(json.dumps(spec)) + exit(0) + else: + exit(kubectl.apply_manifest(spec, args)) + +deploy.help = "deploy an application" +deploy.arguments = ( + ( ('-H', '--hostname'), { + 'type': str, + 'action': 'append', + 'default': [], + 'help': 'Hostname to expose the application on' + }), + ( ('-A', '--acme'), { + 'action': 'store_true', + 'help': 'Issue Let\'s Encrypt (ACME) TLS certificate', + }), + ( ('-M', '--manifest'), { + 'type': str, + 'metavar': 'FILE', + 'help': 'Deploy from Kubernetes manifest with environment substitution', + }), + ( ('-r', '--replicas'), { + 'type': int, + 'default': 1, + 'help': 'Number of replicas to create', + }), + ( ('-P', '--image-pull-policy'), { + 'type': str, + 'choices': ('IfNotPresent', 'Always'), + 'default': 'IfNotPresent', + 'help': 'Image pull policy', + }), + ( ('-e', '--env'), { + 'type': str, + 'action': 'append', + 'default': [], + 'metavar': 'VARNAME=VALUE', + 'help': 'Set environment variable', + }), + ( ('-s', '--secret'), { + 'type': str, + 'action': 'append', + 'default': [], + 'metavar': 'VARNAME=VALUE', + 'help': 'Set secret environment variable', + }), + ( ('-v', '--volume'), { + 'type': str, + 'action': 'append', + 'default': [], + 'metavar': 'PATH', + 'help': 'Attach persistent filesystem storage at PATH', + }), + ( ('-p', '--port'), { + 'type': int, + 'default': 80, + 'help': 'HTTP port the application listens on', + }), + ( ('-j', '--json'), { + 'action': 'store_true', + 'help': 'Print JSON instead of applying to cluster', + }), + ( ('-U', '--undeploy'), { + 'action': 'store_true', + 'help': 'Remove existing application', + }), + ( ('-n', '--dry-run'), { + 'action': 'store_true', + 'help': 'Pass --dry-run to kubectl', + }), + ( ('-D', '--database'), { + 'type': str, + 'choices': ('mysql', 'postgresql'), + 'help': 'Provision database', + }), + ( ('--htauth-user',), { + 'type': str, + 'action': 'append', + 'default': [], + 'metavar': 'USERNAME:PASSWORD', + 'help': 'Add HTTP authentication username/password', + }), + ( ('--htauth-address',), { + 'type': str, + 'action': 'append', + 'default': [], + 'metavar': 'ipaddress[/prefix]', + 'help': 'Add HTTP authentication address', + }), + ( ('--htauth-satisfy',), { + 'type': str, + 'default': 'any', + 'choices': ('any', 'all'), + 'help': 'HTTP authentication satisfy policy', + }), + ( ('--htauth-realm',), { + 'type': str, + 'default': 'Authentication required', + 'help': 'HTTP authentication realm', + }), + ( ('--postgres',), { + 'type': str, + 'metavar': '9.6', + 'help': 'Attach PostgreSQL database at $DATABASE_URL', + }), + ( ('--redis-cache',), { + 'type': str, + 'metavar': '64m', + 'help': 'Attach Redis database at $CACHE_URL', + }), + ( ('--memory-request',), { + 'type': str, + 'default': 'none', + 'help': 'Required memory allocation', + }), + ( ('--memory-limit',), { + 'type': str, + 'default': 'none', + 'help': 'Memory limit', + }), + ( ('--cpu-request',), { + 'type': float, + 'default': 0, + 'help': 'Number of dedicated CPU cores', + }), + ( ('--cpu-limit',), { + 'type': float, + 'default': 0, + 'help': 'CPU core use limit', + }), + ( ('--strategy',), { + 'type': str, + 'choices': ('rollingupdate', 'recreate'), + 'default': 'rollingupdate', + 'help': 'Deployment update strategy', + }), + ( ('image',), { + 'type': str, + 'help': 'Docker image to deploy', + }), + ( ('name',), { + 'type': str, + 'help': 'Application name', + }) +) + +commands = { + 'deploy': deploy, +} diff --git a/deployment.py b/deployment.py index de20da8..50b9233 100644 --- a/deployment.py +++ b/deployment.py @@ -1,123 +1,123 @@ -# vim:set sw=4 ts=4 et: -# -# Copyright (c) 2016-2017 Torchbox Ltd. -# -# Permission is granted to anyone to use this software for any purpose, -# including commercial applications, and to alter it and redistribute it -# freely. This software is provided 'as-is', without any express or implied -# warranty. - - -import json, kubernetes - -import kubeutil - -# get_deployment: return the named deployment. -def get_deployment(namespace, name): - api_client = kubeutil.get_client() - - # We can't use the normal client API here because it returns Python objects - # that can't be converted back into JSON. Instead, fetch the JSON by hand. - resource_path = ('/apis/extensions/v1beta1/namespaces/' - + namespace - + '/deployments/' - + name) - - header_params = {} - header_params['Accept'] = api_client.select_header_accept(['application/json']) - header_params['Content-Type'] = api_client.select_header_content_type(['*/*']) - header_params.update(kubeutil.config.api_key) - - (resp, code, header) = api_client.call_api( - resource_path, 'GET', {}, {}, header_params, None, [], _preload_content=False) - dp = json.loads(resp.data.decode('utf-8')) - - return dp - -# get_replicasets: return all the active replicasets for a deployment. -# old replicasets (with zero replicas) are not included. -def get_replicasets(dp): - ret = [] - - api_client = kubeutil.get_client() - - # We can't use the normal client API here because it returns Python objects - # that can't be converted back into JSON. Instead, fetch the JSON by hand. - resource_path = ('/apis/extensions/v1beta1/namespaces/' - + dp['metadata']['namespace'] - + '/replicasets') - - header_params = {} - header_params['Accept'] = api_client.select_header_accept(['application/json']) - header_params['Content-Type'] = api_client.select_header_content_type(['*/*']) - header_params.update(kubeutil.config.api_key) - - (resp, code, header) = api_client.call_api( - resource_path, 'GET', {}, {}, header_params, None, [], _preload_content=False) - - rslist = json.loads(resp.data.decode('utf-8')) - - for rs in rslist['items']: - md = rs['metadata'] - - # Check if this RS is owned by the correct deployment. - if 'ownerReferences' not in md: - continue - has_owner = False - for owner in md['ownerReferences']: - if owner['kind'] == 'Deployment' and owner['name'] == dp['metadata']['name']: - has_owner = True - break - if not has_owner: - continue - - if rs['spec']['replicas'] == 0: - continue - - ret.append(rs) - - return ret - - -# get_rs_pods: get all the pods for a replicaset. -def get_rs_pods(rs): - ret = [] - - api_client = kubeutil.get_client() - - # We can't use the normal client API here because it returns Python objects - # that can't be converted back into JSON. Instead, fetch the JSON by hand. - resource_path = ('/api/v1/namespaces/' - + rs['metadata']['namespace'] - + '/pods') - - header_params = {} - header_params['Accept'] = api_client.select_header_accept(['application/json']) - header_params['Content-Type'] = api_client.select_header_content_type(['*/*']) - header_params.update(kubeutil.config.api_key) - - (resp, code, header) = api_client.call_api( - resource_path, 'GET', {}, {}, header_params, None, [], _preload_content=False) - - podlist = json.loads(resp.data.decode('utf-8')) - for pod in podlist['items']: - md = pod['metadata'] - if 'ownerReferences' not in md: - continue - - has_owner = False - - for owner in md['ownerReferences']: - if owner['kind'] != 'ReplicaSet': - continue - if owner['name'] != rs['metadata']['name']: - continue - has_owner = True - break - - if not has_owner: - continue - - ret.append(pod) - - return ret +# vim:set sw=4 ts=4 et: +# +# Copyright (c) 2016-2017 Torchbox Ltd. +# +# Permission is granted to anyone to use this software for any purpose, +# including commercial applications, and to alter it and redistribute it +# freely. This software is provided 'as-is', without any express or implied +# warranty. + + +import json, kubernetes + +import kubeutil + +# get_deployment: return the named deployment. +def get_deployment(namespace, name): + api_client = kubeutil.get_client() + + # We can't use the normal client API here because it returns Python objects + # that can't be converted back into JSON. Instead, fetch the JSON by hand. + resource_path = ('/apis/extensions/v1beta1/namespaces/' + + namespace + + '/deployments/' + + name) + + header_params = {} + header_params['Accept'] = api_client.select_header_accept(['application/json']) + header_params['Content-Type'] = api_client.select_header_content_type(['*/*']) + header_params.update(kubeutil.config.api_key) + + (resp, code, header) = api_client.call_api( + resource_path, 'GET', {}, {}, header_params, None, [], _preload_content=False) + dp = json.loads(resp.data.decode('utf-8')) + + return dp + +# get_replicasets: return all the active replicasets for a deployment. +# old replicasets (with zero replicas) are not included. +def get_replicasets(dp): + ret = [] + + api_client = kubeutil.get_client() + + # We can't use the normal client API here because it returns Python objects + # that can't be converted back into JSON. Instead, fetch the JSON by hand. + resource_path = ('/apis/extensions/v1beta1/namespaces/' + + dp['metadata']['namespace'] + + '/replicasets') + + header_params = {} + header_params['Accept'] = api_client.select_header_accept(['application/json']) + header_params['Content-Type'] = api_client.select_header_content_type(['*/*']) + header_params.update(kubeutil.config.api_key) + + (resp, code, header) = api_client.call_api( + resource_path, 'GET', {}, {}, header_params, None, [], _preload_content=False) + + rslist = json.loads(resp.data.decode('utf-8')) + + for rs in rslist['items']: + md = rs['metadata'] + + # Check if this RS is owned by the correct deployment. + if 'ownerReferences' not in md: + continue + has_owner = False + for owner in md['ownerReferences']: + if owner['kind'] == 'Deployment' and owner['name'] == dp['metadata']['name']: + has_owner = True + break + if not has_owner: + continue + + if rs['spec']['replicas'] == 0: + continue + + ret.append(rs) + + return ret + + +# get_rs_pods: get all the pods for a replicaset. +def get_rs_pods(rs): + ret = [] + + api_client = kubeutil.get_client() + + # We can't use the normal client API here because it returns Python objects + # that can't be converted back into JSON. Instead, fetch the JSON by hand. + resource_path = ('/api/v1/namespaces/' + + rs['metadata']['namespace'] + + '/pods') + + header_params = {} + header_params['Accept'] = api_client.select_header_accept(['application/json']) + header_params['Content-Type'] = api_client.select_header_content_type(['*/*']) + header_params.update(kubeutil.config.api_key) + + (resp, code, header) = api_client.call_api( + resource_path, 'GET', {}, {}, header_params, None, [], _preload_content=False) + + podlist = json.loads(resp.data.decode('utf-8')) + for pod in podlist['items']: + md = pod['metadata'] + if 'ownerReferences' not in md: + continue + + has_owner = False + + for owner in md['ownerReferences']: + if owner['kind'] != 'ReplicaSet': + continue + if owner['name'] != rs['metadata']['name']: + continue + has_owner = True + break + + if not has_owner: + continue + + ret.append(pod) + + return ret diff --git a/kubeutil.py b/kubeutil.py index 62c6f42..4ae569e 100644 --- a/kubeutil.py +++ b/kubeutil.py @@ -1,50 +1,49 @@ -# vim:set sw=4 ts=4 et: -# -# Copyright (c) 2016-2017 Torchbox Ltd. -# -# Permission is granted to anyone to use this software for any purpose, -# including commercial applications, and to alter it and redistribute it -# freely. This software is provided 'as-is', without any express or implied -# warranty. - - -from sys import stdout, stderr, exit -import kubernetes, json, urllib3 - -config = kubernetes.client.Configuration() - -# configure: set configuration based on args. -def configure(args): - try: - kubernetes.config.kube_config.load_kube_config(client_configuration=config) - except: - stderr.write("warning: could not load kubeconfig\n") - args.server = 'http://localhost:8080' - - if args.server: - config.host = args.server - if args.token: - print(args.token) - config.api_key['authorization'] = "bearer " + args.token - if args.ca_certificate: - config.ssl_ca_cert = args.ca_certificate - -# get_client: return a Kubernetes client. -def get_client(): - client = kubernetes.client.ApiClient(config=config) - return client - -# get_error: try to extract a printable error message from an exception. -def get_error(exc): - if isinstance(exc, kubernetes.client.rest.ApiException): - try: - body = exc.body.decode('utf-8') - d = json.loads(body) - return d['message'] - except: - return exc.reason - - if isinstance(exc, urllib3.exceptions.HTTPError): - return exc.args[0] - - return str(exc) +# vim:set sw=4 ts=4 et: +# +# Copyright (c) 2016-2017 Torchbox Ltd. +# +# Permission is granted to anyone to use this software for any purpose, +# including commercial applications, and to alter it and redistribute it +# freely. This software is provided 'as-is', without any express or implied +# warranty. + + +from sys import stdout, stderr, exit +import kubernetes, json, urllib3 + +config = kubernetes.client.Configuration() + +# configure: set configuration based on args. +def configure(args): + try: + kubernetes.config.kube_config.load_kube_config(client_configuration=config) + except: + stderr.write("warning: could not load kubeconfig\n") + args.server = 'http://localhost:8080' + + if args.server: + config.host = args.server + if args.token: + config.api_key['authorization'] = "bearer " + args.token + if args.ca_certificate: + config.ssl_ca_cert = args.ca_certificate + +# get_client: return a Kubernetes client. +def get_client(): + client = kubernetes.client.ApiClient(config=config) + return client + +# get_error: try to extract a printable error message from an exception. +def get_error(exc): + if isinstance(exc, kubernetes.client.rest.ApiException): + try: + body = exc.body.decode('utf-8') + d = json.loads(body) + return d['message'] + except: + return exc.reason + + if isinstance(exc, urllib3.exceptions.HTTPError): + return exc.args[0] + + return str(exc) diff --git a/status.py b/status.py index df5a7b2..99f3768 100644 --- a/status.py +++ b/status.py @@ -1,114 +1,120 @@ -# vim:set sw=4 ts=4 et: -# -# Copyright (c) 2016-2017 Torchbox Ltd. -# -# Permission is granted to anyone to use this software for any purpose, -# including commercial applications, and to alter it and redistribute it -# freely. This software is provided 'as-is', without any express or implied -# warranty. - - -import json, kubernetes -from sys import stdout, stderr - -import deployment, kubeutil - -# status: print the overall status of a deployment and any errors. -def status(args): - try: - dp = deployment.get_deployment(args.namespace, args.name) - replicasets = deployment.get_replicasets(dp) - except Exception as e: - stderr.write('cannot load deployment {0}: {1}\n'.format( - args.name, kubeutil.get_error(e))) - exit(1) - - try: - generation = dp['metadata']['annotations']['deployment.kubernetes.io/revision'] - except KeyError: - generation = '?' - - stdout.write( - "deployment {0}: {1} replica(s), current generation {2}\n".format( - dp['metadata']['name'], - dp['spec']['replicas'], - generation, - )) - - stdout.write(" {0} active replica sets (* = current, ! = error):\n".format(len(replicasets))) - for rs in replicasets: - pods = deployment.get_rs_pods(rs) - error = ' ' - - try: - revision = rs['metadata']['annotations']['deployment.kubernetes.io/revision'] - except KeyError: - revision = '?' - - if str(revision) == str(generation): - active = '*' - else: - active = ' ' - - try: - nready = rs['status']['readyReplicas'] - except KeyError: - error = '!' - nready = 0 - - errors = [] - try: - for condition in rs['status']['conditions']: - if condition['type'] == 'ReplicaFailure' and condition['status'] == 'True': - errors.append(condition['message']) - error = '!' - except KeyError: - pass - - stdout.write(" {4}{5}generation {1} ({0}): {2} replicas configured, {3} ready\n".format( - rs['metadata']['name'], - revision, - rs['spec']['replicas'], - nready, - active, - error - )) - - for error in errors: - stdout.write(" {0}\n".format(error)) - - for pod in pods: - try: - phase = pod['status']['phase'] - except KeyError: - phase = '?' - - stdout.write(" pod {0}: {1}\n".format( - pod['metadata']['name'], - phase, - )) - - if 'status' in pod and 'containerStatuses' in pod['status']: - for cs in pod['status']['containerStatuses']: - if 'waiting' in cs['state']: - try: - message = cs['state']['waiting']['message'] - except KeyError: - message = '(no reason)' - - stdout.write(" {0}: {1}\n".format( - cs['state']['waiting']['reason'], - message, - )) - -status.help = "show deployment status" -status.arguments = ( - ( ('name',), { - 'type': str, - 'help': 'deployment name', - }), -) - -commands = { - 'status': status -} +# vim:set sw=4 ts=4 et: +# +# Copyright (c) 2016-2017 Torchbox Ltd. +# +# Permission is granted to anyone to use this software for any purpose, +# including commercial applications, and to alter it and redistribute it +# freely. This software is provided 'as-is', without any express or implied +# warranty. + + +import json, kubernetes +from sys import stdout, stderr + +import deployment, kubeutil + +# status: print the overall status of a deployment and any errors. +def status(args): + try: + dp = deployment.get_deployment(args.namespace, args.name) + replicasets = deployment.get_replicasets(dp) + except Exception as e: + stderr.write('cannot load deployment {0}: {1}\n'.format( + args.name, kubeutil.get_error(e))) + exit(1) + + try: + generation = dp['metadata']['annotations']['deployment.kubernetes.io/revision'] + except KeyError: + generation = '?' + + stdout.write( + "deployment {0}: {1} replica(s), current generation {2}\n".format( + dp['metadata']['name'], + dp['spec']['replicas'], + generation, + )) + + stdout.write(" {0} active replica sets (* = current, ! = error):\n".format(len(replicasets))) + for rs in replicasets: + pods = deployment.get_rs_pods(rs) + error = ' ' + + try: + revision = rs['metadata']['annotations']['deployment.kubernetes.io/revision'] + except KeyError: + revision = '?' + + if str(revision) == str(generation): + active = '*' + else: + active = ' ' + + try: + nready = rs['status']['readyReplicas'] + except KeyError: + error = '!' + nready = 0 + + errors = [] + try: + for condition in rs['status']['conditions']: + if condition['type'] == 'ReplicaFailure' and condition['status'] == 'True': + errors.append(condition['message']) + error = '!' + except KeyError: + pass + + stdout.write(" {4}{5}generation {1} ({0}): {2} replicas configured, {3} ready\n".format( + rs['metadata']['name'], + revision, + rs['spec']['replicas'], + nready, + active, + error + )) + + for container in rs['spec']['template']['spec']['containers']: + stdout.write(" container {0}: image {1}\n".format( + container['name'], + container['image'], + )) + + for error in errors: + stdout.write(" {0}\n".format(error)) + + for pod in pods: + try: + phase = pod['status']['phase'] + except KeyError: + phase = '?' + + stdout.write(" pod {0}: {1}\n".format( + pod['metadata']['name'], + phase, + )) + + if 'status' in pod and 'containerStatuses' in pod['status']: + for cs in pod['status']['containerStatuses']: + if 'waiting' in cs['state']: + try: + message = cs['state']['waiting']['message'] + except KeyError: + message = '(no reason)' + + stdout.write(" {0}: {1}\n".format( + cs['state']['waiting']['reason'], + message, + )) + +status.help = "show deployment status" +status.arguments = ( + ( ('name',), { + 'type': str, + 'help': 'deployment name', + }), +) + +commands = { + 'status': status +}