├── .github └── workflows │ ├── publish_pypi.yml │ └── run_tests.yml ├── .gitignore ├── CHANGELOG.md ├── LICENSE.md ├── MANIFEST.in ├── README.md ├── kubetools ├── __init__.py ├── cli │ ├── __init__.py │ ├── __main__.py │ ├── deploy.py │ ├── generate_config.py │ ├── git_utils.py │ ├── push_image.py │ ├── server_util.py │ └── show.py ├── config.py ├── constants.py ├── deploy │ ├── __init__.py │ ├── build.py │ ├── commands │ │ ├── __init__.py │ │ ├── cleanup.py │ │ ├── deploy.py │ │ ├── remove.py │ │ └── restart.py │ ├── image.py │ └── util.py ├── dev │ ├── __init__.py │ ├── __main__.py │ ├── backend.py │ ├── backends │ │ ├── __init__.py │ │ └── docker_compose │ │ │ ├── __init__.py │ │ │ ├── config.py │ │ │ └── docker_util.py │ ├── container.py │ ├── environment.py │ ├── logs.py │ ├── process_util.py │ └── scripts.py ├── exceptions.py ├── kubernetes │ ├── __init__.py │ ├── api.py │ └── config │ │ ├── __init__.py │ │ ├── container.py │ │ ├── cronjob.py │ │ ├── deployment.py │ │ ├── job.py │ │ ├── namespace.py │ │ ├── service.py │ │ ├── util.py │ │ └── volume.py ├── log.py ├── main.py └── settings.py ├── setup.cfg ├── setup.py └── tests ├── __init__.py ├── configs ├── basic_app │ ├── k8s_cronjobs.yml │ ├── k8s_deployments.yml │ ├── k8s_jobs.yml │ ├── k8s_services.yml │ └── kubetools.yml ├── dependencies │ ├── k8s_deployments.yml │ ├── k8s_services.yml │ └── kubetools.yml ├── dev_overrides │ ├── k8s_deployments.yml │ ├── k8s_services.yml │ └── kubetools.yml ├── docker_registry │ ├── Dockerfile │ ├── k8s_deployments.yml │ └── kubetools.yml ├── k8s_container_passthrough │ ├── k8s_deployments.yml │ ├── k8s_services.yml │ └── kubetools.yml ├── k8s_cronjobs_beta_api_version │ ├── k8s_cronjobs_beta.yml │ └── kubetools.yml ├── k8s_with_mounted_secrets │ ├── k8s_cronjobs.yml │ ├── k8s_deployments.yml │ ├── k8s_jobs.yml │ └── kubetools.yml ├── k8s_with_node_selectors │ ├── k8s_cronjobs.yml │ ├── k8s_deployments.yml │ ├── k8s_jobs.yml │ └── kubetools.yml ├── ktd-compose-bug │ ├── Dockerfile │ └── kubetools.yml └── multiple_deployments │ ├── k8s_deployments.yml │ ├── k8s_services.yml │ └── kubetools.yml ├── conftest.py ├── test_build_compose_command.py ├── test_config_generation.py └── test_make_job_config.py /.github/workflows/publish_pypi.yml: -------------------------------------------------------------------------------- 1 | on: 2 | release: 3 | types: [published] 4 | 5 | jobs: 6 | publish-to-pypi: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v4 10 | - name: Set up Python 11 | uses: actions/setup-python@v5 12 | with: 13 | python-version: '3.11' 14 | - name: Install build dependencies 15 | run: pip install build 16 | - name: Build package 17 | run: python -m build 18 | - name: Publish Package 19 | uses: pypa/gh-action-pypi-publish@v1.6.4 20 | with: 21 | password: ${{ secrets.PYPI_API_TOKEN }} 22 | -------------------------------------------------------------------------------- /.github/workflows/run_tests.yml: -------------------------------------------------------------------------------- 1 | name: Run tests 2 | 3 | on: [push] 4 | 5 | jobs: 6 | tests: 7 | strategy: 8 | matrix: 9 | os: [ubuntu-20.04, ubuntu-latest] 10 | python-version: ['3.6', '3.7', '3.8', '3.9', '3.10', '3.11', '3.12'] 11 | exclude: 12 | # Python 3.6 is not available in GitHub Actions Ubuntu 22.04 13 | - os: ubuntu-latest 14 | python-version: '3.6' 15 | runs-on: ${{ matrix.os }} 16 | steps: 17 | - uses: actions/checkout@v4 18 | - name: Set up Python 19 | uses: actions/setup-python@v5 20 | with: 21 | python-version: ${{ matrix.python-version }} 22 | # See https://github.com/yaml/pyyaml/issues/601 23 | - name: Add cython constraint 24 | run: 'echo "cython<3" > /tmp/constraint.txt' 25 | - name: Install package 26 | run: PIP_CONSTRAINT=/tmp/constraint.txt python -m pip install .[dev] 27 | - name: Linting 28 | # Python 3.12 bug breaks flake8: https://github.com/PyCQA/flake8/issues/1905 29 | if: matrix.python-version != '3.12' 30 | run: "flake8" 31 | - name: Unit tests 32 | run: "pytest --cov" 33 | 34 | all-tests: 35 | # Single step that will succeed iff all test steps succeed. To used for branch protection 36 | runs-on: ubuntu-latest 37 | needs: [tests] 38 | if: always() 39 | steps: 40 | - name: Failed tests 41 | if: ${{ contains(needs.*.result, 'failure') }} 42 | run: exit 1 43 | - name: Cancelled tests 44 | if: ${{ contains(needs.*.result, 'cancelled') }} 45 | run: exit 1 46 | - name: Skipped tests 47 | if: ${{ contains(needs.*.result, 'skipped') }} 48 | run: exit 1 49 | - name: Successful tests 50 | if: ${{ !(contains(needs.*.result, 'failure')) && !(contains(needs.*.result, 'cancelled')) && !(contains(needs.*.result, 'skipped')) }} 51 | run: exit 0 52 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | *.egg-info 3 | *.pyc 4 | dist/ 5 | build/ 6 | 7 | /docs/_html 8 | /docs/build 9 | /.coverage 10 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ### Unreleased 4 | 5 | # v13.14.2 6 | - Collect additional parameters provided to kubetools cronjob spec and attach to k8s cronjob spec 7 | 8 | # v13.14.1 9 | - Add annotations and labels options to resources defined in kubetools.yaml file 10 | 11 | # v13.14.0 12 | - Fix docker-compose conflict when kubetools commands are called without activating their venv 13 | - Add Python 3.12 to supported versions, albeit without Flake8 because of CPython bug 14 | - Upgrade GitHub actions workflow to deal with deprecation warnings 15 | 16 | # v13.13.1 17 | - Add nodeSelector config to kubetools file 18 | - Fix bug where `config` command was not printing the actual `k8s` configs used by `deploy` because it did not take into account the kube-context, whether default or given with `--context`. 19 | 20 | # v13.13.0 21 | - Cython 3.0 release is preventing this package to be released. A constraint of `cython<3` needs to be added to install this 22 | - Add ability to use secrets in "migration" jobs 23 | 24 | # v13.12.1 25 | - De-couple serviceAccountName and secrets 26 | 27 | # v13.12.0 28 | - Add support for docker build arguments 29 | 30 | # v13.11.0 31 | - Add support to specify SecretProviderClass 32 | - Add support to specify ServiceAccount 33 | 34 | # v13.10.0 35 | - Add ability to provide a custom script to check the presence of the image on the target registry 36 | - Re-work checking for CronJob API version compatibility against the target k8s cluster 37 | - Fix crash where we could try to delete `default` namespace, which is forbidden by k8s 38 | - Fix crash where we tried to delete a namespace that doesn't exist 39 | - Fix crash trying to gather annotations from k8s resource that can't have any 40 | 41 | # v13.9.6 42 | - Fix default registry option to not override registry for images specified 43 | directly, so they keep using the docker server default registry (dockerhub) 44 | 45 | # v13.9.5.1 46 | - No functional change, this is just validating the change of CI to Github Actions 47 | 48 | # v13.9.5 49 | DO NOT USE: This has a bug fixed in v13.9.6 50 | - Allow adding a default registry in command line instead of 51 | specifying the registry in kubetools.yml file. 52 | 53 | # v13.9.4 54 | - Added support job time to live 55 | 56 | # v13.9.3 57 | - Add support for CronJob api version `batch/v1beta1` 58 | 59 | # v13.9.2 60 | - Ensure Resources within Job containers are parsed correctly, optimise 61 | passing resources through `job_spec` 62 | 63 | # v13.9.1 64 | - Allow jobs to specify Resources within Job spec 65 | 66 | # v13.9.0 67 | - Add support for creating CronJob objects in k8s 68 | - Fix bug where 2 concurrent `ktd` commands could create a duplicated `dev` network 69 | - Small optimisation for checking presence of image in registry 70 | 71 | # v13.8.1 72 | - Pin docker-compose as v2 breaks docker naming convention 73 | 74 | # v13.8.0 75 | - Allow customisation of naming in job configuration 76 | 77 | # v13.7.5 78 | - Ensure command format is correct for full command strings 79 | 80 | # v13.7.4 81 | - Avoid shell escaping full command strings 82 | 83 | # v13.7.3 84 | - Add option to cleanup command for cleaning up completed jobs 85 | 86 | # v13.6.3 87 | - Shell-escape command in run container entrypoint 88 | 89 | # v13.6.2 90 | - Only build/push images relevant to relevant container contexts during a deploy 91 | 92 | # v13.6.1 93 | 94 | - Convert timeout envvar to int 95 | 96 | # v13.6.0 97 | 98 | - Add envvar to set timeouts for waiting for k8s funcs to complete 99 | 100 | # v13.5.1 101 | 102 | - Fix defaulting of propagation_policy param in k8s api job deletion 103 | 104 | # v13.5.0 105 | 106 | - Update push command to allow additional tags for image 107 | 108 | # v13.4.1 109 | 110 | - Fix regression from 13.0.0 causing upgrades and tests to fail when using an image with an entrypoint, or specifying 111 | one with "command" in the config 112 | 113 | # v13.4.0 114 | 115 | - Expose propagation_policy param in k8s api job deletion 116 | 117 | # v13.3.0 118 | 119 | - Add cli command for building and pushing app images to docker repo 120 | 121 | # v13.2.0 122 | 123 | - Add function to kubernetes api to list running jobs 124 | 125 | # v13.1.0 126 | 127 | - Update kubernetes api create job with non-blocking option 128 | 129 | # v13.0.0 130 | 131 | - Translate k8s-style `command`/`args` options to docker[-compose]-style `entrypoint`/`command` 132 | 133 | This is breaking backwards compatibility for any project using `entrypoint`/`command` options in a `dev` section. 134 | These need to be renamed resp. `command`/`args` to be picked up. The bright side of this is that these cases 135 | will now only require 1 copy of the options when the same values were just repeated under `dev` to get them to 136 | work with `ktd` (`docker-compose`) 137 | 138 | # v12.2.2 139 | 140 | - Ignore pods with no owner metadata when restarting a service 141 | 142 | # v12.2.1 143 | 144 | - Fix call to check if service exists 145 | 146 | # v12.2.0 147 | 148 | - Remove Kubernetes jobs after they complete successfully 149 | 150 | # v12.1.0 151 | 152 | - Allow to run upgrades and tests on any container using a containerContext. Especially allows containers that use a released image rather than a Dockerfile 153 | 154 | # v12.0.3 155 | 156 | - Ensure docker compose configs are always generated relative to the kubetools config file 157 | - Fix use of `--debug` when using `ktd` 158 | 159 | # v12.0.2 160 | 161 | - Fix issue when creating new deployments alongside existing ones 162 | - Fix `kubetools restart` 163 | 164 | # v12.0.1 165 | 166 | - Fix cleanup failure in removing namespaces which contain replica sets 167 | 168 | # v12.0 169 | 170 | Breaking note: this change passes all non Kubetools specific container config through to the generated K8s container spec. Any invalid/unused data would have previously been ignored will now be passed to K8s and throw an error. 171 | 172 | - Pass any non-Kubetools specific container config through to K8s container spec 173 | - Implicitly create target namespace if it does not exist 174 | - The `--cleanup` flag will now remove the target namespace if empty 175 | - Add `-f` / `--force` argument to `kubetools restart` 176 | - Add `-e KEY=VALUE` flag to inject environment variables when using `kubetools deploy` 177 | - Replace `yaml.load` with `yaml.safe_load` to avoid CLI warning 178 | - Fix issues with listing objects in `kubetools restart` 179 | - Fix the test condition for upgrades 180 | 181 | 182 | # v11.1.1 183 | 184 | - Fix `-f` / `--file` handling NoneType attribute error (introduced in v11.1) 185 | 186 | # v11.1 187 | 188 | - Add `-f`/`--file` argument to specify custom `kubetools.yml` file location for `kubetools deploy` and `kubetools config` 189 | - Add `--ignore-git-changes` argument to skip git check for uncommitted files for `kubetools deploy` 190 | 191 | # v11.0 192 | 193 | This release follows a major overhaul of Kubetools - most notably moving all of the server/build logic down into this library (to deprecate/remove the server). The `kubetools` command can now deploy direct to Kubernetes. 194 | 195 | - **Migration to client-only** (no more server), meaning new/changed commands: 196 | + `kubetools deploy [...]` 197 | + `kubetools remove [...]` 198 | + `kubetools restart ` 199 | + `kubeotols cleanup ` 200 | + `kubetools show []` 201 | + Commands removed: 202 | * `kubetools wait` 203 | * `kubetools list *` 204 | * `kubetools job *` 205 | - **Remove Python 2 support** 206 | - Uses `kubeconfig` and Kubernetes contexts 207 | - Correctly uses Kubernetes deployment objects for proper rolling updates 208 | + This also adds rollback compatability 209 | - Improved replica control in `kubetools.yml`: 210 | + `deployments.NAME.minReplicas` (max exists already) 211 | + `deployments.NAME.replicaMultiplier` 212 | - Support deployment strategy in `kubetools.yml`: 213 | + `deployments.NAME.updateStrategy` -> K8s `Deployment.spec.strategy` 214 | - Add `--annotation` argument to `kubetools deploy` 215 | - Add `--shell` argument to `ktd enter` 216 | 217 | 218 | # v10.2 219 | - Always ensure deployment names start with the project name 220 | 221 | # v10.1.1 222 | - Fix Python 2 compatability (broken in v10) 223 | 224 | # v10.1 225 | - Add `KTD_ENV` environment variable in `docker-compose` dev backend 226 | - Print out all injected environment variables in `docker-compose` dev backend 227 | - Replace `envars` with `envvars` everywhere (w/backwards compatability) 228 | 229 | # v10.0 230 | - Fix issue where stdout from a kubetools dev exception would not be formatted properly 231 | - Add "dev backends" support for future work (alternatives to `docker-compose`) 232 | - Add kubernetes config generation from the kubetools server 233 | * adds `kubetools configs` command to generate/view them by hand 234 | 235 | 236 | # v9.1.1 237 | - Add ability to define number of retries on readinessProbe 238 | 239 | # v9.1 240 | - Add `ktd restart` command 241 | - Search for `kubetools.yml` "up" the directory tree, similar to `.git` 242 | 243 | # v9.0.3 244 | - Bump min click version to 7 245 | 246 | # v9.0.2 247 | - Fix clashes between two projects starting with the same name 248 | 249 | # v9.0.1 250 | - Version bump for pypi release 251 | 252 | # v9.0.0 253 | - Initial public release 254 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright (C) 2021 EDITED 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include CHANGELOG.md 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![Build Status](https://github.com/EDITD/kubetools/actions/workflows/run_tests.yml/badge.svg?branch=master) 2 | [![Pypi Version](https://img.shields.io/pypi/v/kubetools.svg)](https://pypi.org/project/kubetools/) 3 | [![Python Versions](https://img.shields.io/pypi/pyversions/kubetools.svg)](https://pypi.org/project/kubetools/) 4 | 5 | # Kubetools 6 | 7 | Kubetools is a tool and processes for developing and deploying microservices to Kubernetes. Say that: 8 | 9 | + You have **a bunch of repositories, each containing one or more microservices** 10 | + You want to **deploy each of these microservices into one or more Kubernetes clusters** 11 | + You want a **single configuration file per project** (repository) 12 | 13 | And you would like: 14 | 15 | + **Development setup should be near-instant** - and _not_ require specific K8s knowledge 16 | + **Deployment to production can be automated** - and integrated with existing CI tooling 17 | 18 | Kubetools provides the tooling required to achieve this, by way of two CLI tools: 19 | 20 | + **`ktd`**: generates _100% local_ development environments using Docker/docker-compose 21 | + **`kubetools`**: deploys projects to Kubernetes, handling any changes/jobs as required 22 | 23 | Both of these use a single configuration file, `kubetools.yml`, for example a basic `django` app: 24 | 25 | ```yaml 26 | name: my-app 27 | 28 | containerContexts: 29 | django_app: 30 | build: 31 | registry: my-registry.net 32 | dockerfile: Dockerfile 33 | dev: 34 | volumes: 35 | - ./:/opt/django_app 36 | 37 | upgrades: 38 | - name: Upgrade database 39 | containerContext: django_app 40 | command: [./manage.py, migrate, --noinput] 41 | 42 | tests: 43 | - name: Nosetests 44 | containerContext: django_app 45 | command: [./manage.py, test] 46 | 47 | deployments: 48 | my-app-webserver: 49 | annotations: 50 | imageregistry: "https://hub.docker.com/" 51 | labels: 52 | app.kubernetes.io/name: my-app-webserver 53 | serviceAccountName: webserver 54 | secrets: 55 | secret-volume: 56 | mountPath: /mnt/secrets-store 57 | secretProviderClass: webserver-secrets 58 | containers: 59 | uwsgi: 60 | command: [uwsgi, --ini, /etc/uwsgi.conf] 61 | containerContext: django_app 62 | ports: 63 | - 80 64 | dev: 65 | command: [./manage.py, runserver, '0.0.0.0:80'] 66 | 67 | dependencies: 68 | mariadb: 69 | containers: 70 | mariadb: 71 | image: mariadb:v10.4.1 72 | 73 | cronjobs: 74 | my-cronjob: 75 | batch-api-version: 'batch/v1beta1' # Must add if k8s version < 1.21+ 76 | schedule: "*/1 * * * *" 77 | concurrency_policy: "Replace" 78 | containers: 79 | hello: 80 | image: busybox 81 | command: [/bin/sh, -c, date; echo Hello from the Kubernetes cluster] 82 | ``` 83 | 84 | With this in your current directory, you can now: 85 | 86 | ```sh 87 | # Bring up a local development environment using docker-compose 88 | ktd up 89 | 90 | # Deploy the project to a Kubernetes namespace 91 | kubetools deploy my-namespace 92 | ``` 93 | 94 | ## Installing 95 | 96 | ```sh 97 | pip install kubetools 98 | ``` 99 | 100 | **NOTE**: Since Cython 3.0 was released, the installation of `kubetools` dependencies will fail 101 | due to compatibility issues between Cython 3 and PyYaml (see 102 | [this issue](https://github.com/yaml/pyyaml/issues/601)). This can be worked around for example 103 | with `pip` by using a "constraints" file containing `cython<3`. 104 | 105 | ## Configuration 106 | Users can configure some aspects of `kubetools`. The configuration folder location depends on the 107 | operating system of the user. See the 108 | [Click documentation](https://click.palletsprojects.com/en/8.1.x/api/#click.get_app_dir) 109 | to find the appropriate one for you. Note that we use the "POSIX" version (for example 110 | `~/.kubetools/` on Unix systems). 111 | * `kubetools.conf` contains key-value settings, see [`settings.py`](kubetools/settings.py) for the 112 | possible settings and their meaning. 113 | * `scripts/` can contain scripts to be made available to `ktd script` command 114 | 115 | ## Developing 116 | 117 | Install the package in editable mode, with the dev extras: 118 | 119 | ```sh 120 | pip install -e .[dev] 121 | ``` 122 | 123 | ## Local deployment testing 124 | 125 | For deployment testing, you will need a kubernetes cluster and a docker registry. You can get both 126 | easily using `minikube`: 127 | ```shell 128 | minikube start --addons registry --insecure-registry ${MINIKUBE_IP}:5000 129 | ``` 130 | Then you can deploy to that environment: 131 | ```shell 132 | kubetools --context minikube deploy --default-registry ${MINIKUBE_IP}:5000 default . 133 | ``` 134 | 135 | `MINIKUBE_IP` value can vary depending on your local environment. The easiest way to get the correct 136 | value is to start minikube once then reset it: 137 | ```shell 138 | minikube start 139 | MINIKUBE_IP=$(minikube ip) 140 | minikube delete 141 | ... 142 | ``` 143 | 144 | ## Releasing (admins/maintainers only) 145 | * Update [CHANGELOG](CHANGELOG.md) to add new version and document it 146 | * In GitHub, create a new release 147 | * Title the release `v` (for example `v1.2.3`) 148 | * Select to create a new tag `v` against `master` branch 149 | * Copy changes in the release from `CHANGELOG.md` into the release description 150 | * [GitHub Actions](https://github.com/EDITD/kubetools/actions) will package the release and 151 | publish it to [Pypi](https://pypi.org/project/kubetools/) 152 | 153 | ## Mounting K8s Secrets 154 | We assume that `ServiceAccount` and `SecretProviderClass` are already created (if needed), before deploying the project with kubetools. 155 | 156 | ## Docker build args 157 | `kubetools` now supports passing values for `ARG` parameters used in Dockerfiles, using 158 | `--build-args`. This has a couple of caveats though: 159 | * it is NOT supported in `ktd`. A workaround for this is to use the default value of the `ARG` 160 | instruction. 161 | * this doesn't affect the image tag pushed to the docker registry, which is based only on the git 162 | commit hash. This means that these arguments cannot be used to generate multiple images from the 163 | same Dockerfile. So their main usage should be to pass secrets that should not be recorded in the 164 | git repository but are needed at build time, to access external resources for example. 165 | * these values could be recorded in the docker image layer history. To prevent leaking secrets, you 166 | should consider using multi-stage builds where the secrets are only used in a "builder" image. 167 | -------------------------------------------------------------------------------- /kubetools/__init__.py: -------------------------------------------------------------------------------- 1 | from pkg_resources import get_distribution 2 | 3 | __version__ = get_distribution('kubetools').version 4 | -------------------------------------------------------------------------------- /kubetools/cli/__init__.py: -------------------------------------------------------------------------------- 1 | import click 2 | 3 | from kubernetes import config 4 | 5 | from kubetools import __version__ 6 | from kubetools.log import setup_logging 7 | from kubetools.settings import get_settings 8 | 9 | 10 | class SpecialHelpOrder(click.Group): 11 | def __init__(self, *args, **kwargs): 12 | self.help_priorities = {} 13 | super(SpecialHelpOrder, self).__init__(*args, **kwargs) 14 | 15 | def list_commands(self, ctx): 16 | ''' 17 | Reorder the list of commands when listing the help. 18 | ''' 19 | commands = super(SpecialHelpOrder, self).list_commands(ctx) 20 | return ( 21 | c[1] for c in sorted( 22 | (self.help_priorities.get(command, 1), command) 23 | for command in commands 24 | ) 25 | ) 26 | 27 | def group(self, *args, **kwargs): 28 | ''' 29 | Behaves the same as `click.Group.command()` except capture a priority for 30 | listing command names in help. 31 | ''' 32 | 33 | help_priority = kwargs.pop('help_priority', 1) 34 | help_priorities = self.help_priorities 35 | 36 | def decorator(f): 37 | cmd = super(SpecialHelpOrder, self).group(*args, **kwargs)(f) 38 | help_priorities[cmd.name] = help_priority 39 | return cmd 40 | 41 | return decorator 42 | 43 | def command(self, *args, **kwargs): 44 | ''' 45 | Behaves the same as `click.Group.command()` except capture a priority for 46 | listing command names in help. 47 | ''' 48 | 49 | help_priority = kwargs.pop('help_priority', 1) 50 | help_priorities = self.help_priorities 51 | 52 | def decorator(f): 53 | cmd = super(SpecialHelpOrder, self).command(*args, **kwargs)(f) 54 | help_priorities[cmd.name] = help_priority 55 | return cmd 56 | 57 | return decorator 58 | 59 | 60 | def _get_context_names(): 61 | try: 62 | contexts, active_context = config.list_kube_config_contexts() 63 | except config.ConfigException as e: 64 | # The python-kubernetes library currently does not handle a missing "current context" 65 | # well at all, raising an exception. 66 | # See: https://github.com/kubernetes-client/python/issues/1193 67 | if 'Expected key current-context' in e.args[0]: 68 | raise click.ClickException(( 69 | 'No current-context set in kubeconfig! Please set this to any ' 70 | 'value using `kubectl config use-context `.' 71 | )) 72 | raise 73 | 74 | if not contexts: 75 | print('Cannot find any context in kube-config file.') 76 | return 77 | return [context['name'] for context in contexts], active_context['name'] 78 | 79 | 80 | def print_contexts(ctx, param, value): 81 | if not value: 82 | return 83 | 84 | click.echo('--> Available Kubernetes contexts:') 85 | context_names, active_context_name = _get_context_names() 86 | for name in context_names: 87 | click.echo(f' {click.style(name, bold=name == active_context_name)}') 88 | 89 | ctx.exit() 90 | 91 | 92 | def ensure_context(ctx, param, value): 93 | context_names, active_context_name = _get_context_names() 94 | 95 | if value: 96 | if value not in context_names: 97 | raise click.BadParameter(f'{value}; available contexts: {context_names}') 98 | else: 99 | click.echo(f'Using active context: {click.style(active_context_name, bold=True)}') 100 | value = active_context_name 101 | 102 | return value 103 | 104 | 105 | @click.group(cls=SpecialHelpOrder) 106 | @click.option( 107 | '--context', 108 | callback=ensure_context, 109 | envvar='KUBETOOLS_CONTEXT', 110 | help='The name of the Kubernetes context to use.', 111 | ) 112 | @click.option( 113 | '--contexts', 114 | is_flag=True, 115 | is_eager=True, 116 | callback=print_contexts, 117 | expose_value=False, 118 | help='List available Kubernetes contexts and exit.', 119 | ) 120 | @click.option('--debug', is_flag=True, help='Show debug logs.') 121 | @click.version_option(version=__version__, message='%(prog)s: v%(version)s') 122 | @click.pass_context 123 | def cli_bootstrap(ctx, context, debug): 124 | ''' 125 | Kubetools client - deploy apps to Kubernetes. 126 | ''' 127 | 128 | ctx.meta['kube_context'] = context 129 | 130 | setup_logging(debug) 131 | get_settings() 132 | -------------------------------------------------------------------------------- /kubetools/cli/__main__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | from kubetools.cli import cli_bootstrap 4 | from kubetools.main import run_cli 5 | # Import click command groups 6 | from kubetools.cli import deploy, generate_config, push_image, show # noqa: F401, I100 7 | 8 | 9 | run_cli(cli_bootstrap) 10 | -------------------------------------------------------------------------------- /kubetools/cli/generate_config.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from collections import defaultdict 4 | 5 | import click 6 | import yaml 7 | 8 | from kubetools.config import load_kubetools_config 9 | from kubetools.kubernetes.api import get_object_name 10 | from kubetools.kubernetes.config import generate_kubernetes_configs_for_project 11 | 12 | from . import cli_bootstrap 13 | 14 | 15 | yaml.Dumper.ignore_aliases = lambda *args: True 16 | 17 | FORMATTERS = { 18 | 'json': lambda d: json.dumps(d, indent=4), 19 | 'yaml': lambda d: yaml.dump(d), 20 | } 21 | 22 | 23 | @cli_bootstrap.command(help_priority=4) 24 | @click.option( 25 | '--replicas', 26 | type=int, 27 | default=1, 28 | help='Default number of replicas for each app.', 29 | ) 30 | @click.option( 31 | '-f', '--file', 32 | nargs=1, 33 | help='Specify a non-default Kubetools yml file to generate config from.', 34 | type=click.Path(exists=True), 35 | ) 36 | @click.option( 37 | '--format', 'output_format', 38 | type=click.Choice(('json', 'yaml')), 39 | default='json', 40 | help='Specify the output format', 41 | ) 42 | @click.argument( 43 | 'app_dir', 44 | type=click.Path(exists=True, file_okay=False), 45 | ) 46 | @click.option( 47 | '--default-registry', 48 | help='Default registry for apps that do not specify.', 49 | ) 50 | @click.pass_context 51 | def config(ctx, replicas, file, app_dir, output_format, default_registry): 52 | ''' 53 | Generate and write out Kubernetes configs for a project. 54 | ''' 55 | 56 | env = ctx.meta['kube_context'] 57 | kubetools_config = load_kubetools_config(app_dir, env=env, custom_config_file=file) 58 | context_to_image = defaultdict(lambda: 'IMAGE') 59 | services, deployments, jobs, cronjobs = generate_kubernetes_configs_for_project( 60 | kubetools_config, 61 | replicas=replicas, 62 | context_name_to_image=context_to_image, 63 | default_registry=default_registry, 64 | ) 65 | 66 | echo_resources(services, 'Service', output_format) 67 | echo_resources(deployments, 'Deployment', output_format) 68 | echo_resources(jobs, 'Job', output_format) 69 | echo_resources(cronjobs, 'Cronjob', output_format) 70 | 71 | 72 | def echo_resources(resources, resource_kind, output_format): 73 | formatter = FORMATTERS[output_format] 74 | for resource in resources: 75 | name = get_object_name(resource) 76 | click.echo(f'{resource_kind}: {click.style(name, bold=True)}') 77 | click.echo(formatter(resource)) 78 | click.echo() 79 | -------------------------------------------------------------------------------- /kubetools/cli/git_utils.py: -------------------------------------------------------------------------------- 1 | from os import path 2 | 3 | from kubetools.constants import ( 4 | GIT_BRANCH_ANNOTATION_KEY, 5 | GIT_COMMIT_ANNOTATION_KEY, 6 | GIT_TAG_ANNOTATION_KEY, 7 | ) 8 | from kubetools.deploy.util import run_shell_command 9 | from kubetools.exceptions import KubeBuildError 10 | 11 | 12 | def _is_git_committed(app_dir): 13 | git_status = run_shell_command( 14 | 'git', 'status', '--porcelain', 15 | cwd=app_dir, 16 | ).strip().decode() 17 | 18 | if git_status: 19 | return False 20 | return True 21 | 22 | 23 | def _get_git_info(app_dir): 24 | git_annotations = {} 25 | 26 | commit_hash = run_shell_command( 27 | 'git', 'rev-parse', '--short=7', 'HEAD', 28 | cwd=app_dir, 29 | ).strip().decode() 30 | git_annotations[GIT_COMMIT_ANNOTATION_KEY] = commit_hash 31 | 32 | branch_name = run_shell_command( 33 | 'git', 'rev-parse', '--abbrev-ref', 'HEAD', 34 | cwd=app_dir, 35 | ).strip().decode() 36 | 37 | if branch_name != 'HEAD': 38 | git_annotations[GIT_BRANCH_ANNOTATION_KEY] = branch_name 39 | 40 | try: 41 | git_tag = run_shell_command( 42 | 'git', 'tag', '--points-at', commit_hash, 43 | cwd=app_dir, 44 | ).strip().decode() 45 | except KubeBuildError: 46 | pass 47 | else: 48 | if git_tag: 49 | git_annotations[GIT_TAG_ANNOTATION_KEY] = git_tag 50 | 51 | return commit_hash, git_annotations 52 | 53 | 54 | def get_git_info(app_dir, ignore_git_changes=False): 55 | if path.exists(path.join(app_dir, '.git')): 56 | if not _is_git_committed(app_dir) and not ignore_git_changes: 57 | raise KubeBuildError(f'{app_dir} contains uncommitted changes, refusing to deploy!') 58 | return _get_git_info(app_dir) 59 | raise KubeBuildError(f'{app_dir} is not a valid git repository!') 60 | -------------------------------------------------------------------------------- /kubetools/cli/push_image.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import click 4 | 5 | from kubetools.cli import cli_bootstrap 6 | from kubetools.cli.git_utils import get_git_info 7 | from kubetools.config import load_kubetools_config 8 | from kubetools.deploy.build import Build 9 | from kubetools.deploy.image import ensure_docker_images 10 | 11 | 12 | @cli_bootstrap.command(help_priority=5) 13 | @click.option( 14 | '--default-registry', 15 | help='Default registry for apps that do not specify.', 16 | ) 17 | @click.option( 18 | 'additional_tags', '-t', '--tag', 19 | multiple=True, 20 | help='Extra tags to apply to built image', 21 | ) 22 | @click.option( 23 | 'build_args', '-b', '--build-arg', 24 | multiple=True, 25 | help='Arguments to pass to docker build (Dockerfile ARG) as ARG=VALUE', 26 | ) 27 | @click.pass_context 28 | def push(ctx, default_registry, additional_tags, build_args): 29 | ''' 30 | Push app images to docker repo 31 | ''' 32 | build = Build( 33 | env=ctx.meta['kube_context'], 34 | namespace=None, 35 | ) 36 | app_dir = os.getcwd() 37 | commit_hash, _ = get_git_info(app_dir) 38 | kubetools_config = load_kubetools_config( 39 | app_dir, 40 | env=build.env, 41 | namespace=build.namespace, 42 | app_name=app_dir, 43 | custom_config_file=False, 44 | ) 45 | ensure_docker_images( 46 | kubetools_config, build, app_dir, 47 | commit_hash=commit_hash, 48 | default_registry=default_registry, 49 | additional_tags=additional_tags, 50 | build_args=build_args, 51 | ) 52 | -------------------------------------------------------------------------------- /kubetools/cli/server_util.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from collections import deque 4 | from time import sleep 5 | 6 | import click 7 | 8 | 9 | # Hacky way of getting terminal size (so can clear lines) 10 | # Source: http://stackoverflow.com/questions/566746 11 | TERMINAL_SIZE = os.popen('stty size', 'r').read().split() 12 | IS_TTY = bool(TERMINAL_SIZE) 13 | TERMINAL_WIDTH = int(TERMINAL_SIZE[1]) if IS_TTY else None 14 | 15 | # Rotate the spinner every 1/UPDATE_DIVISOR seconds 16 | UPDATE_DIVISOR = 20 17 | 18 | # Get stdout as defined by Click 19 | STDOUT = click.get_text_stream('stdout') 20 | 21 | 22 | def _clear_line(return_=True): 23 | line = ''.join(' ' for _ in range(0, TERMINAL_WIDTH)) 24 | 25 | if return_: 26 | line = '{0}\r'.format(line) 27 | 28 | STDOUT.write(line) 29 | STDOUT.flush() 30 | 31 | 32 | def wait_with_spinner( 33 | func, 34 | check_status_divisor=UPDATE_DIVISOR, 35 | tick_divisor=UPDATE_DIVISOR, 36 | ): 37 | wait_chars = deque(('-', '/', '|', '\\')) 38 | wait_ticks = 0 39 | 40 | # Store previous status so we still print progress w/o a tty, but only when 41 | # it changes rather than every .05s. 42 | previous_status = '' 43 | 44 | while True: 45 | # Get build state every ~1s 46 | if wait_ticks % check_status_divisor == 0: 47 | status = func(previous_status) 48 | 49 | # None = complete, so just break the loop 50 | if status is None: 51 | break 52 | 53 | status = status.strip() 54 | 55 | # Write status spinner 56 | if IS_TTY: 57 | # Limit prefix + status text width to terminal width 58 | width_limit = TERMINAL_WIDTH - 50 59 | if len(status) > width_limit: 60 | status = '{0}...'.format(status[:width_limit]) 61 | 62 | _clear_line() 63 | STDOUT.write(' {0} in progress{1}\r'.format( 64 | wait_chars[0], 65 | ' (status = {0})'.format(status) if status else '', 66 | )) 67 | STDOUT.flush() 68 | wait_chars.rotate(1) 69 | 70 | # Print out changes to status only when no tty 71 | else: 72 | if status != previous_status: 73 | click.echo(' status: {0}'.format(status)) 74 | 75 | previous_status = status 76 | 77 | # Tick tock 78 | wait_ticks += 1 79 | sleep(1 / tick_divisor) 80 | 81 | # This hack clears the progress bar line 82 | if IS_TTY: 83 | _clear_line() 84 | -------------------------------------------------------------------------------- /kubetools/cli/show.py: -------------------------------------------------------------------------------- 1 | import click 2 | 3 | from tabulate import tabulate 4 | 5 | from kubetools.constants import ( 6 | GIT_BRANCH_ANNOTATION_KEY, 7 | GIT_COMMIT_ANNOTATION_KEY, 8 | GIT_TAG_ANNOTATION_KEY, 9 | NAME_LABEL_KEY, 10 | PROJECT_NAME_LABEL_KEY, 11 | ROLE_LABEL_KEY, 12 | ) 13 | from kubetools.kubernetes.api import ( 14 | get_object_annotations_dict, 15 | get_object_labels_dict, 16 | get_object_name, 17 | is_kubetools_object, 18 | list_cronjobs, 19 | list_deployments, 20 | list_jobs, 21 | list_replica_sets, 22 | list_services, 23 | ) 24 | 25 | from . import cli_bootstrap 26 | 27 | 28 | def _get_service_meta(service): 29 | meta_items = [] 30 | for port in service.spec.ports: 31 | meta_items.append(f'port={port.port}, nodePort={port.node_port}') 32 | return ''.join(meta_items) 33 | 34 | 35 | def _print_items(items, header_to_getter=None): 36 | header_to_getter = header_to_getter or {} 37 | 38 | headers = ['Name', 'Role', 'Project'] 39 | headers.extend(header_to_getter.keys()) 40 | headers = [click.style(header, bold=True) for header in headers] 41 | 42 | rows = [] 43 | for item in items: 44 | labels = get_object_labels_dict(item) 45 | 46 | row = [ 47 | get_object_name(item), 48 | labels.get(ROLE_LABEL_KEY), 49 | ] 50 | 51 | if not is_kubetools_object(item): 52 | row.append(click.style('NOT MANAGED BY KUBETOOLS', 'yellow')) 53 | else: 54 | row.append(labels.get(PROJECT_NAME_LABEL_KEY, 'unknown')) 55 | 56 | for getter in header_to_getter.values(): 57 | row.append(getter(item)) 58 | 59 | rows.append(row) 60 | 61 | click.echo(tabulate(rows, headers=headers, tablefmt='simple')) 62 | 63 | 64 | def _get_node_ports(item): 65 | node_ports = [] 66 | for port in item.spec.ports: 67 | if port.node_port: 68 | node_ports.append(f'{port.port}:{port.node_port}') 69 | else: 70 | node_ports.append(f'{port.port}') 71 | 72 | return ', '.join(node_ports) 73 | 74 | 75 | def _get_ready_status(item): 76 | return f'{item.status.ready_replicas or 0}/{item.status.replicas}' 77 | 78 | 79 | def _get_version_info(item): 80 | annotations = get_object_annotations_dict(item) 81 | bits = [] 82 | for name, key in ( 83 | ('branch', GIT_BRANCH_ANNOTATION_KEY), 84 | ('tag', GIT_TAG_ANNOTATION_KEY), 85 | ('commit', GIT_COMMIT_ANNOTATION_KEY), 86 | ): 87 | data = annotations.get(key) 88 | if data: 89 | bits.append(f'{name}={data}') 90 | 91 | return ', '.join(bits) 92 | 93 | 94 | def _get_completion_status(item): 95 | return f'{item.status.succeeded}/{item.spec.completions}' 96 | 97 | 98 | def _get_command(item): 99 | return get_object_annotations_dict(item).get('description') 100 | 101 | 102 | @cli_bootstrap.command(help_priority=3) 103 | @click.argument('namespace') 104 | @click.argument('app', required=False) 105 | @click.pass_context 106 | def show(ctx, namespace, app): 107 | ''' 108 | Show running apps in a given namespace. 109 | ''' 110 | 111 | exists = False 112 | 113 | env = ctx.meta['kube_context'] 114 | 115 | if app: 116 | click.echo(f'--> Filtering by app={app}') 117 | 118 | services = list_services(env, namespace) 119 | 120 | if services: 121 | exists = True 122 | 123 | if app: 124 | services = [s for s in services if get_object_name(s) == app] 125 | 126 | click.echo(f'--> {len(services)} Services') 127 | _print_items(services, { 128 | 'Port(:nodePort)': _get_node_ports, 129 | }) 130 | click.echo() 131 | 132 | deployments = list_deployments(env, namespace) 133 | 134 | if deployments: 135 | exists = True 136 | 137 | if app: 138 | deployments = [d for d in deployments if get_object_name(d) == app] 139 | 140 | click.echo(f'--> {len(deployments)} Deployments') 141 | 142 | _print_items(deployments, { 143 | 'Ready': _get_ready_status, 144 | 'Version': _get_version_info, 145 | }) 146 | click.echo() 147 | 148 | if app: 149 | replica_sets = list_replica_sets(env, namespace) 150 | replica_sets = [ 151 | r for r in replica_sets 152 | if r.metadata.labels.get(NAME_LABEL_KEY) == app 153 | ] 154 | 155 | click.echo(f'--> {len(replica_sets)} Replica sets') 156 | _print_items(replica_sets, { 157 | 'Ready': _get_ready_status, 158 | 'Version': _get_version_info, 159 | }) 160 | click.echo() 161 | else: 162 | cronjobs = list_cronjobs(env, namespace) 163 | 164 | if cronjobs: 165 | exists = True 166 | 167 | click.echo(f'--> {len(cronjobs)} Cronjobs') 168 | 169 | _print_items(cronjobs, { 170 | 'Ready': _get_cronjob_status, 171 | 'Version': _get_version_info, 172 | }) 173 | click.echo() 174 | 175 | jobs = [] 176 | jobs_cronjobs = [] 177 | job_list = list_jobs(env, namespace) 178 | if job_list: 179 | for job in job_list: 180 | labels = get_object_labels_dict(job) 181 | if labels.get(ROLE_LABEL_KEY) == 'job': 182 | jobs.append(job) 183 | elif labels.get(ROLE_LABEL_KEY) == 'cronjob': 184 | jobs_cronjobs.append(job) 185 | 186 | if jobs_cronjobs: 187 | exists = True 188 | click.echo(f'--> {len(jobs_cronjobs)} Jobs created by Cronjobs') 189 | 190 | _print_items(jobs_cronjobs, { 191 | 'Completions': _get_completion_status, 192 | 'Command': _get_command, 193 | }) 194 | click.echo() 195 | 196 | if jobs: 197 | exists = True 198 | click.echo(f'--> {len(jobs)} Jobs') 199 | _print_items(jobs, { 200 | 'Completions': _get_completion_status, 201 | 'Command': _get_command, 202 | }) 203 | click.echo() 204 | 205 | if not exists: 206 | click.echo('Nothing to be found here 👀!') 207 | 208 | 209 | def _get_cronjob_status(item): 210 | if item.status.active is not None: 211 | # Job is currently running (implies successfully started) 212 | return "?/1" 213 | elif item.status.last_successful_time is not None: 214 | # Job has been successful (implies successfully started) 215 | return "1/1" 216 | elif item.status.last_schedule_time is not None: 217 | # Job has been scheduled 218 | return "0/1" 219 | else: 220 | # Job has never been scheduled (error in CronJob?) 221 | return "0/0" 222 | -------------------------------------------------------------------------------- /kubetools/config.py: -------------------------------------------------------------------------------- 1 | ''' 2 | Shared Python module for handling Kubetools (kubetools.yml) config. Used by both 3 | the dev client (ktd) and the server (lists KubetoolsClient as a requirement). 4 | ''' 5 | 6 | from os import getcwd, path 7 | 8 | import yaml 9 | 10 | from pkg_resources import parse_version 11 | 12 | from . import __version__ 13 | from .exceptions import KubeConfigError 14 | 15 | 16 | # Config keys that can be filtered with a conditions: object 17 | TOP_LEVEL_CONDITION_KEYS = ( 18 | 'upgrades', 19 | 'deployments', 20 | 'dependencies', 21 | 'cronjobs', 22 | ) 23 | 24 | # Config keys that can have re-usable containerContext: names 25 | TOP_LEVEL_CONTAINER_KEYS = TOP_LEVEL_CONDITION_KEYS + ( 26 | 'tests', 27 | ) 28 | 29 | 30 | def load_kubetools_config( 31 | directory=None, 32 | app_name=None, 33 | # Filters for config items 34 | env=None, 35 | namespace=None, 36 | dev=False, # when true disables env/namespace filtering (dev *only*) 37 | custom_config_file=False, 38 | ): 39 | ''' 40 | Load Kubetools config files. 41 | 42 | Filtering: 43 | Most config items (deployments, dependencies, upgrades) can have conditions 44 | attached to them (eg dev: true). If an item has conditions, *either* dev or 45 | both env/namespace must match. 46 | 47 | Args: 48 | directory (str): directory to load ther config from (defaults to cwd) 49 | app_name (str): name of the app we're trying to load 50 | env (str): which envrionment to filter the config items by 51 | namespace (str): which namespace to filter the config items by 52 | dev (bool): filter config items by dev mode 53 | ''' 54 | 55 | if custom_config_file: 56 | possible_filenames = (custom_config_file,) 57 | 58 | else: 59 | possible_filenames = ( 60 | 'kubetools.yml', 61 | 'kubetools.yaml', 62 | ) 63 | 64 | if directory: 65 | possible_files = [ 66 | path.join(directory, filename) 67 | for filename in possible_filenames 68 | ] 69 | else: 70 | directory = getcwd() 71 | possible_files = [] 72 | 73 | # Attempt parent directories back up to root 74 | while True: 75 | possible_files.extend([ 76 | path.join(directory, filename) 77 | for filename in possible_filenames 78 | ]) 79 | 80 | directory, splitdir = path.split(directory) 81 | if not splitdir: 82 | break 83 | 84 | config = None 85 | 86 | for filename in possible_files: 87 | try: 88 | with open(filename, 'r') as f: 89 | config = f.read() 90 | 91 | except IOError: 92 | pass 93 | else: 94 | break 95 | 96 | # If not present, this app deosn't support deploy/upgrade jobs (build/run only) 97 | if config is None: 98 | raise KubeConfigError(( 99 | 'Could not build app{0} as no kubetools config found!' 100 | ).format(' ({0})'.format(app_name) if app_name else '')) 101 | 102 | config = yaml.safe_load(config) 103 | config['_filename'] = filename 104 | 105 | # Check Kubetools version? 106 | if 'minKubetoolsVersion' in config: 107 | _check_min_version(config) 108 | 109 | # Apply an env name? 110 | if env: 111 | config['env'] = env 112 | 113 | # Filter out config items according to our conditions 114 | for key in TOP_LEVEL_CONDITION_KEYS: 115 | if key in config: 116 | config[key] = _filter_config_data( 117 | key, config[key], 118 | env=env, 119 | namespace=namespace, 120 | dev=dev, 121 | ) 122 | 123 | # De-nest/apply any contextContexts 124 | for key in TOP_LEVEL_CONTAINER_KEYS: 125 | contexts = config.get('containerContexts', {}) 126 | 127 | if key in config: 128 | config[key] = _expand_containers( 129 | key, config[key], 130 | contexts=contexts, 131 | dev=dev, 132 | ) 133 | 134 | return config 135 | 136 | 137 | def _check_min_version(config): 138 | running_version = parse_version(__version__) 139 | needed_version = parse_version( 140 | # Version must be a string 141 | str(config['minKubetoolsVersion']), 142 | ) 143 | 144 | if needed_version > running_version: 145 | raise KubeConfigError( 146 | 'Minimum Kubetools version not met, need {0} but got {1}'.format( 147 | needed_version, running_version, 148 | ), 149 | ) 150 | 151 | 152 | def _filter_config_data(key, items_or_object, env, namespace, dev): 153 | def is_match(item): 154 | return _conditions_match( 155 | item.get('conditions'), 156 | env=env, 157 | namespace=namespace, 158 | dev=dev, 159 | ) 160 | 161 | if isinstance(items_or_object, list): 162 | return [ 163 | item for item in items_or_object 164 | if is_match(item) 165 | ] 166 | 167 | elif isinstance(items_or_object, dict): 168 | return { 169 | key: item 170 | for key, item in items_or_object.items() 171 | if is_match(item) 172 | } 173 | 174 | else: 175 | raise KubeConfigError('Invalid type ({0}) for key: {1}'.format( 176 | type(items_or_object), 177 | key, 178 | )) 179 | 180 | 181 | def _conditions_match(conditions, env, namespace, dev): 182 | # No conditions? We're good! 183 | if conditions is None: 184 | return True 185 | 186 | # Dev mode? Must have dev: true (or no conditions above) 187 | if dev: 188 | return conditions.get('dev') is True 189 | 190 | # If dev is set and nothing else, fail (dev only)! 191 | if conditions.get('dev') and len(conditions) == 1: 192 | return False 193 | 194 | # If we have envs but our env isn't present, fail! 195 | if 'envs' in conditions and env not in conditions['envs']: 196 | return False 197 | 198 | # We have namespaces but our namespace isn't present, fail! 199 | if 'namespaces' in conditions and namespace not in conditions['namespaces']: 200 | return False 201 | 202 | # If we have notNamespaces and our namespace is present, fail! 203 | if 'notNamespaces' in conditions and namespace in conditions['notNamespaces']: 204 | return False 205 | 206 | return True 207 | 208 | 209 | def _expand_containers(key, items_or_object, contexts, dev): 210 | def do_expand(item): 211 | return _expand_container( 212 | item, 213 | contexts=contexts, 214 | dev=dev, 215 | ) 216 | 217 | # List items have conditions and containerContext at the same level 218 | if isinstance(items_or_object, list): 219 | return [ 220 | do_expand(item) 221 | for item in items_or_object 222 | ] 223 | 224 | # Named items have conditions top level. but containers are nested 225 | elif isinstance(items_or_object, dict): 226 | new_item = {} 227 | 228 | for key, item in items_or_object.items(): 229 | if 'containers' in item: 230 | item['containers'] = { 231 | k: do_expand(v) 232 | for k, v in item.pop('containers').items() 233 | } 234 | 235 | new_item[key] = item 236 | 237 | return new_item 238 | 239 | else: 240 | raise KubeConfigError('Invalid type ({0}) for key: {1}'.format( 241 | type(items_or_object), 242 | key, 243 | )) 244 | 245 | 246 | def _merge_config(base_config, new_config): 247 | for key, value in new_config.items(): 248 | # If this key is a dict in the old config, merge those 249 | if key in base_config and isinstance(value, dict): 250 | _merge_config(base_config[key], new_config[key]) 251 | else: 252 | base_config[key] = new_config[key] 253 | 254 | 255 | def _expand_container(container, contexts, dev): 256 | # Expand any containerContext objects 257 | if 'containerContext' in container: 258 | context_name = container.get('containerContext') 259 | 260 | try: 261 | context = contexts[context_name] 262 | except KeyError: 263 | raise KubeConfigError('Missing containerContext: {0}'.format( 264 | context_name, 265 | )) 266 | 267 | # Merge in anything not explicitly defined on the container itself 268 | _merge_config(container, context) 269 | 270 | # Expand any dev keys from the dev config 271 | if 'dev' in container: 272 | dev_overrides = container.pop('dev') 273 | if dev: 274 | _merge_config(container, dev_overrides) 275 | 276 | # Apply any commandArguments 277 | if 'commandArguments' in container: 278 | # Duplicate the command here as it might be from a context so we don't 279 | # want to continuously append the arguments to the same base command. 280 | command = [cmd for cmd in container.pop('command')] 281 | command.extend(container.pop('commandArguments')) 282 | container['command'] = command 283 | 284 | return container 285 | -------------------------------------------------------------------------------- /kubetools/constants.py: -------------------------------------------------------------------------------- 1 | MANAGED_BY_ANNOTATION_KEY = 'app.kubernetes.io/managed-by' 2 | 3 | GIT_BRANCH_ANNOTATION_KEY = 'kubetools/git_branch' 4 | GIT_TAG_ANNOTATION_KEY = 'kubetools/git_tag' 5 | GIT_COMMIT_ANNOTATION_KEY = 'kubetools/git_commit' 6 | 7 | PROJECT_NAME_LABEL_KEY = 'kubetools/project_name' 8 | ROLE_LABEL_KEY = 'kubetools/role' 9 | NAME_LABEL_KEY = 'kubetools/name' 10 | -------------------------------------------------------------------------------- /kubetools/deploy/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EDITD/kubetools/e836c75768d9e4af9e13680afcd5164c6f4ed7a0/kubetools/deploy/__init__.py -------------------------------------------------------------------------------- /kubetools/deploy/build.py: -------------------------------------------------------------------------------- 1 | from contextlib import contextmanager 2 | 3 | import click 4 | 5 | 6 | class Build(object): 7 | ''' 8 | Build is a stub class that encapsulates the context and namespace for 9 | a given build, as well as accepting log entries. 10 | 11 | The kubetools server provides it's own build class, which also handles things 12 | like aborting builds via Redis and keeps them saved in the database. 13 | ''' 14 | 15 | in_stage = False 16 | 17 | def __init__(self, env, namespace): 18 | self.env = env 19 | self.namespace = namespace 20 | 21 | def log_info(self, text, extra_detail=None, formatter=lambda s: s): 22 | ''' 23 | Create BuildLog information. 24 | ''' 25 | 26 | if extra_detail: 27 | text = f'{text}@{extra_detail}' 28 | 29 | if self.in_stage: 30 | text = f' {text}' 31 | 32 | if formatter: 33 | text = formatter(text) 34 | 35 | click.echo(text) 36 | 37 | def log_warning(self, *args, **kwargs): 38 | kwargs['formatter'] = lambda s: click.style(s, 'yellow') 39 | self.log_info(*args, **kwargs) 40 | 41 | def log_error(self, *args, **kwargs): 42 | kwargs['formatter'] = lambda s: click.style(s, 'red') 43 | self.log_info(*args, **kwargs) 44 | 45 | @contextmanager 46 | def stage(self, stage_name): 47 | click.echo(f'--> {stage_name}') 48 | old_in_stage = self.in_stage 49 | self.in_stage = True 50 | yield 51 | self.in_stage = old_in_stage 52 | click.echo() 53 | -------------------------------------------------------------------------------- /kubetools/deploy/commands/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EDITD/kubetools/e836c75768d9e4af9e13680afcd5164c6f4ed7a0/kubetools/deploy/commands/__init__.py -------------------------------------------------------------------------------- /kubetools/deploy/commands/cleanup.py: -------------------------------------------------------------------------------- 1 | from kubetools.deploy.util import delete_objects, log_actions 2 | from kubetools.kubernetes.api import ( 3 | delete_job, 4 | delete_namespace, 5 | delete_pod, 6 | delete_replica_set, 7 | get_object_name, 8 | is_kubetools_object, 9 | list_complete_jobs, 10 | list_namespaces, 11 | list_pods, 12 | list_replica_sets, 13 | ) 14 | 15 | 16 | # Cleanup 17 | # Handles removal of orphaned replicasets/pods and optionally any complete jobs, 18 | # working on the namespace level only (no apps). 19 | # If the cleanup removes all remaining objects, the namespace will be deleted too. 20 | 21 | def get_cleanup_objects(build, cleanup_jobs): 22 | replica_sets = list_replica_sets(build.env, build.namespace) 23 | replica_set_names = set(get_object_name(replica_set) for replica_set in replica_sets) 24 | replica_sets_to_delete = [] 25 | replica_set_names_to_delete = set() 26 | replica_set_names_already_deleted = set() 27 | 28 | for replica_set in replica_sets: 29 | if not is_kubetools_object(replica_set): 30 | continue 31 | 32 | if not replica_set.metadata.owner_references: 33 | replica_set_names_to_delete.add(get_object_name(replica_set)) 34 | replica_sets_to_delete.append(replica_set) 35 | 36 | if replica_set.metadata.deletion_timestamp: 37 | replica_set_names_already_deleted.add(get_object_name(replica_set)) 38 | 39 | jobs_to_delete = [] 40 | jobs_set_names_to_delete = set() 41 | if cleanup_jobs: 42 | jobs_to_delete = list_complete_jobs(build.env, build.namespace) 43 | for job in jobs_to_delete: 44 | jobs_set_names_to_delete.add(get_object_name(job)) 45 | 46 | pods = list_pods(build.env, build.namespace) 47 | pod_names = set(get_object_name(pod) for pod in pods) 48 | pods_to_delete = [] 49 | pod_names_to_delete = set() 50 | pod_names_already_deleted = set() 51 | 52 | for pod in pods: 53 | if not pod.metadata.owner_references: 54 | pods_to_delete.append(pod) 55 | pod_names_to_delete.add(get_object_name(pod)) 56 | 57 | elif len(pod.metadata.owner_references) == 1: 58 | owner = pod.metadata.owner_references[0] 59 | if owner.name in replica_set_names_to_delete or owner.name in jobs_set_names_to_delete: 60 | pods_to_delete.append(pod) 61 | pod_names_to_delete.add(get_object_name(pod)) 62 | 63 | if pod.metadata.deletion_timestamp: 64 | pod_names_already_deleted.add(get_object_name(pod)) 65 | 66 | namespaces = list_namespaces(build.env) 67 | for namespace in namespaces: 68 | if namespace.metadata.name == build.namespace: 69 | current_namespace = namespace 70 | break 71 | else: 72 | current_namespace = None 73 | 74 | namespace_to_delete = [] 75 | # Namespace must exist. And "default" namespace can't be deleted 76 | if current_namespace and current_namespace.metadata.name != "default": 77 | remaining_pods = pod_names - pod_names_to_delete - pod_names_already_deleted 78 | remaining_replicasets = ( 79 | replica_set_names - replica_set_names_to_delete - replica_set_names_already_deleted 80 | ) 81 | 82 | if len(remaining_pods) == 0 and len(remaining_replicasets) == 0: 83 | namespace_to_delete = [current_namespace] 84 | 85 | return namespace_to_delete, replica_sets_to_delete, pods_to_delete, jobs_to_delete 86 | 87 | 88 | def log_cleanup_changes( 89 | build, namespace, replica_sets, pods, jobs, 90 | message='Executing changes:', 91 | name_formatter=lambda name: name, 92 | ): 93 | with build.stage(message): 94 | log_actions(build, 'DELETE', 'replica_set', replica_sets, name_formatter) 95 | log_actions(build, 'DELETE', 'jobs', jobs, name_formatter) 96 | log_actions(build, 'DELETE', 'pod', pods, name_formatter) 97 | log_actions(build, 'DELETE', 'namespace', namespace, name_formatter) 98 | 99 | 100 | def execute_cleanup(build, namespace, replica_sets, pods, jobs): 101 | with build.stage('Delete replica sets'): 102 | delete_objects(build, replica_sets, delete_replica_set) 103 | 104 | with build.stage('Delete jobs'): 105 | delete_objects(build, jobs, delete_job) 106 | 107 | with build.stage('Delete pods'): 108 | delete_objects(build, pods, delete_pod) 109 | 110 | with build.stage('Delete namespace'): 111 | delete_objects(build, namespace, delete_namespace) 112 | -------------------------------------------------------------------------------- /kubetools/deploy/commands/deploy.py: -------------------------------------------------------------------------------- 1 | from kubetools.cli.git_utils import get_git_info 2 | from kubetools.config import load_kubetools_config 3 | from kubetools.constants import ( 4 | ROLE_LABEL_KEY, 5 | ) 6 | from kubetools.deploy.image import ensure_docker_images 7 | from kubetools.deploy.util import log_actions 8 | from kubetools.kubernetes.api import ( 9 | create_cronjob, 10 | create_deployment, 11 | create_job, 12 | create_namespace, 13 | create_service, 14 | cronjob_exists, 15 | delete_job, 16 | deployment_exists, 17 | get_object_name, 18 | list_cronjobs, 19 | list_deployments, 20 | list_namespaces, 21 | list_services, 22 | namespace_exists, 23 | service_exists, 24 | update_cronjob, 25 | update_deployment, 26 | update_namespace, 27 | update_service, 28 | ) 29 | from kubetools.kubernetes.config import ( 30 | generate_kubernetes_configs_for_project, 31 | generate_namespace_config, 32 | ) 33 | 34 | 35 | # Deploy/upgrade 36 | # Handles deploying new services and upgrading existing ones 37 | 38 | def get_deploy_objects( 39 | build, 40 | app_dirs, 41 | replicas=None, 42 | default_registry=None, 43 | build_args=None, 44 | extra_envvars=None, 45 | extra_annotations=None, 46 | ignore_git_changes=False, 47 | custom_config_file=False, 48 | ): 49 | all_services = [] 50 | all_deployments = [] 51 | all_jobs = [] 52 | all_cronjobs = [] 53 | 54 | envvars = { 55 | 'KUBE_ENV': build.env, 56 | 'KUBE_NAMESPACE': build.namespace, 57 | } 58 | if extra_envvars: 59 | envvars.update(extra_envvars) 60 | 61 | annotations = { 62 | 'kubetools/env': build.env, 63 | 'kubetools/namespace': build.namespace, 64 | } 65 | if extra_annotations: 66 | annotations.update(extra_annotations) 67 | 68 | namespace = generate_namespace_config(build.namespace, base_annotations=annotations) 69 | 70 | for app_dir in app_dirs: 71 | commit_hash, git_annotations = get_git_info(app_dir, ignore_git_changes) 72 | annotations.update(git_annotations) 73 | 74 | kubetools_config = load_kubetools_config( 75 | app_dir, 76 | env=build.env, 77 | namespace=build.namespace, 78 | app_name=app_dir, 79 | custom_config_file=custom_config_file, 80 | ) 81 | 82 | context_to_image = ensure_docker_images( 83 | kubetools_config, build, app_dir, 84 | commit_hash=commit_hash, 85 | default_registry=default_registry, 86 | build_args=build_args, 87 | ) 88 | 89 | services, deployments, jobs, cronjobs = generate_kubernetes_configs_for_project( 90 | kubetools_config, 91 | envvars=envvars, 92 | context_name_to_image=context_to_image, 93 | base_annotations=annotations, 94 | replicas=replicas or 1, 95 | default_registry=default_registry, 96 | ) 97 | 98 | all_services.extend(services) 99 | all_deployments.extend(deployments) 100 | all_jobs.extend(jobs) 101 | all_cronjobs.extend(cronjobs) 102 | 103 | existing_deployments = { 104 | get_object_name(deployment): deployment 105 | for deployment in list_deployments(build.env, build.namespace) 106 | } 107 | 108 | # If we haven't been provided an explicit number of replicas, default to using 109 | # anything that exists live when available. 110 | if replicas is None: 111 | for deployment in all_deployments: 112 | existing_deployment = existing_deployments.get(get_object_name(deployment)) 113 | if existing_deployment: 114 | deployment['spec']['replicas'] = existing_deployment.spec.replicas 115 | 116 | return namespace, all_services, all_deployments, all_jobs, all_cronjobs 117 | 118 | 119 | def log_deploy_changes( 120 | build, namespace, services, deployments, jobs, cronjobs, 121 | message='Executing changes:', 122 | name_formatter=lambda name: name, 123 | ): 124 | existing_namespace_names = set( 125 | get_object_name(namespace) 126 | for namespace in list_namespaces(build.env) 127 | ) 128 | existing_service_names = set( 129 | get_object_name(service) 130 | for service in list_services(build.env, build.namespace) 131 | ) 132 | existing_deployment_names = set( 133 | get_object_name(deployment) 134 | for deployment in list_deployments(build.env, build.namespace) 135 | ) 136 | existing_cronjobs_names = set( 137 | get_object_name(cronjob) 138 | for cronjob in list_cronjobs(build.env, build.namespace) 139 | ) 140 | 141 | deploy_service_names = set( 142 | get_object_name(service) for service in services 143 | ) 144 | deploy_deployment_names = set( 145 | get_object_name(deployment) for deployment in deployments 146 | ) 147 | deploy_cronjobs_names = set( 148 | get_object_name(cronjob) for cronjob in cronjobs 149 | ) 150 | deploy_namespace_name = set((build.namespace,)) 151 | 152 | new_namespace = deploy_namespace_name - existing_namespace_names 153 | 154 | new_services = deploy_service_names - existing_service_names 155 | update_services = deploy_service_names - new_services 156 | 157 | new_deployments = deploy_deployment_names - existing_deployment_names 158 | update_deployments = deploy_deployment_names - new_deployments 159 | 160 | new_cronjobs = deploy_cronjobs_names - existing_cronjobs_names 161 | update_cronjobs = deploy_cronjobs_names - new_cronjobs 162 | 163 | with build.stage(message): 164 | log_actions(build, 'CREATE', 'namespace', new_namespace, name_formatter) 165 | log_actions(build, 'CREATE', 'service', new_services, name_formatter) 166 | log_actions(build, 'CREATE', 'deployment', new_deployments, name_formatter) 167 | log_actions(build, 'CREATE', 'cronjob', new_cronjobs, name_formatter) 168 | log_actions(build, 'UPDATE', 'service', update_services, name_formatter) 169 | log_actions(build, 'UPDATE', 'deployment', update_deployments, name_formatter) 170 | log_actions(build, 'UPDATE', 'cronjob', update_cronjobs, name_formatter) 171 | 172 | 173 | def execute_deploy( 174 | build, 175 | namespace, 176 | services, 177 | deployments, 178 | jobs, 179 | cronjobs, 180 | delete_completed_jobs=True, 181 | ): 182 | # Split services + deployments into app (main) and dependencies 183 | depend_services = [] 184 | main_services = [] 185 | 186 | for service in services: 187 | if service['metadata']['labels'][ROLE_LABEL_KEY] == 'app': 188 | main_services.append(service) 189 | else: 190 | depend_services.append(service) 191 | 192 | depend_deployments = [] 193 | main_deployments = [] 194 | for deployment in deployments: 195 | if deployment['metadata']['labels'][ROLE_LABEL_KEY] == 'app': 196 | main_deployments.append(deployment) 197 | else: 198 | depend_deployments.append(deployment) 199 | 200 | # Now execute the deploy process 201 | if namespace: 202 | with build.stage('Create and/or update namespace'): 203 | if namespace_exists(build.env, namespace): 204 | build.log_info(f'Update namespace: {get_object_name(namespace)}') 205 | update_namespace(build.env, namespace) 206 | else: 207 | build.log_info(f'Create namespace: {get_object_name(namespace)}') 208 | create_namespace(build.env, namespace) 209 | 210 | if depend_services: 211 | with build.stage('Create and/or update dependency services'): 212 | for service in depend_services: 213 | if service_exists(build.env, build.namespace, service): 214 | build.log_info(f'Update service: {get_object_name(service)}') 215 | update_service(build.env, build.namespace, service) 216 | else: 217 | build.log_info(f'Create service: {get_object_name(service)}') 218 | create_service(build.env, build.namespace, service) 219 | 220 | if depend_deployments: 221 | with build.stage('Create and/or update dependency deployments'): 222 | for deployment in depend_deployments: 223 | if deployment_exists(build.env, build.namespace, deployment): 224 | build.log_info(f'Update deployment: {get_object_name(deployment)}') 225 | update_deployment(build.env, build.namespace, deployment) 226 | else: 227 | build.log_info(f'Create deployment: {get_object_name(deployment)}') 228 | create_deployment(build.env, build.namespace, deployment) 229 | 230 | noexist_main_services = [] 231 | exist_main_services = [] 232 | for service in main_services: 233 | if not service_exists(build.env, build.namespace, service): 234 | noexist_main_services.append(service) 235 | else: 236 | exist_main_services.append(service) 237 | 238 | if noexist_main_services: 239 | with build.stage('Create any app services that do not exist'): 240 | for service in noexist_main_services: 241 | build.log_info(f'Create service: {get_object_name(service)}') 242 | create_service(build.env, build.namespace, service) 243 | 244 | noexist_main_deployments = [] 245 | exist_main_deployments = [] 246 | for deployment in main_deployments: 247 | if not deployment_exists(build.env, build.namespace, deployment): 248 | noexist_main_deployments.append(deployment) 249 | else: 250 | exist_main_deployments.append(deployment) 251 | 252 | if noexist_main_deployments: 253 | with build.stage('Create any app deployments that do not exist'): 254 | for deployment in noexist_main_deployments: 255 | build.log_info(f'Create deployment: {get_object_name(deployment)}') 256 | create_deployment(build.env, build.namespace, deployment) 257 | 258 | if jobs: 259 | with build.stage('Execute upgrades'): 260 | for job in jobs: 261 | build.log_info(f'Create job: {get_object_name(job)}') 262 | create_job(build.env, build.namespace, job) 263 | if delete_completed_jobs: 264 | delete_job(build.env, build.namespace, job) 265 | 266 | if exist_main_deployments: 267 | with build.stage('Update existing app deployments'): 268 | for deployment in exist_main_deployments: 269 | build.log_info(f'Update deployment: {get_object_name(deployment)}') 270 | update_deployment(build.env, build.namespace, deployment) 271 | 272 | if exist_main_services: 273 | with build.stage('Update existing app services'): 274 | for service in exist_main_services: 275 | build.log_info(f'Update service: {get_object_name(service)}') 276 | update_service(build.env, build.namespace, service) 277 | 278 | for cronjob in cronjobs: 279 | with build.stage('Create and/or update cronjobs'): 280 | if cronjob_exists(build.env, build.namespace, cronjob): 281 | build.log_info(f'Update cronjob: {get_object_name(cronjob)}') 282 | update_cronjob(build.env, build.namespace, cronjob) 283 | else: 284 | build.log_info(f'Create cronjob: {get_object_name(cronjob)}') 285 | create_cronjob(build.env, build.namespace, cronjob) 286 | -------------------------------------------------------------------------------- /kubetools/deploy/commands/remove.py: -------------------------------------------------------------------------------- 1 | from kubetools.deploy.util import delete_objects, get_app_objects, log_actions 2 | from kubetools.kubernetes.api import ( 3 | delete_cronjob, 4 | delete_deployment, 5 | delete_job, 6 | delete_service, 7 | list_cronjobs, 8 | list_deployments, 9 | list_jobs, 10 | list_services, 11 | ) 12 | 13 | 14 | # Remove 15 | # Handles removal of deployments, services and jobs in a namespace 16 | 17 | def get_remove_objects(build, app_names=None, force=False): 18 | services_to_delete = get_app_objects( 19 | build, 20 | app_names, 21 | list_services, 22 | force=force, 23 | ) 24 | deployments_to_delete = get_app_objects( 25 | build, 26 | app_names, 27 | list_deployments, 28 | force=force, 29 | ) 30 | jobs_to_delete = get_app_objects( 31 | build, 32 | app_names, 33 | list_jobs, 34 | force=force, 35 | ) 36 | 37 | cronjobs_to_delete = get_app_objects( 38 | build, 39 | app_names, 40 | list_cronjobs, 41 | force=force, 42 | ) 43 | 44 | return services_to_delete, deployments_to_delete, jobs_to_delete, cronjobs_to_delete 45 | 46 | 47 | def log_remove_changes( 48 | build, services, deployments, jobs, cronjobs, 49 | message='Executing changes:', 50 | name_formatter=lambda name: name, 51 | ): 52 | with build.stage(message): 53 | log_actions(build, 'DELETE', 'service', services, name_formatter) 54 | log_actions(build, 'DELETE', 'deployment', deployments, name_formatter) 55 | log_actions(build, 'DELETE', 'job', jobs, name_formatter) 56 | log_actions(build, 'DELETE', 'cronjob', cronjobs, name_formatter) 57 | 58 | 59 | def execute_remove(build, services, deployments, jobs, cronjobs): 60 | if services: 61 | with build.stage('Delete services'): 62 | delete_objects(build, services, delete_service) 63 | 64 | if deployments: 65 | with build.stage('Delete deployments'): 66 | delete_objects(build, deployments, delete_deployment) 67 | 68 | if jobs: 69 | with build.stage('Delete jobs'): 70 | delete_objects(build, jobs, delete_job) 71 | 72 | # This will delete all cronjobs associated with a project 73 | # Need to look into this in the future, to be able to delete individual jobs 74 | if cronjobs: 75 | with build.stage('Delete cronjobs'): 76 | delete_objects(build, cronjobs, delete_cronjob) 77 | -------------------------------------------------------------------------------- /kubetools/deploy/commands/restart.py: -------------------------------------------------------------------------------- 1 | from collections import defaultdict 2 | 3 | from kubetools.deploy.util import get_app_objects, log_actions 4 | from kubetools.kubernetes.api import ( 5 | delete_pod, 6 | get_object_name, 7 | list_deployments, 8 | list_pods, 9 | list_replica_sets, 10 | wait_for_deployment, 11 | ) 12 | 13 | 14 | # Restart 15 | # Handles restarting a deployment by deleting each pod and waiting for recovery 16 | 17 | def get_restart_objects(build, app_names=None, force=False): 18 | deployments = get_app_objects( 19 | build, 20 | app_names, 21 | list_deployments, 22 | force=force, 23 | ) 24 | name_to_deployment = { 25 | get_object_name(deployment): deployment 26 | for deployment in deployments 27 | } 28 | 29 | replica_sets = list_replica_sets(build.env, build.namespace) 30 | replica_set_names_to_deployment = {} 31 | 32 | for replica_set in replica_sets: 33 | if not replica_set.metadata.owner_references: 34 | build.log_warning(( 35 | 'Found replicaSet with no owner (needs cleanup): ' 36 | f'{replica_set.metadata.name}' 37 | )) 38 | continue 39 | 40 | if len(replica_set.metadata.owner_references) > 1: 41 | build.log_error(( 42 | 'Found replicaSet with more than one owner: ' 43 | f'{replica_set.metadata.name}' 44 | )) 45 | continue 46 | 47 | owner_name = replica_set.metadata.owner_references[0].name 48 | if owner_name in name_to_deployment: 49 | replica_set_names_to_deployment[get_object_name(replica_set)] = ( 50 | name_to_deployment[owner_name] 51 | ) 52 | 53 | pods = list_pods(build.env, build.namespace) 54 | deployment_name_to_pods = defaultdict(list) 55 | 56 | for pod in pods: 57 | if pod.metadata.owner_references and len(pod.metadata.owner_references) == 1: 58 | owner = pod.metadata.owner_references[0] 59 | deployment = replica_set_names_to_deployment.get(owner.name) 60 | if deployment: 61 | deployment_name_to_pods[get_object_name(deployment)].append(pod) 62 | 63 | return [ 64 | (name_to_deployment[name], pods) 65 | for name, pods in deployment_name_to_pods.items() 66 | ] 67 | 68 | 69 | def log_restart_changes( 70 | build, deployments_and_pods, 71 | message='Executing changes:', 72 | name_formatter=lambda name: name, 73 | ): 74 | deployments = [deployment for deployment, _ in deployments_and_pods] 75 | with build.stage(message): 76 | log_actions(build, 'RESTART', 'deployment', deployments, name_formatter) 77 | 78 | 79 | def execute_restart(build, deployments_and_pods): 80 | for deployment, pods in deployments_and_pods: 81 | with build.stage(f'Restart pods for {get_object_name(deployment)}'): 82 | for pod in pods: 83 | build.log_info(f'Delete pod: {get_object_name(pod)}') 84 | delete_pod(build.env, build.namespace, pod) 85 | wait_for_deployment(build.env, build.namespace, deployment) 86 | -------------------------------------------------------------------------------- /kubetools/deploy/image.py: -------------------------------------------------------------------------------- 1 | import subprocess 2 | 3 | import requests 4 | 5 | from kubetools.exceptions import KubeBuildError 6 | from kubetools.kubernetes.config import make_context_name 7 | from kubetools.settings import get_settings 8 | 9 | from .util import run_shell_command 10 | 11 | 12 | def get_commit_hash_tag(context_name, commit_hash): 13 | ''' 14 | Turn a commit hash into a Docker registry tag. 15 | ''' 16 | 17 | return '-'.join((context_name, 'commit', commit_hash)) 18 | 19 | 20 | def get_docker_name(registry, app_name): 21 | return '{0}/{1}'.format(registry, app_name) 22 | 23 | 24 | def get_docker_tag(registry, app_name, tag): 25 | # Tag the image like registry/app:tag 26 | docker_version = '{0}:{1}'.format(app_name, tag) 27 | # The full docker tag 28 | return '{0}/{1}'.format(registry, docker_version) 29 | 30 | 31 | def get_docker_tag_for_commit(registry, app_name, context_name, commit_hash): 32 | return get_docker_tag(registry, app_name, get_commit_hash_tag(context_name, commit_hash)) 33 | 34 | 35 | def has_app_commit_image(registry, app_name, context_name, commit_hash): 36 | ''' 37 | Check the registry has an app image for a certain commit hash. 38 | ''' 39 | 40 | if registry is None: 41 | raise KubeBuildError(f'Invalid registry to build {context_name}: {registry}') 42 | 43 | commit_version = get_commit_hash_tag(context_name, commit_hash) 44 | 45 | settings = get_settings() 46 | if settings.REGISTRY_CHECK_SCRIPT: 47 | # We have a REGISTRY_CHECK_SCRIPT config, so use it to check for an image 48 | cmd = [settings.REGISTRY_CHECK_SCRIPT, registry, app_name, commit_version] 49 | rc = subprocess.call(cmd) 50 | if rc == 0: 51 | # A return code of 0 means the image was found 52 | return True 53 | elif rc == 1: 54 | # A return code of 1 means the image was not found 55 | return False 56 | elif rc == 2: 57 | # A return code of 2 means that the image should be checked using http 58 | pass 59 | else: 60 | # Any other return code means an error occured and we should not continue 61 | raise Exception('Error checking app image status') 62 | 63 | url = 'http://{0}/v2/{1}/manifests/{2}'.format(registry, app_name, commit_version) 64 | 65 | response = requests.head(url) 66 | 67 | if response.status_code != 200: 68 | return False 69 | 70 | return True 71 | 72 | 73 | def get_container_contexts_from_config(app_config): 74 | context_name_to_build = {} 75 | container_contexts = app_config.get('containerContexts', {}) 76 | for deployment, data in app_config.get('deployments', {}).items(): 77 | containers = data.get('containers') 78 | for name, container in containers.items(): 79 | if 'containerContext' in container: 80 | context_name = container['containerContext'] 81 | if context_name not in container_contexts: 82 | raise KubeBuildError(f'{context_name} is not a valid container context') 83 | container_context = container_contexts[context_name] 84 | if 'build' in container_context: 85 | context_name_to_build[context_name] = container_context['build'] 86 | 87 | elif 'build' in container: 88 | context_name = make_context_name(deployment, name) 89 | if context_name in context_name_to_build: 90 | raise KubeBuildError('Duplicate deployment/container') 91 | 92 | context_name_to_build[context_name] = container['build'] 93 | 94 | return context_name_to_build 95 | 96 | 97 | def ensure_docker_images(kubetools_config, build, *args, **kwargs): 98 | ''' 99 | Ensures that our Docker registry has the specified image. If not we build 100 | and upload to the registry. 101 | ''' 102 | 103 | project_name = kubetools_config['name'] 104 | commit_hash = kwargs.get('commit_hash') 105 | 106 | with build.stage(f'Ensuring Docker images built for {project_name}={commit_hash}'): 107 | return _ensure_docker_images(kubetools_config, build, *args, **kwargs) 108 | 109 | 110 | def _ensure_docker_images( 111 | kubetools_config, build, app_dir, commit_hash, 112 | default_registry=None, 113 | additional_tags=None, 114 | build_args=None, 115 | ): 116 | if additional_tags is None: 117 | additional_tags = [] 118 | if build_args is None: 119 | build_args = [] 120 | 121 | project_name = kubetools_config['name'] 122 | 123 | context_name_to_build = get_container_contexts_from_config(kubetools_config) 124 | 125 | build_inputs = {} 126 | for context_name, build_context in context_name_to_build.items(): 127 | registry = build_context.get('registry', default_registry) 128 | 129 | docker_tag_for_commit = get_docker_tag_for_commit( 130 | registry, 131 | project_name, 132 | context_name, 133 | commit_hash, 134 | ) 135 | 136 | additional_docker_tags = [ 137 | get_docker_tag(registry, project_name, additional_tag) 138 | for additional_tag in additional_tags 139 | ] 140 | docker_tags = [docker_tag_for_commit] 141 | docker_tags.extend(additional_docker_tags) 142 | 143 | build_inputs[context_name] = { 144 | 'context': build_context, 145 | 'registry': registry, 146 | 'image': docker_tag_for_commit, 147 | 'tags': docker_tags, 148 | } 149 | 150 | first_context, first_build_input = list(build_inputs.items())[0] 151 | first_registry = first_build_input["registry"] 152 | previous_commit = _find_last_pushed_commit(app_dir, first_context, first_registry, project_name) 153 | 154 | build.log_info(f'Building {project_name} @ commit {commit_hash}') 155 | 156 | # Now actually build the images 157 | for context_name, build_input in build_inputs.items(): 158 | if has_app_commit_image( 159 | build_input['registry'], 160 | project_name, 161 | context_name, 162 | commit_hash, 163 | ): 164 | build.log_info(( 165 | f'Docker image for {project_name}/{context_name} commit {commit_hash} exists, ' 166 | 'skipping build' 167 | )) 168 | continue 169 | 170 | build_context = build_input['context'] 171 | 172 | # Run pre docker commands? 173 | pre_build_commands = build_context.get('preBuildCommands', []) 174 | 175 | for command in pre_build_commands: 176 | build.log_info(f'Executing pre-build command: {command}') 177 | 178 | # Run it, passing in the commit hashes as ENVars 179 | env = { 180 | 'KUBE_ENV': build.env, 181 | 'BUILD_COMMIT': commit_hash, 182 | } 183 | if previous_commit: 184 | env['PREVIOUS_BUILD_COMMIT'] = previous_commit 185 | 186 | run_shell_command(*command, cwd=app_dir, env=env) 187 | 188 | tag_arguments = [] 189 | for docker_tag in build_input['tags']: 190 | tag_arguments.extend(['-t', docker_tag]) 191 | 192 | build_arg_arguments = [] 193 | for build_arg in build_args: 194 | build_arg_arguments.extend(['--build-arg', build_arg]) 195 | 196 | # Build the image 197 | build.log_info(( 198 | f'Building {project_name}/{context_name} ' 199 | f'(file: {build_context["dockerfile"]}, commit: {commit_hash})' 200 | )) 201 | 202 | run_shell_command( 203 | 'docker', 'build', '--pull', 204 | '-f', build_context['dockerfile'], 205 | *tag_arguments, 206 | *build_arg_arguments, 207 | '.', 208 | cwd=app_dir, 209 | ) 210 | 211 | # Push the image and additional tags 212 | for docker_tag in build_input['tags']: 213 | build.log_info(f'Pushing docker image: {docker_tag}') 214 | run_shell_command('docker', 'push', docker_tag) 215 | 216 | return { 217 | context_name: build_input['image'] 218 | for context_name, build_input in build_inputs.items() 219 | } 220 | 221 | 222 | def _find_last_pushed_commit(app_dir, context_name, registry, project_name, max_commits=100): 223 | commit_history = run_shell_command( 224 | 'git', 'log', '--pretty=format:"%h"', '--max-count', str(max_commits), 225 | cwd=app_dir, 226 | ).decode() 227 | 228 | commit_history = [ 229 | commit.strip('"') 230 | for commit in commit_history.split() 231 | ] 232 | 233 | for i, commit in enumerate(commit_history): 234 | if has_app_commit_image( 235 | registry, 236 | project_name, 237 | context_name, 238 | commit, 239 | ): 240 | return commit 241 | else: 242 | return None 243 | -------------------------------------------------------------------------------- /kubetools/deploy/util.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from subprocess import CalledProcessError, check_output, STDOUT 4 | 5 | from kubetools.constants import NAME_LABEL_KEY, PROJECT_NAME_LABEL_KEY 6 | from kubetools.exceptions import KubeBuildError 7 | from kubetools.kubernetes.api import ( 8 | get_object_labels_dict, 9 | get_object_name, 10 | is_kubetools_object, 11 | ) 12 | from kubetools.log import logger 13 | 14 | 15 | def run_shell_command(*command, **kwargs): 16 | ''' 17 | Run a shell command and return it's output. Capture fails and pass to internal 18 | exception. 19 | ''' 20 | 21 | cwd = kwargs.pop('cwd', None) 22 | env = kwargs.pop('env', {}) 23 | 24 | new_env = os.environ.copy() 25 | new_env.update(env) 26 | 27 | logger.debug(f'Running shell command in {cwd}: {command}, env: {env}') 28 | 29 | try: 30 | return check_output(command, stderr=STDOUT, cwd=cwd, env=new_env) 31 | 32 | except CalledProcessError as e: 33 | raise KubeBuildError('Command failed: {0}\n\n{1}'.format( 34 | ' '.join(command), 35 | e.output.decode('utf-8', 'ignore'), 36 | )) 37 | 38 | 39 | def log_actions(build, action, object_type, names, name_formatter): 40 | for name in names: 41 | if not isinstance(name, str): 42 | name = get_object_name(name) 43 | build.log_info(f'{action} {object_type} {name_formatter(name)}') 44 | 45 | 46 | def delete_objects(build, objects, delete_function): 47 | for obj in objects: 48 | build.log_info(f'Delete: {get_object_name(obj)}') 49 | delete_function(build.env, build.namespace, obj) 50 | 51 | 52 | def get_app_objects( 53 | build, app_or_project_names, list_objects_function, 54 | force=False, 55 | ): 56 | objects = list_objects_function(build.env, build.namespace) 57 | 58 | def filter_object(obj): 59 | if not is_kubetools_object(obj): 60 | if force: 61 | warning = f'Will touch {get_object_name(obj)} that is not managed by kubetools!' 62 | else: 63 | warning = f'Refusing to touch {get_object_name(obj)} as not managed by kubetools!' 64 | 65 | build.log_warning(warning) 66 | return force is True 67 | return True 68 | 69 | objects = list(filter(filter_object, objects)) 70 | 71 | if app_or_project_names: 72 | matched_object_names = set() 73 | 74 | def filter_object_names(obj): 75 | labels = get_object_labels_dict(obj) 76 | app_name = labels.get(NAME_LABEL_KEY) 77 | if app_name in app_or_project_names: 78 | matched_object_names.add(app_name) 79 | return True 80 | 81 | project_name = labels.get(PROJECT_NAME_LABEL_KEY) 82 | if project_name in app_or_project_names: 83 | matched_object_names.add(project_name) 84 | return True 85 | 86 | return False 87 | 88 | objects = list(filter(filter_object_names, objects)) 89 | 90 | return objects 91 | -------------------------------------------------------------------------------- /kubetools/dev/__init__.py: -------------------------------------------------------------------------------- 1 | import click 2 | 3 | from kubetools import __version__ 4 | from kubetools.config import load_kubetools_config 5 | from kubetools.log import setup_logging 6 | from kubetools.settings import get_settings 7 | 8 | from . import backends # noqa 9 | 10 | 11 | @click.group() 12 | @click.option( 13 | '--env', 14 | envvar='KUBETOOLS_DEV_ENV', 15 | default=None, 16 | help='Override environment name.', 17 | ) 18 | @click.option('--debug', is_flag=True) 19 | @click.version_option(version=__version__, message='%(prog)s: v%(version)s') 20 | @click.pass_context 21 | def dev(ctx, env, debug=False): 22 | ''' 23 | Kubetools dev client - develop apps with Docker. 24 | ''' 25 | 26 | setup_logging(debug) 27 | 28 | settings = get_settings() 29 | 30 | if not env: 31 | env = settings.DEV_DEFAULT_ENV 32 | 33 | # Get the config and attach it to the context 34 | ctx.obj = load_kubetools_config(env=env, dev=True) 35 | -------------------------------------------------------------------------------- /kubetools/dev/__main__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | from kubetools.dev import dev 4 | from kubetools.main import run_cli 5 | 6 | # Import click command groups 7 | from kubetools.dev import ( # noqa: F401, I100, I202 8 | container, 9 | environment, 10 | logs, 11 | scripts, 12 | ) 13 | 14 | 15 | run_cli(dev) 16 | -------------------------------------------------------------------------------- /kubetools/dev/backend.py: -------------------------------------------------------------------------------- 1 | # dummy file - see ./backends/__init__.py for actual module 2 | -------------------------------------------------------------------------------- /kubetools/dev/backends/__init__.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | from kubetools.settings import get_settings 4 | 5 | from . import docker_compose 6 | 7 | 8 | DEV_BACKEND_PROVIDERS = { 9 | 'docker_compose': docker_compose, 10 | } 11 | 12 | 13 | # Now, using settings splice in the .backend module to match the provider 14 | settings = get_settings() 15 | 16 | try: 17 | backend = DEV_BACKEND_PROVIDERS[settings.DEV_BACKEND] 18 | except KeyError: 19 | raise KeyError('Invalid dev backend: {0}'.format(settings.DEV_BACKEND)) 20 | 21 | sys.modules['kubetools.dev.backend'] = backend 22 | backend.init_backend() 23 | -------------------------------------------------------------------------------- /kubetools/dev/backends/docker_compose/config.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | from collections import OrderedDict 4 | from functools import lru_cache 5 | from hashlib import md5 6 | from os import makedirs, path 7 | 8 | import click 9 | import yaml 10 | 11 | from kubetools.exceptions import KubeDevError 12 | from kubetools.settings import get_settings 13 | 14 | NON_COMPOSE_KEYS = ( 15 | # Kubetools specific 16 | 'minKubetoolsVersion', 17 | 18 | # Kubetools dev specific 19 | 'devScripts', 20 | 'containerContext', 21 | 22 | # Kubetools -> Kubernetes specific 23 | 'servicePorts', 24 | 'probes', 25 | 'resources', 26 | 27 | # Kubernetes specific 28 | 'livenessProbe', 29 | 'readinessProbe', 30 | 31 | # Internal flags for `ktd status` 32 | 'is_deployment', 33 | 'is_dependency', 34 | ) 35 | 36 | CONTAINER_KEYS = ('dependencies', 'deployments') 37 | CONTAINER_KEY_TO_FLAG = { 38 | 'dependencies': 'is_dependency', 39 | 'deployments': 'is_deployment', 40 | } 41 | 42 | 43 | def dockerise_label(value): 44 | # Unfortunate egg from Docker engine pre label support, see: 45 | # https://github.com/docker/compose/issues/2119 46 | return re.sub(r'[^a-z0-9]', '', value.lower()) 47 | 48 | 49 | def get_project_name(kubetools_config): 50 | name = kubetools_config['name'] 51 | env = kubetools_config['env'] 52 | 53 | # Compose name is APP-ENV 54 | return '-'.join((name, env)) 55 | 56 | 57 | def get_compose_name(kubetools_config): 58 | name_env = get_project_name(kubetools_config) 59 | return dockerise_label(name_env) 60 | 61 | 62 | def get_compose_dirname(kubetools_config): 63 | settings = get_settings() 64 | return path.join( 65 | path.dirname(kubetools_config['_filename']), 66 | settings.DEV_CONFIG_DIRNAME, 67 | ) 68 | 69 | 70 | def get_compose_filename(kubetools_config): 71 | env = kubetools_config['env'] 72 | compose_filename = '{0}-compose.yml'.format(env) 73 | return path.join(get_compose_dirname(kubetools_config), compose_filename) 74 | 75 | 76 | def get_all_containers(kubetools_config, container_keys=CONTAINER_KEYS): 77 | containers = [] 78 | 79 | for key_name in container_keys: 80 | deployments = kubetools_config.get(key_name, {}) 81 | 82 | for deployment_name, deployment_config in deployments.items(): 83 | if 'containers' not in deployment_config: 84 | raise KubeDevError('Deployment {0} is missing containers'.format( 85 | deployment_name, 86 | )) 87 | 88 | deployment_containers = deployment_config['containers'] 89 | 90 | for container_name, config in deployment_containers.items(): 91 | config[CONTAINER_KEY_TO_FLAG[key_name]] = True 92 | containers.append((container_name, config)) 93 | 94 | return containers 95 | 96 | 97 | def get_all_containers_by_name(kubetools_config, container_keys=CONTAINER_KEYS): 98 | return OrderedDict(get_all_containers( 99 | kubetools_config, 100 | container_keys=container_keys, 101 | )) 102 | 103 | 104 | def _create_compose_service(kubetools_config, name, config, envvars=None): 105 | for invalid_build_key in ( 106 | 'preBuildCommands', 107 | 'registry', 108 | ): 109 | if invalid_build_key in config.get('build', {}): 110 | config['build'].pop(invalid_build_key) 111 | 112 | # Because this is one of our containers (buildContexts are relevant to 113 | # the project) - setup a TTY and STDIN so we can attach and be interactive 114 | # when attached (eg ipdb). 115 | service = { 116 | 'tty': True, 117 | 'stdin_open': True, 118 | 'labels': { 119 | 'kubetools.project.name': kubetools_config['name'], 120 | 'kubetools.project.env': kubetools_config['env'], 121 | }, 122 | } 123 | 124 | service.update(config) 125 | 126 | # Translate k8s command/args to docker-compose entrypoint/command 127 | if 'command' in service: 128 | service['entrypoint'] = service.pop('command') 129 | if 'args' in config: 130 | service['command'] = service.pop('args') 131 | 132 | if 'build' in service and 'context' not in service['build']: 133 | service['build']['context'] = '.' 134 | 135 | if 'ports' in config: 136 | # Generate a consistent base port for this project/container combo 137 | hash_string = '{0}-{1}'.format(get_project_name(kubetools_config), name) 138 | 139 | # MD5 the string, integer that and then modulus 10k to shorten it down 140 | port_base = int(md5(hash_string.encode('utf-8')).hexdigest(), 16) % 10000 141 | # And bump by 10k so we don't stray into the privileged port range (<1025) 142 | port_base += 10000 143 | 144 | # Reassign ports with explicit host port numbers 145 | ports = [] 146 | 147 | for port in config['ports']: 148 | if isinstance(port, dict): 149 | port = port['port'] 150 | 151 | ports.append( 152 | '{0}:{1}'.format(int(port_base) + int(port), port), 153 | ) 154 | 155 | service['ports'] = ports 156 | 157 | # Make our service - drop anything kubernetes or kubetools specific 158 | service = { 159 | key: value 160 | for key, value in service.items() 161 | if key not in NON_COMPOSE_KEYS 162 | } 163 | 164 | # Provide project-specific aliases for all containers. This means we can up 165 | # the same containers (eg mariadb x2) under the same dev network, but have 166 | # each app speak to it's own mariadb instance (eg app-mariadb). 167 | compose_name = kubetools_config['name'] 168 | 169 | service['networks'] = { 170 | 'default': { 171 | 'aliases': [ 172 | '{0}-{1}'.format(compose_name, name), 173 | ], 174 | }, 175 | } 176 | 177 | # Add any *missing* extra envvars 178 | if envvars: 179 | environment = service.setdefault('environment', []) 180 | for envar in envvars: 181 | if envar not in environment: 182 | environment.append(envar) 183 | 184 | return service 185 | 186 | 187 | @lru_cache(maxsize=1) 188 | def get_dev_network_environment_variables(): 189 | # This "fixes" a horrible circular dependency between config/docker_util 190 | from .docker_util import get_all_docker_dev_network_containers 191 | containers = get_all_docker_dev_network_containers() 192 | 193 | envvars = set() 194 | 195 | for container in containers: 196 | networks = container.attrs['NetworkSettings']['Networks'] 197 | if 'dev' not in networks: 198 | continue 199 | 200 | aliases = networks['dev']['Aliases'] 201 | for alias in aliases: 202 | if '-' in alias: 203 | break 204 | else: # no alias with "-" 205 | continue 206 | 207 | envar = alias.upper().replace('-', '_') 208 | envvars.add('DEV_{0}={1}'.format(envar, alias)) 209 | return list(envvars) 210 | 211 | 212 | def create_compose_config(kubetools_config): 213 | # If we're not in a custom env, everything sits on the "dev" network. Envs 214 | # remain encapsulated inside their own network. 215 | DEV_DEFAULT_ENV = get_settings().DEV_DEFAULT_ENV 216 | ktd_env = kubetools_config.get('env', DEV_DEFAULT_ENV) 217 | dev_network = ktd_env == DEV_DEFAULT_ENV 218 | 219 | all_containers = get_all_containers(kubetools_config) 220 | 221 | envvars = [ 222 | 'KTD_ENV={0}'.format(ktd_env), 223 | ] 224 | 225 | dev_network_envvars = None 226 | if dev_network: 227 | dev_network_envvars = get_dev_network_environment_variables() 228 | if dev_network_envvars: 229 | envvars.extend(dev_network_envvars) 230 | 231 | click.echo('--> Injecting environment variables:') 232 | for envar in envvars: 233 | click.echo(' {0}'.format(envar)) 234 | 235 | services = { 236 | name: _create_compose_service( 237 | kubetools_config, name, config, 238 | envvars=envvars, 239 | ) 240 | for name, config in all_containers 241 | } 242 | 243 | compose_config = { 244 | 'version': '3', 245 | 'services': services, 246 | } 247 | 248 | if dev_network: 249 | compose_config['networks'] = { 250 | 'default': { 251 | 'external': { 252 | 'name': 'dev', 253 | }, 254 | }, 255 | } 256 | 257 | yaml_data = yaml.safe_dump(compose_config) 258 | 259 | compose_dirname = get_compose_dirname(kubetools_config) 260 | if not path.exists(compose_dirname): 261 | makedirs(compose_dirname) 262 | 263 | with click.open_file(get_compose_filename(kubetools_config), 'w') as f: 264 | f.write(yaml_data) 265 | -------------------------------------------------------------------------------- /kubetools/dev/backends/docker_compose/docker_util.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | from functools import lru_cache 4 | 5 | import docker 6 | import requests 7 | 8 | from kubetools.dev.process_util import run_process 9 | from kubetools.exceptions import KubeDevError 10 | from kubetools.log import logger 11 | 12 | from .config import ( 13 | create_compose_config, 14 | dockerise_label, 15 | get_all_containers, 16 | get_compose_filename, 17 | get_compose_name, 18 | ) 19 | 20 | 21 | @lru_cache(maxsize=1) 22 | def get_docker_client(): 23 | client = docker.from_env() 24 | 25 | try: 26 | client.ping() 27 | 28 | except (docker.errors.APIError, requests.exceptions.ConnectionError) as e: 29 | raise KubeDevError(( 30 | 'Could not connect to Docker, is it running?\n' 31 | 'Error: {0}' 32 | ).format(e)) 33 | 34 | return client 35 | 36 | 37 | def ensure_docker_dev_network(): 38 | ''' 39 | Ensure we have a dev Docker network for all our containers to belong to. 40 | ''' 41 | 42 | docker_client = get_docker_client() 43 | 44 | try: 45 | new_network = docker_client.networks.create(name='dev', check_duplicate=True) 46 | except docker.errors.APIError as e: 47 | if e.status_code != 409: 48 | raise 49 | else: 50 | # There's no guarantee that check_duplicate will be enforced. 51 | # If we still created a duplicate, remove it 52 | dev_network_ids = [ 53 | network.id 54 | for network in docker_client.networks.list() 55 | if network.name == 'dev' 56 | ] 57 | if len(dev_network_ids) > 1: 58 | new_network.remove() 59 | 60 | 61 | def get_all_docker_dev_network_containers(): 62 | ''' 63 | Gets the container names for everything using the global dev network. 64 | 65 | Note only containers created with the default "dev" env become part of this 66 | network - any custom env (eg when testing or `--env`) won't be included. 67 | ''' 68 | 69 | docker_client = get_docker_client() 70 | 71 | labels = [ 72 | # Ignore containers created by run/exec/etc (not services) 73 | 'com.docker.compose.oneoff=False', 74 | # Load anything from a compose project 75 | 'com.docker.compose.project', 76 | ] 77 | 78 | docker_containers = docker_client.containers.list(all=True, filters={ 79 | 'label': labels, 80 | }) 81 | 82 | return docker_containers 83 | 84 | 85 | def get_containers_status( 86 | kubetools_config, 87 | container_name=None, 88 | all_environments=False, 89 | ): 90 | ''' 91 | Get the status of any containers for the current Kubetools project. 92 | ''' 93 | 94 | docker_client = get_docker_client() 95 | 96 | labels = [ 97 | # Ignore containers created by run/exec/etc (not services) 98 | 'com.docker.compose.oneoff=False', 99 | ] 100 | 101 | if all_environments: 102 | # Filter by *any* docker compose - we filter out containers that don't 103 | # belong to this project below - this maintains compatability with 104 | # kubetools <8 that didn't write the kubetools.project.env label. 105 | labels.append('com.docker.compose.project') 106 | else: 107 | # Filter by the project/env name to only fetch this envs containers 108 | labels.append('com.docker.compose.project={0}'.format( 109 | get_compose_name(kubetools_config), 110 | )) 111 | 112 | if container_name: 113 | labels.append( 114 | 'com.docker.compose.service={0}'.format(container_name), 115 | ) 116 | 117 | logger.debug('Listing Docker containers with labels={0}'.format(labels)) 118 | docker_containers = docker_client.containers.list(all=True, filters={ 119 | 'label': labels, 120 | }) 121 | 122 | env_to_containers = {} 123 | docker_name = dockerise_label(kubetools_config['name']) 124 | 125 | for container in docker_containers: 126 | compose_project = container.labels['com.docker.compose.project'] 127 | if all_environments: 128 | if not compose_project.startswith(docker_name): 129 | continue 130 | 131 | # For old Kubetools versions (<8) this label won't exist 132 | kubetools_name = container.labels.get('kubetools.project.name') 133 | if kubetools_name and kubetools_config['name'] != kubetools_name: 134 | continue 135 | 136 | env = container.labels.get('kubetools.project.env') 137 | # Compatability for existing containers created with kubetools <8 138 | if not env: 139 | env = compose_project.replace(docker_name, '') 140 | 141 | # Where the name is compose-name_container_N, get container 142 | name = container.name.split('_')[1] 143 | 144 | status = container.status == 'running' 145 | ports = [] 146 | 147 | if container.attrs['NetworkSettings']['Ports']: 148 | for ( 149 | local_port, host_port, 150 | ) in container.attrs['NetworkSettings']['Ports'].items(): 151 | if not host_port: 152 | continue 153 | 154 | ports.append({ 155 | 'local': local_port, 156 | 'host': host_port[0]['HostPort'], 157 | }) 158 | 159 | container_data = { 160 | 'up': status, 161 | 'ports': ports, 162 | 'id': container.id, 163 | 'labels': container.labels, 164 | } 165 | 166 | env_containers = env_to_containers.setdefault(env, {}) 167 | 168 | if name in env_containers: 169 | raise ValueError(( 170 | 'Duplicate container for env {0}!: {1}({2})' 171 | ).format(env, name, container_data)) 172 | 173 | env_containers[name] = container_data 174 | 175 | # Always provide the current environment, even if it's empty 176 | current_env = kubetools_config['env'] 177 | if current_env not in env_to_containers: 178 | env_to_containers[kubetools_config['env']] = {} 179 | 180 | for env, containers in env_to_containers.items(): 181 | for name, container in get_all_containers(kubetools_config): 182 | if name not in containers: 183 | containers[name] = { 184 | 'up': None, 185 | 'id': None, 186 | 'ports': [], 187 | } 188 | 189 | containers[name]['is_dependency'] = container.get('is_dependency', False) 190 | containers[name]['is_deployment'] = container.get('is_deployment', False) 191 | 192 | if all_environments: 193 | return env_to_containers 194 | return env_to_containers.get(kubetools_config['env'], {}) 195 | 196 | 197 | def get_container_status(kubetools_config, name): 198 | containers = get_containers_status(kubetools_config, container_name=name) 199 | return containers.get(name) 200 | 201 | 202 | def run_compose_process(kubetools_config, command_args, **kwargs): 203 | # Ensure we have a compose file for this config 204 | create_compose_config(kubetools_config) 205 | 206 | compose_command = [ 207 | # Use current interpreter to run the docker-compose module installed in the same venv 208 | sys.executable, '-m', 'compose', 209 | # Force us to look at the current directory, not relative to the compose 210 | # filename (ie .kubetools/compose-name.yml). 211 | '--project-directory', '.', 212 | # Name of the project (for the com.docker.compose.projectname label) 213 | '--project-name', get_compose_name(kubetools_config), 214 | # Filename of the YAML file to load 215 | '--file', get_compose_filename(kubetools_config), 216 | ] 217 | compose_command.extend(command_args) 218 | 219 | return run_process(compose_command, **kwargs) 220 | -------------------------------------------------------------------------------- /kubetools/dev/container.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import click 4 | 5 | from . import dev 6 | from .backend import ( 7 | build_containers, 8 | exec_container, 9 | get_container_status, 10 | run_container, 11 | ) 12 | 13 | 14 | @dev.command() 15 | @click.argument('container') 16 | @click.option('--shell', default='sh') 17 | @click.pass_obj 18 | def enter(kubetools_config, container, shell): 19 | ''' 20 | Enter a shell in a running container. 21 | ''' 22 | 23 | click.echo('--> Entering {0}'.format(click.style(container, bold=True))) 24 | exec_container(kubetools_config, container, [shell]) 25 | 26 | 27 | @dev.command() 28 | @click.argument('container') 29 | @click.pass_obj 30 | def attach(kubetools_config, container): 31 | ''' 32 | Attach to the main process in a container. 33 | ''' 34 | 35 | container_status = get_container_status(kubetools_config, container) 36 | 37 | click.echo('--> Attaching to {0}'.format(click.style(container, bold=True))) 38 | click.echo(' use ctrl + p, ctrl + q to escape') 39 | 40 | # We *don't* use run_process here because when you escape the process exits 41 | # with an error code (go docker). 42 | os.system('docker attach {0}'.format(container_status['id'])) 43 | 44 | 45 | @dev.command(name='exec') 46 | @click.argument('container') 47 | @click.argument( 48 | 'command', 49 | nargs=-1, 50 | required=True, 51 | ) 52 | @click.pass_obj 53 | def exec_(kubetools_config, container, command): 54 | ''' 55 | Run a command in an existing container. 56 | ''' 57 | 58 | click.echo('--> Building any out of date containers') 59 | build_containers(kubetools_config, [container]) 60 | click.echo() 61 | 62 | click.echo('--> Executing in {0}: {1}'.format(container, command)) 63 | return exec_container(kubetools_config, container, command) 64 | 65 | 66 | @dev.command() 67 | @click.argument('container') 68 | @click.argument( 69 | 'command', 70 | nargs=-1, 71 | required=True, 72 | ) 73 | @click.option( 74 | 'envvars', '-e', '--envvar', 75 | '--envar', # legacy support TODO: remove! 76 | multiple=True, 77 | help='Environment variables to pass into the container.', 78 | ) 79 | @click.pass_obj 80 | def run(kubetools_config, container, command, envvars=None): 81 | ''' 82 | Run a command in a new container. 83 | ''' 84 | 85 | click.echo('--> Building any out of date containers') 86 | build_containers(kubetools_config, [container]) 87 | click.echo() 88 | 89 | click.echo('--> Running in {0}: {1}'.format(container, command)) 90 | return run_container(kubetools_config, container, command, envvars=envvars) 91 | -------------------------------------------------------------------------------- /kubetools/dev/environment.py: -------------------------------------------------------------------------------- 1 | import click 2 | 3 | from kubetools.exceptions import KubeDevError 4 | from kubetools.settings import get_settings 5 | 6 | from . import dev 7 | from .backend import ( 8 | build_containers, 9 | destroy_containers, 10 | find_container_for_config, 11 | get_containers_status, 12 | print_containers, 13 | run_container, 14 | start_containers, 15 | stop_containers, 16 | up_containers, 17 | ) 18 | 19 | 20 | @dev.command() 21 | @click.option( 22 | '--active-only', '--active', 23 | is_flag=True, 24 | help='Only show containers from the active environment (--env).', 25 | ) 26 | @click.pass_obj 27 | def status(kubetools_config, active_only): 28 | ''' 29 | Display info on the local dev environment. 30 | ''' 31 | 32 | click.echo('--> Loading environments...') 33 | print_containers(kubetools_config, all_environments=not active_only) 34 | 35 | 36 | @dev.command() 37 | @click.argument('containers', nargs=-1) 38 | @click.pass_obj 39 | def destroy(kubetools_config, containers=None): 40 | ''' 41 | Stop and remove containers. 42 | ''' 43 | 44 | click.echo('--> Destroying containers') 45 | destroy_containers(kubetools_config, containers) 46 | click.echo('--> Dev environment down') 47 | 48 | 49 | @dev.command() 50 | @click.argument('containers', nargs=-1) 51 | @click.pass_obj 52 | def start(kubetools_config, containers=None): 53 | ''' 54 | Start stopped containers. 55 | ''' 56 | 57 | click.echo('--> Starting containers') 58 | start_containers(kubetools_config, containers) 59 | click.echo('--> Containers started') 60 | click.echo() 61 | 62 | # Always print all containers 63 | print_containers(kubetools_config) 64 | 65 | 66 | @dev.command() 67 | @click.argument('containers', nargs=-1) 68 | @click.pass_obj 69 | def stop(kubetools_config, containers=None): 70 | ''' 71 | Stop running containers. 72 | ''' 73 | 74 | click.echo('--> Stopping containers') 75 | stop_containers(kubetools_config, containers) 76 | click.echo('--> Containers stopped') 77 | click.echo() 78 | 79 | # Always print all containers 80 | print_containers(kubetools_config) 81 | 82 | 83 | @dev.command() 84 | @click.argument('containers', nargs=-1) 85 | @click.pass_context 86 | def restart(ctx, containers): 87 | ''' 88 | Restart containers. 89 | ''' 90 | 91 | ctx.invoke(stop, containers=containers) 92 | ctx.invoke(start, containers=containers) 93 | 94 | 95 | @dev.command() 96 | @click.argument('containers', nargs=-1) 97 | @click.option('destroy_containers', '--destroy', is_flag=True) 98 | @click.option('--no-upgrade', is_flag=True) 99 | @click.pass_obj 100 | @click.pass_context 101 | def reload( 102 | ctx, kubetools_config, 103 | containers=None, 104 | destroy_containers=False, 105 | no_upgrade=False, 106 | ): 107 | ''' 108 | Reload the dev environment. 109 | ''' 110 | 111 | if destroy_containers: 112 | ctx.invoke(destroy, containers=containers) 113 | else: 114 | ctx.invoke(stop, containers=containers) 115 | 116 | click.echo() 117 | 118 | # Then up with all the arguments/options 119 | ctx.invoke(up, containers=containers, no_upgrade=no_upgrade) 120 | 121 | 122 | @dev.command() 123 | @click.argument('containers', nargs=-1) 124 | @click.option( 125 | '--no-upgrade', 126 | is_flag=True, 127 | help='Disable running of any upgrades.', 128 | ) 129 | @click.pass_obj 130 | def up( 131 | kubetools_config, 132 | containers=None, 133 | no_upgrade=False, 134 | is_testing=False, 135 | ): 136 | ''' 137 | Create and/or start containers and run any upgrades. 138 | ''' 139 | 140 | container_statuses = get_containers_status(kubetools_config) 141 | 142 | # Figure out containers, if any, to upgrade 143 | upgrade_containers = [] 144 | if not no_upgrade: 145 | if containers: 146 | upgrade_containers = containers 147 | else: 148 | upgrade_containers = [ 149 | container_name 150 | for container_name, container in container_statuses.items() 151 | ] 152 | 153 | # Build the container(s) 154 | click.echo('--> Building any out of date containers') 155 | build_containers(kubetools_config, containers) 156 | click.echo() 157 | 158 | # Up the container(s) 159 | click.echo('--> Starting containers') 160 | up_containers(kubetools_config, containers) 161 | 162 | click.echo('--> Dev environment up') 163 | click.echo() 164 | 165 | if upgrade_containers: 166 | click.echo('--> Running upgrades') 167 | 168 | for upgrade in kubetools_config.get('upgrades', []): 169 | apply_when_testing = upgrade.get('conditions', {}).get('test', True) 170 | if is_testing and not apply_when_testing: 171 | continue 172 | 173 | container_name = find_container_for_config( 174 | kubetools_config, upgrade, 175 | ) 176 | 177 | if containers and container_name not in upgrade_containers: 178 | continue 179 | 180 | click.echo('--> Running upgrade {0} in container {1}'.format( 181 | click.style(upgrade.get( 182 | 'name', 183 | ' '.join(upgrade['command']), 184 | ), bold=True), 185 | container_name, 186 | )) 187 | 188 | run_container( 189 | kubetools_config, 190 | container_name, 191 | upgrade['command'], 192 | ) 193 | click.echo() 194 | 195 | # Always print all containers 196 | print_containers(kubetools_config) 197 | click.echo() 198 | 199 | click.echo(''.join(( 200 | click.style('Use `', 'blue'), 201 | click.style('ktd logs', bold=True), 202 | click.style('` to see what the containers are up to', 'blue'), 203 | ))) 204 | click.echo(''.join(( 205 | click.style('Use `', 'blue'), 206 | click.style('ktd attach ', bold=True), 207 | click.style('` to attach to a running container', 'blue'), 208 | ))) 209 | 210 | 211 | @dev.command() 212 | @click.argument('arguments', nargs=-1) 213 | @click.option( 214 | '--keep-containers', 215 | is_flag=True, 216 | help="Don't remove any test environment containers after completion", 217 | ) 218 | @click.pass_obj 219 | @click.pass_context 220 | def test(ctx, kubetools_config, keep_containers=False, arguments=None): 221 | ''' 222 | Execute tests in a new environment. 223 | 224 | You can pass extra arguments to the test command like so: 225 | 226 | ktd test -- --ipdb-failures 227 | ''' 228 | 229 | DEV_DEFAULT_ENV = get_settings().DEV_DEFAULT_ENV 230 | 231 | # Set the env to test if not already overridden 232 | if kubetools_config.get('env', DEV_DEFAULT_ENV) == DEV_DEFAULT_ENV: 233 | kubetools_config['env'] = 'test' 234 | 235 | try: 236 | ctx.invoke(up, is_testing=True) 237 | 238 | click.echo() 239 | click.echo('--> Executing tests...') 240 | 241 | tests = kubetools_config.get('tests') 242 | 243 | if not tests: 244 | raise KubeDevError('No tests provided in kubetools config!') 245 | 246 | for test in tests: 247 | command = test['command'] 248 | 249 | if arguments: 250 | command.extend(arguments) 251 | 252 | container_name = find_container_for_config( 253 | kubetools_config, test, 254 | ) 255 | 256 | click.echo('--> Running test {0} in container {1}'.format( 257 | click.style(test.get( 258 | 'name', 259 | ' '.join(command), 260 | ), bold=True), 261 | container_name, 262 | )) 263 | 264 | run_container( 265 | kubetools_config, 266 | container_name, 267 | command, 268 | envvars=test.get('environment', []), 269 | ) 270 | click.echo() 271 | 272 | except Exception: 273 | click.echo(click.style('Exception!', 'red', bold=True)) 274 | raise 275 | 276 | else: 277 | click.echo(click.style('--> Tests complete', 'green')) 278 | 279 | finally: 280 | if not keep_containers: 281 | ctx.invoke(destroy) 282 | -------------------------------------------------------------------------------- /kubetools/dev/logs.py: -------------------------------------------------------------------------------- 1 | import click 2 | 3 | from . import dev 4 | from .backend import ( 5 | follow_logs, 6 | get_all_containers_by_name, 7 | ) 8 | 9 | DEFAULT_LOG_LINES = 5 10 | 11 | 12 | @dev.command() 13 | @click.argument('containers', nargs=-1) 14 | @click.option( 15 | 'all_containers', '--all', 16 | is_flag=True, 17 | help='Show the output of all containers, not just app containers', 18 | ) 19 | @click.option( 20 | '-n', '--number-of-lines', 21 | type=int, 22 | default=DEFAULT_LOG_LINES, 23 | help='Number of lines of history to output', 24 | ) 25 | @click.option( 26 | '--with-history', 27 | is_flag=True, 28 | help='Show all of the output history rather than `-n` lines', 29 | ) 30 | @click.pass_obj 31 | def logs( 32 | kubetools_config, 33 | containers, all_containers, 34 | number_of_lines, with_history, 35 | ): 36 | ''' 37 | Follow logs for the dev environment. 38 | ''' 39 | 40 | click.echo(click.style( 41 | '--> Following dev environment logs...', 'blue', 42 | )) 43 | 44 | # Show all the top level containers 45 | if not containers and not all_containers: 46 | containers = list(get_all_containers_by_name( 47 | kubetools_config, 48 | ('deployments',), 49 | ).keys()) 50 | 51 | tail = number_of_lines 52 | if with_history: 53 | # "all" is a special value for `docker-compose logs tail=X` 54 | tail = 'all' 55 | if number_of_lines != DEFAULT_LOG_LINES: 56 | click.echo(click.style( 57 | '-n={0} overridden by --with-history'.format(number_of_lines), 58 | 'yellow', 59 | )) 60 | 61 | follow_logs(kubetools_config, containers, tail=tail) 62 | -------------------------------------------------------------------------------- /kubetools/dev/process_util.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import re 3 | 4 | from subprocess import CalledProcessError, PIPE, Popen, STDOUT 5 | from threading import Thread 6 | 7 | from kubetools.cli.server_util import UPDATE_DIVISOR, wait_with_spinner 8 | from kubetools.exceptions import KubeDevCommandError 9 | from kubetools.log import logger 10 | 11 | 12 | def _read_command_output(command, output_lines): 13 | # Read the commands output indefinitely 14 | while True: 15 | stdout_line = command.stdout.readline() 16 | 17 | if stdout_line: 18 | # Remove any trailing newline 19 | stdout_line = stdout_line.decode().strip('\n') 20 | # # Strip non-alphanumeric characters 21 | # stdout_line = re.sub(r'[^a-zA-Z0-9_\.\-\s]', '', stdout_line) 22 | 23 | # Strip any ANSI escape characters 24 | stdout_line = re.sub( 25 | r'(\x9B|\x1B\[)[0-?]*[ -\/]*[@-~]', 26 | '', 27 | stdout_line, 28 | ) 29 | 30 | output_lines.append(stdout_line) 31 | 32 | # No line from the command? We're done! 33 | else: 34 | break 35 | 36 | 37 | def _run_process_with_spinner(args): 38 | command = Popen( 39 | ' '.join(args), 40 | stdout=PIPE, 41 | stderr=STDOUT, 42 | close_fds=True, 43 | shell=True, 44 | ) 45 | 46 | # Buffers 47 | output_lines = [] 48 | 49 | command_reader = Thread( 50 | target=_read_command_output, 51 | args=(command, output_lines), 52 | ) 53 | command_reader.start() 54 | 55 | def check_status(previous_status): 56 | # Command complete (we've read everything)? Exit here 57 | if not command_reader.is_alive(): 58 | return 59 | 60 | if output_lines: 61 | return output_lines[-1] 62 | 63 | return previous_status 64 | 65 | wait_with_spinner( 66 | check_status, 67 | # This means we run the get_line check every .5 seconds 68 | check_status_divisor=(UPDATE_DIVISOR / 2), 69 | ) 70 | 71 | # Re-join the stdout/stderr lines 72 | stdout = '\n'.join(output_lines) 73 | 74 | # Poll the command to populate it's return code 75 | command.poll() 76 | 77 | # Ensure the command is dead 78 | try: 79 | command.terminate() 80 | command.kill() 81 | 82 | # If already dead, just ignore 83 | except Exception: 84 | pass 85 | 86 | return command.returncode, stdout 87 | 88 | 89 | def run_process(args, env=None, hide_output=False): 90 | if logger.level <= logging.DEBUG: # always show output when debugging 91 | hide_output = False 92 | 93 | logger.debug('--> Executing: {0}'.format(' '.join(args))) 94 | 95 | try: 96 | # If we're capturing output - things are more complicated. We need to spawn 97 | # the subprocess in a thread and read its output into two lists, which we 98 | # then rejoin to return. 99 | if hide_output: 100 | code, stdout = _run_process_with_spinner(args) 101 | 102 | # Inline? Simply start the process and "communicate", this will print stdout 103 | # and stderr to the terminal and also capture them into variables. 104 | else: 105 | command = Popen(args, env=env, stderr=STDOUT, close_fds=True) 106 | stdout, _ = command.communicate() 107 | code = command.returncode 108 | 109 | if code > 0: 110 | raise KubeDevCommandError( 111 | 'External process failed: {0}'.format(args), 112 | stdout, 113 | ) 114 | 115 | return stdout 116 | 117 | except (CalledProcessError, OSError) as e: 118 | raise KubeDevCommandError( 119 | 'External process failed: {0}'.format(args), 120 | getattr(e, 'output', e), 121 | ) 122 | -------------------------------------------------------------------------------- /kubetools/dev/scripts.py: -------------------------------------------------------------------------------- 1 | from os import environ, path 2 | 3 | import click 4 | 5 | from kubetools.exceptions import KubeDevError 6 | from kubetools.settings import get_settings, get_settings_directory 7 | 8 | from . import dev 9 | from .backend import ( 10 | get_all_containers, 11 | get_all_containers_by_name, 12 | get_container_status, 13 | ) 14 | from .process_util import run_process 15 | 16 | 17 | def _list_scripts(kubetools_config): 18 | ''' 19 | List available dev scripts. 20 | ''' 21 | 22 | settings = get_settings() 23 | click.echo('--> Containers & scripts:') 24 | 25 | for name, container in get_all_containers(kubetools_config): 26 | if 'devScripts' not in container: 27 | continue 28 | 29 | click.echo(' {0}:'.format(click.style(name, bold=True))) 30 | 31 | for script in container['devScripts']: 32 | if script in settings.scripts: 33 | click.echo(' - {0}'.format(click.style(script, 'green'))) 34 | else: 35 | click.echo(' - {0} (not found)'.format( 36 | click.style(script, 'red'), 37 | )) 38 | 39 | 40 | @dev.command() 41 | @click.argument('container', required=False) 42 | @click.argument('script', required=False) 43 | @click.pass_obj 44 | def script(kubetools_config, container=None, script=None): 45 | ''' 46 | List and execute scripts. 47 | 48 | The script must be made available to the container in `kubetools.yml`. 49 | ''' 50 | 51 | if container is None or script is None: 52 | return _list_scripts(kubetools_config) 53 | 54 | settings = get_settings() 55 | config = get_all_containers_by_name(kubetools_config).get(container) 56 | 57 | if not config: 58 | raise KubeDevError('Invalid container: {0}'.format(container)) 59 | 60 | if script not in config.get('devScripts', []): 61 | raise KubeDevError('Script {0} is not available in container {1}'.format( 62 | script, container, 63 | )) 64 | 65 | script_path = path.join(get_settings_directory(), 'scripts', script) 66 | 67 | if script not in settings.scripts: 68 | raise KubeDevError('Could not locate local script (expected: {0})'.format( 69 | script_path, 70 | )) 71 | 72 | status = get_container_status(kubetools_config, container) 73 | if not status or not status['up']: 74 | raise KubeDevError('Container {0} is not online'.format(container)) 75 | 76 | script_env = environ.copy() 77 | script_env['APP_NAME'] = kubetools_config['name'] 78 | 79 | # Add the envvars defined in kubetools.yml 80 | for env in config.get('environment', []): 81 | key, value = env.split('=', 1) 82 | script_env[key] = value 83 | 84 | # Add PORT_N envvars mapping the container -> host ports 85 | for port in status['ports']: 86 | # Remove the /tcp, /udp bit 87 | local_port = port['local'].replace('/', '') 88 | host_port = port['host'].split(':')[-1] 89 | script_env['PORT_{0}'.format(local_port)] = host_port 90 | 91 | # Execute the script! 92 | run_process([script_path], env=script_env) 93 | -------------------------------------------------------------------------------- /kubetools/exceptions.py: -------------------------------------------------------------------------------- 1 | class KubeError(Exception): 2 | type = 'generic' 3 | 4 | 5 | # Config errors 6 | # 7 | 8 | class KubeConfigError(KubeError): 9 | type = 'config' 10 | 11 | 12 | # Client/server errors 13 | # 14 | 15 | class KubeClientError(KubeError): 16 | type = 'client' 17 | 18 | 19 | class KubeServerError(KubeError): 20 | type = 'server' 21 | 22 | 23 | class KubeCLIError(KubeError): 24 | type = 'cli' 25 | 26 | 27 | # Build errors 28 | # 29 | 30 | class KubeBuildError(KubeError): 31 | type = 'build' 32 | 33 | 34 | # Local/dev errors 35 | # 36 | 37 | class KubeDevError(KubeCLIError): 38 | type = 'dev' 39 | 40 | 41 | class KubeDevCommandError(KubeDevError): 42 | type = 'dev' 43 | -------------------------------------------------------------------------------- /kubetools/kubernetes/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EDITD/kubetools/e836c75768d9e4af9e13680afcd5164c6f4ed7a0/kubetools/kubernetes/__init__.py -------------------------------------------------------------------------------- /kubetools/kubernetes/config/container.py: -------------------------------------------------------------------------------- 1 | from .util import get_hash 2 | 3 | # Keys to turn into environment variables 4 | LABEL_ENVAR_KEYS = ( 5 | 'role', 6 | 'app', 7 | 'name', 8 | 'commit', 9 | 'project_name', 10 | 'git_name', 11 | 'manifest_name', 12 | ) 13 | ANNOTATION_ENVAR_KEYS = ('version',) 14 | 15 | 16 | def _make_probe_config(config): 17 | if 'httpGet' in config: 18 | # Ensure we have a HTTP port 19 | if 'port' not in config['httpGet']: 20 | config['httpGet']['port'] = 80 21 | 22 | # Ensure we have a HTTP path 23 | if 'path' not in config['httpGet']: 24 | config['httpGet']['path'] = '/' 25 | 26 | return config 27 | 28 | 29 | def make_container_config( 30 | name, container, 31 | envvars=None, labels=None, 32 | annotations=None, secrets=None, 33 | ): 34 | ''' 35 | Builds the common Kubernetes container config. 36 | ''' 37 | 38 | envvars = envvars or {} 39 | labels = labels or {} 40 | annotations = annotations or {} 41 | 42 | image = container['image'] 43 | 44 | container_data = { 45 | 'name': name, 46 | 47 | # Always pull the image from the registry 48 | 'imagePullPolicy': 'Always', 49 | 'image': image, 50 | 51 | # Environment flag we use to determine if app is in Kube 52 | 'env': [], 53 | } 54 | 55 | # Copy these keys as-is 56 | for key in ('livenessProbe', 'readinessProbe'): 57 | if key in container: 58 | container_data[key] = _make_probe_config(container.pop(key)) 59 | 60 | # Probes is a shortcut for both ready and live probes 61 | if 'probes' in container: 62 | probes = container.pop('probes') 63 | container_data['livenessProbe'] = _make_probe_config(probes) 64 | container_data['readinessProbe'] = _make_probe_config(probes) 65 | 66 | # Attach any of these labels as envvars 67 | for key in LABEL_ENVAR_KEYS: 68 | if key in labels: 69 | env_key = 'KUBETOOLS_{0}'.format(key.upper()) 70 | container_data['env'].append({ 71 | 'name': env_key, 72 | 'value': labels[key], 73 | }) 74 | 75 | # Attach any of these annotations as envvars 76 | for key in ANNOTATION_ENVAR_KEYS: 77 | if key in annotations: 78 | env_key = 'KUBETOOLS_{0}'.format(key.upper()) 79 | container_data['env'].append({ 80 | 'name': env_key, 81 | 'value': annotations[key], 82 | }) 83 | 84 | # Attach environment from the config 85 | if 'environment' in container: 86 | for item in container.pop('environment'): 87 | k, v = item.split('=') 88 | 89 | container_data['env'].append({ 90 | 'name': k, 91 | 'value': v, 92 | }) 93 | 94 | # Attach extra envvars 95 | if envvars: 96 | container_data['env'].extend([ 97 | { 98 | 'name': key, 99 | 'value': str(value), 100 | } 101 | for key, value in envvars.items() 102 | ]) 103 | 104 | # Apply any container-config level ENVars 105 | if container.get('env'): 106 | container_data['env'].extend([ 107 | { 108 | 'name': key, 109 | 'value': str(value), 110 | } 111 | for key, value in container['env'].items() 112 | ]) 113 | 114 | if 'volumes' in container: 115 | container_data.setdefault('volumeMounts', []) 116 | for volume in container.pop('volumes'): 117 | container_data['volumeMounts'].append({ 118 | 'mountPath': volume.split(':')[1], 119 | 'name': get_hash(volume), 120 | }) 121 | 122 | if secrets is not None: 123 | container_data.setdefault('volumeMounts', []) 124 | for secret_name, secret in secrets.items(): 125 | container_data['volumeMounts'].append({ 126 | 'name': secret_name, 127 | 'mountPath': secret.get('mountPath'), 128 | 'readonly': True, 129 | }) 130 | 131 | # Finally, attach all remaining data 132 | container_data.update(container) 133 | 134 | return container_data 135 | -------------------------------------------------------------------------------- /kubetools/kubernetes/config/cronjob.py: -------------------------------------------------------------------------------- 1 | import shlex 2 | 3 | from .container import make_container_config 4 | from .util import copy_and_update 5 | from .volume import make_secret_volume_config 6 | 7 | 8 | def make_cronjob_config( 9 | config, 10 | cronjob_name, 11 | cronjob_spec, 12 | containers, 13 | labels=None, 14 | annotations=None, 15 | envvars=None, 16 | ): 17 | ''' 18 | Builds a Kubernetes cronjob configuration dict. 19 | ''' 20 | 21 | labels = labels or {} 22 | annotations = annotations or {} 23 | 24 | node_selector_labels = cronjob_spec.get('nodeSelector', None) 25 | service_account_name = cronjob_spec.get('serviceAccountName', None) 26 | secrets = cronjob_spec.get('secrets', None) 27 | 28 | # Build our container list 29 | kubernetes_containers = [] 30 | for container_name, container in containers.items(): 31 | # Figure out the command 32 | command = container['command'] 33 | if isinstance(command, str): 34 | command = shlex.split(command) 35 | 36 | # Get/create description 37 | description = config.get('description', 'Run: {0}'.format(command)) 38 | 39 | # Attach description to annotations 40 | annotations = copy_and_update(annotations, { 41 | 'description': description, 42 | }) 43 | 44 | kubernetes_containers.append(make_container_config( 45 | container_name, container, 46 | envvars=envvars, 47 | labels=labels, 48 | annotations=annotations, 49 | secrets=secrets, 50 | )) 51 | 52 | template_spec = { 53 | 'restartPolicy': 'OnFailure', 54 | 'containers': kubernetes_containers, 55 | } 56 | 57 | if node_selector_labels is not None: 58 | template_spec['nodeSelector'] = node_selector_labels 59 | 60 | if service_account_name is not None: 61 | template_spec['serviceAccountName'] = service_account_name 62 | 63 | if secrets is not None: 64 | kubernetes_volumes = [] 65 | for secret_name, secret in secrets.items(): 66 | kubernetes_volumes.append(make_secret_volume_config( 67 | secret_name, secret, 68 | )) 69 | template_spec['volumes'] = kubernetes_volumes 70 | 71 | # The actual cronjob spec 72 | cronjob = { 73 | 'kind': 'CronJob', 74 | 'metadata': { 75 | 'name': cronjob_name, 76 | 'labels': labels, 77 | 'annotations': annotations, 78 | }, 79 | 'spec': { 80 | 'schedule': cronjob_spec['schedule'], 81 | 'startingDeadlineSeconds': 10, 82 | 'concurrencyPolicy': cronjob_spec['concurrency_policy'], 83 | 'jobTemplate': { 84 | 'spec': { 85 | 'template': { 86 | 'metadata': { 87 | 'name': cronjob_name, 88 | 'labels': labels, 89 | 'annotations': annotations, 90 | }, 91 | 'spec': template_spec, 92 | }, 93 | }, 94 | }, 95 | }, 96 | } 97 | batch_api_version = cronjob_spec.get('batch-api-version', None) 98 | if batch_api_version is not None: 99 | # Only set here if user has specified it in the config 100 | cronjob['apiVersion'] = batch_api_version 101 | 102 | successfulJobsHistoryLimit = cronjob_spec.get('successfulJobsHistoryLimit', None) 103 | if successfulJobsHistoryLimit is not None: 104 | cronjob['spec']['successfulJobsHistoryLimit'] = successfulJobsHistoryLimit 105 | failedJobsHistoryLimit = cronjob_spec.get('failedJobsHistoryLimit', None) 106 | if failedJobsHistoryLimit is not None: 107 | cronjob['spec']['failedJobsHistoryLimit'] = failedJobsHistoryLimit 108 | 109 | return cronjob 110 | -------------------------------------------------------------------------------- /kubetools/kubernetes/config/deployment.py: -------------------------------------------------------------------------------- 1 | from .container import make_container_config 2 | from .util import get_hash, make_dns_safe_name 3 | from .volume import make_secret_volume_config 4 | 5 | DEPLOYMENT_REVISION_LIMIT = 5 6 | 7 | 8 | def make_deployment_config( 9 | name, containers, 10 | replicas=1, 11 | labels=None, 12 | annotations=None, 13 | envvars=None, 14 | update_strategy=None, 15 | node_selector_labels=None, 16 | service_account_name=None, 17 | secrets=None, 18 | ): 19 | ''' 20 | Builds a Kubernetes deployment configuration dict. 21 | ''' 22 | 23 | labels = labels or {} 24 | annotations = annotations or {} 25 | 26 | # Build our container list 27 | kubernetes_containers = [] 28 | for container_name, container in containers.items(): 29 | kubernetes_containers.append(make_container_config( 30 | container_name, container, 31 | envvars=envvars, 32 | labels=labels, 33 | annotations=annotations, 34 | secrets=secrets, 35 | )) 36 | 37 | template_spec = { 38 | 'containers': kubernetes_containers, 39 | } 40 | 41 | if node_selector_labels is not None: 42 | template_spec['nodeSelector'] = node_selector_labels 43 | 44 | if service_account_name is not None: 45 | template_spec['serviceAccountName'] = service_account_name 46 | 47 | if secrets is not None: 48 | kubernetes_volumes = [] 49 | for secret_name, secret in secrets.items(): 50 | kubernetes_volumes.append(make_secret_volume_config( 51 | secret_name, secret, 52 | )) 53 | template_spec['volumes'] = kubernetes_volumes 54 | 55 | # The actual controller Kubernetes config 56 | controller = { 57 | 'apiVersion': 'apps/v1', 58 | 'kind': 'Deployment', 59 | 'metadata': { 60 | 'name': make_dns_safe_name(name), 61 | 'labels': labels, 62 | 'annotations': annotations, 63 | }, 64 | 'spec': { 65 | 'revisionHistoryLimit': DEPLOYMENT_REVISION_LIMIT, 66 | 'selector': { 67 | 'matchLabels': labels, 68 | }, 69 | 'replicas': replicas, 70 | 'template': { 71 | 'metadata': { 72 | 'labels': labels, 73 | }, 74 | 'spec': template_spec, 75 | }, 76 | }, 77 | } 78 | 79 | if update_strategy: 80 | controller['spec']['strategy'] = update_strategy 81 | 82 | container_volumes = {} 83 | for container in containers.values(): 84 | if 'volumes' in container: 85 | for volume in container['volumes']: 86 | name = get_hash(volume) 87 | container_volumes[name] = { 88 | 'name': name, 89 | 'hostPath': { 90 | 'path': volume.split(':')[0], 91 | }, 92 | } 93 | 94 | if container_volumes: # kube does not like an empty list 95 | controller['spec']['template']['spec']['volumes'] = container_volumes.values() 96 | 97 | for container in containers: 98 | if 'volumes' in container: 99 | for volume in container['volumes']: 100 | controller['spec']['template'].setdefault('volumes', []).append({ 101 | 'name': get_hash(volume), 102 | 'hostPath': { 103 | 'path': volume.split(':')[0], 104 | }, 105 | }) 106 | 107 | return controller 108 | -------------------------------------------------------------------------------- /kubetools/kubernetes/config/job.py: -------------------------------------------------------------------------------- 1 | import shlex 2 | from uuid import uuid4 3 | 4 | from .container import make_container_config 5 | from .util import copy_and_update 6 | from .volume import make_secret_volume_config 7 | 8 | 9 | def make_job_config( 10 | config, 11 | app_name=None, 12 | labels=None, 13 | annotations=None, 14 | envvars=None, 15 | job_name=None, 16 | container_name="upgrade", 17 | node_selector_labels=None, 18 | service_account_name=None, 19 | secrets=None, 20 | ): 21 | ''' 22 | Builds a Kubernetes job configuration dict. 23 | ''' 24 | 25 | # We want a copy of these because we'll modify them below 26 | labels = labels or {} 27 | envvars = envvars or {} 28 | annotations = annotations or {} 29 | 30 | # Generate name 31 | job_id = str(uuid4()) 32 | if job_name is None: 33 | job_name = job_id 34 | 35 | # Attach the ID to labels 36 | labels = copy_and_update(labels, { 37 | 'job-id': job_id, 38 | }) 39 | 40 | # Figure out the command 41 | command = config['command'] 42 | 43 | if isinstance(command, str): 44 | command = shlex.split(command) 45 | 46 | # Get/create description 47 | description = config.get('description', 'Run: {0}'.format(command)) 48 | 49 | # Attach description to annotations 50 | annotations = copy_and_update(annotations, { 51 | 'description': description, 52 | }) 53 | 54 | # Update global envvars with job specific ones 55 | envvars = copy_and_update( 56 | envvars, 57 | config.get('envars'), # legacy support TODO: remove! 58 | config.get('envvars'), 59 | {'KUBE_JOB_ID': job_id}, 60 | ) 61 | 62 | # Make our container 63 | container = make_container_config( 64 | job_id, 65 | { 66 | 'name': container_name, 67 | 'command': command, 68 | 'image': config['image'], 69 | 'chdir': config.get('chdir', '/'), 70 | 'resources': config.get('resources', {}), 71 | }, 72 | envvars=envvars, 73 | labels=labels, 74 | annotations=annotations, 75 | secrets=secrets, 76 | ) 77 | 78 | # Completions default to 1, same as Kubernetes 79 | completions = config.get('completions', 1) 80 | # Parallelism defaults to completions, also as Kubernetes 81 | parallelism = config.get('parallelism', completions) 82 | 83 | template_spec = { 84 | 'restartPolicy': 'Never', 85 | 'containers': [container], 86 | } 87 | 88 | if node_selector_labels is not None: 89 | template_spec['nodeSelector'] = node_selector_labels 90 | 91 | if service_account_name is not None: 92 | template_spec['serviceAccountName'] = service_account_name 93 | 94 | if secrets is not None: 95 | kubernetes_volumes = [] 96 | for secret_name, secret in secrets.items(): 97 | kubernetes_volumes.append(make_secret_volume_config( 98 | secret_name, secret, 99 | )) 100 | template_spec['volumes'] = kubernetes_volumes 101 | 102 | job_config = { 103 | # Normal Kubernetes job config 104 | 'apiVersion': 'batch/v1', 105 | 'kind': 'Job', 106 | 'metadata': { 107 | 'name': job_name, 108 | 'labels': labels, 109 | 'annotations': annotations, 110 | }, 111 | 'spec': { 112 | 'completions': completions, 113 | 'parallelism': parallelism, 114 | 'selector': labels, 115 | 'template': { 116 | 'metadata': { 117 | 'labels': labels, 118 | }, 119 | 'spec': template_spec, 120 | }, 121 | }, 122 | } 123 | 124 | if 'ttl_seconds_after_finished' in config: 125 | job_config['spec']['ttlSecondsAfterFinished'] = config.get('ttl_seconds_after_finished') 126 | 127 | return job_config 128 | -------------------------------------------------------------------------------- /kubetools/kubernetes/config/namespace.py: -------------------------------------------------------------------------------- 1 | from .util import make_dns_safe_name 2 | 3 | 4 | def make_namespace_config( 5 | name, 6 | labels=None, 7 | annotations=None, 8 | ): 9 | ''' 10 | Builds a Kubernetes namespace configuration dict. 11 | ''' 12 | 13 | labels = labels or {} 14 | annotations = annotations or {} 15 | 16 | # The actual namespace spec 17 | namespace = { 18 | 'apiVersion': 'v1', 19 | 'kind': 'Namespace', 20 | 'metadata': { 21 | 'name': make_dns_safe_name(name), 22 | 'labels': labels, 23 | 'annotations': annotations, 24 | }, 25 | } 26 | 27 | return namespace 28 | -------------------------------------------------------------------------------- /kubetools/kubernetes/config/service.py: -------------------------------------------------------------------------------- 1 | from .util import make_dns_safe_name 2 | 3 | 4 | def make_service_config( 5 | name, ports, 6 | node_port=True, 7 | labels=None, 8 | annotations=None, 9 | ): 10 | ''' 11 | Builds a Kubernetes service configuration dict. 12 | ''' 13 | 14 | labels = labels or {} 15 | annotations = annotations or {} 16 | 17 | # Build our ports list 18 | service_ports = [] 19 | for port in ports: 20 | # Accept port dicts w/protocol/name/etc 21 | if isinstance(port, dict): 22 | # Default targetPort to same as port 23 | if 'targetPort' not in port: 24 | port['targetPort'] = port['port'] 25 | 26 | service_ports.append(port) 27 | 28 | # And accept plain numbers 29 | else: 30 | service_ports.append({ 31 | 'port': port, 32 | 'targetPort': port, 33 | }) 34 | 35 | # The actual service spec 36 | service = { 37 | 'apiVersion': 'v1', 38 | 'kind': 'Service', 39 | 'metadata': { 40 | 'name': make_dns_safe_name(name), 41 | 'labels': labels, 42 | 'annotations': annotations, 43 | }, 44 | 'spec': { 45 | 'selector': labels, 46 | 'ports': service_ports, 47 | }, 48 | } 49 | 50 | if node_port: 51 | service['spec']['type'] = 'NodePort' 52 | 53 | return service 54 | -------------------------------------------------------------------------------- /kubetools/kubernetes/config/util.py: -------------------------------------------------------------------------------- 1 | from hashlib import sha1 2 | 3 | 4 | def make_dns_safe_name(name): 5 | return name.replace('_', '-') 6 | 7 | 8 | def get_hash(name): 9 | if isinstance(name, str): 10 | name = name.encode() 11 | return sha1(name).hexdigest()[:6] 12 | 13 | 14 | def copy_and_update(base, *extras): 15 | if base: 16 | new_base = base.copy() 17 | else: 18 | new_base = {} 19 | 20 | for extra in extras: 21 | if extra: 22 | new_base.update(extra) 23 | return new_base 24 | -------------------------------------------------------------------------------- /kubetools/kubernetes/config/volume.py: -------------------------------------------------------------------------------- 1 | def make_secret_volume_config(name, secret): 2 | volume_data = { 3 | 'name': name, 4 | 'csi': { 5 | 'driver': 'secrets-store.csi.k8s.io', 6 | 'readOnly': True, 7 | 'volumeAttributes': { 8 | 'secretProviderClass': secret['secretProviderClass'], 9 | }, 10 | }, 11 | } 12 | 13 | return volume_data 14 | -------------------------------------------------------------------------------- /kubetools/log.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import sys 3 | 4 | import click 5 | 6 | STDOUT_LOG_LEVELS = (logging.DEBUG, logging.INFO) 7 | STDERR_LOG_LEVELS = (logging.WARNING, logging.ERROR, logging.CRITICAL) 8 | 9 | 10 | logger = logging.getLogger('kubetools') 11 | 12 | 13 | class LogFilter(logging.Filter): 14 | def __init__(self, *levels): 15 | self.levels = levels 16 | 17 | def filter(self, record): 18 | return record.levelno in self.levels 19 | 20 | 21 | class LogFormatter(logging.Formatter): 22 | level_to_format = { 23 | logging.DEBUG: lambda s: click.style(s, 'green'), 24 | logging.WARNING: lambda s: click.style(s, 'yellow'), 25 | logging.ERROR: lambda s: click.style(s, 'red'), 26 | logging.CRITICAL: lambda s: click.style(s, 'red', bold=True), 27 | } 28 | 29 | def format(self, record): 30 | message = record.msg 31 | if record.args: 32 | message = record.msg % record.args 33 | 34 | # We only handle strings here 35 | if isinstance(message, str): 36 | if record.levelno in self.level_to_format: 37 | message = self.level_to_format[record.levelno](message) 38 | 39 | return message 40 | 41 | # If not a string, pass to standard Formatter 42 | else: 43 | return super(LogFormatter, self).format(record) 44 | 45 | 46 | def setup_logging(debug=False): 47 | log_level = 'WARNING' 48 | 49 | if debug: 50 | log_level = 'DEBUG' 51 | 52 | # Set the log level 53 | logger.setLevel(getattr(logging, log_level)) 54 | 55 | # Setup a new handler for stdout & stderr 56 | stdout_handler = logging.StreamHandler(sys.stdout) 57 | stderr_handler = logging.StreamHandler(sys.stderr) 58 | 59 | # Setup filters to push different levels to different streams 60 | stdout_filter = LogFilter(*STDOUT_LOG_LEVELS) 61 | stdout_handler.addFilter(stdout_filter) 62 | 63 | stderr_filter = LogFilter(*STDERR_LOG_LEVELS) 64 | stderr_handler.addFilter(stderr_filter) 65 | 66 | # Setup a formatter 67 | formatter = LogFormatter() 68 | stdout_handler.setFormatter(formatter) 69 | stderr_handler.setFormatter(formatter) 70 | 71 | # Add the handlers 72 | logger.addHandler(stdout_handler) 73 | logger.addHandler(stderr_handler) 74 | 75 | logger.debug('Log level: {0}'.format(log_level)) 76 | -------------------------------------------------------------------------------- /kubetools/main.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import sys 4 | 5 | import click 6 | 7 | from .exceptions import KubeDevCommandError, KubeDevError, KubeError 8 | 9 | 10 | def run_cli(func): 11 | try: 12 | func() 13 | 14 | except KubeDevCommandError as e: 15 | message, stdout = e.args 16 | 17 | click.echo('--> {0} {1}'.format( 18 | click.style('Kubetools dev exception:', 'red', bold=True), 19 | message, 20 | )) 21 | click.echo(stdout) 22 | sys.exit(1) 23 | 24 | except KubeDevError as e: 25 | click.echo('--> {0} {1}'.format( 26 | click.style('Kubetools dev exception:', 'red', bold=True), 27 | e, 28 | )) 29 | sys.exit(1) 30 | 31 | except KubeError as e: 32 | click.echo('--> {0} {1}'.format( 33 | click.style('Kubetools {0} exception:'.format(e.type), 'red', bold=True), 34 | e, 35 | )) 36 | sys.exit(1) 37 | 38 | except KeyboardInterrupt: 39 | click.echo() 40 | click.echo('Exiting on user request...') 41 | 42 | except Exception as e: 43 | click.echo('--> Unexpected exception: {0}'.format( 44 | click.style( 45 | '{0}{1}'.format(e.__class__.__name__, e.args), 46 | 'red', 47 | bold=True, 48 | ), 49 | )) 50 | 51 | raise 52 | -------------------------------------------------------------------------------- /kubetools/settings.py: -------------------------------------------------------------------------------- 1 | from configparser import ConfigParser 2 | from functools import lru_cache 3 | from os import access, environ, listdir, path, X_OK 4 | 5 | import click 6 | 7 | from .log import logger 8 | 9 | 10 | class KubetoolsSettings(object): 11 | DEFAULT_KUBE_ENV = 'staging' # default environment when speaking to the server 12 | 13 | DEV_DEFAULT_ENV = 'dev' # default environment name in dev 14 | DEV_HOST = 'localhost' # dev host to link people to (should map to 127.0.0.1) 15 | DEV_CONFIG_DIRNAME = '.kubetools' # project directroy to generate compose config 16 | DEV_BACKEND = 'docker_compose' # backend to use for development 17 | 18 | CRONJOBS_BATCH_API_VERSION = 'batch/v1' # if k8s version < 1.21+ should be 'batch/v1beta1' 19 | 20 | REGISTRY_CHECK_SCRIPT = None 21 | ''' Optional external script to check if an image exists in the docker registry. 22 | 23 | For cases when the registry cannot be checked by just using the docker registry V2 API. 24 | 25 | The script will be passed 3 arguments: 26 | * the registry IP/hostname and port (in the form `:`) 27 | * the image name, which comes from the app name 28 | * the image tag (version), which comes from the build context and commit hash 29 | Concretely they could be used in `docker pull /:` 30 | 31 | The script must return one of the following codes: 32 | * 0: the image was found in the registry 33 | * 1: the image was not found in the registry 34 | * 2: the image should be checked with the HTTP Docker V2 API 35 | * anything else: the script ran into an error and `kubetools` must abort 36 | Note that return code 2 is useful for example if the script is only responsible for checking 37 | some combination of registries or images, but not all. 38 | ''' 39 | 40 | WAIT_SLEEP_TIME = 3 41 | WAIT_MAX_TIME = int(environ.get('KUBETOOLS_WAIT_MAX_TIME', 300)) 42 | WAIT_MAX_SLEEPS = WAIT_MAX_TIME / WAIT_SLEEP_TIME 43 | 44 | def __init__(self, filename=None): 45 | self.filename = filename 46 | self.scripts = [] 47 | 48 | 49 | def get_settings_directory(): 50 | return click.get_app_dir('kubetools', force_posix=True) 51 | 52 | 53 | @lru_cache(maxsize=1) 54 | def get_settings(): 55 | settings_directory = get_settings_directory() 56 | settings_file = path.join(settings_directory, 'kubetools.conf') 57 | 58 | settings = KubetoolsSettings(filename=settings_file) 59 | 60 | if path.exists(settings_file): 61 | logger.info('Loading settings file: {0}'.format(settings_file)) 62 | parser = ConfigParser() 63 | parser.read(settings_file) 64 | 65 | for option in parser.options('kubetools'): 66 | setattr( 67 | settings, 68 | option.upper().replace('-', '_'), 69 | parser.get('kubetools', option), 70 | ) 71 | 72 | else: 73 | logger.info('No settings file: {0}'.format(settings_file)) 74 | 75 | scripts_directory = path.join(settings_directory, 'scripts') 76 | if path.exists(scripts_directory): 77 | for filename in listdir(scripts_directory): 78 | if access(path.join(scripts_directory, filename), X_OK): 79 | settings.scripts.append(filename) 80 | 81 | return settings 82 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 100 3 | inline-quotes = single 4 | multiline-quotes = single 5 | docstring-quotes = single 6 | import-order-style = edited 7 | application-import-names = kubetools 8 | 9 | [coverage:report] 10 | show_missing = true 11 | skip_covered = true 12 | precision = 1 13 | include = kubetools/* 14 | 15 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import re 2 | from os import path 3 | 4 | from setuptools import find_packages, setup 5 | 6 | 7 | # Regex matching pattern followed by 3 numerical values separated by '.' 8 | pattern = re.compile(r'# v(?P[0-9]+\.[0-9]+(\.[0-9]+(\.[a-z0-9]+)?)?)') 9 | 10 | 11 | def get_version(): 12 | with open('CHANGELOG.md', 'r') as fn: 13 | for line in fn.readlines(): 14 | match = pattern.fullmatch(line.strip()) 15 | if match: 16 | return ''.join(match.group('version')) 17 | raise RuntimeError('No version found in CHANGELOG.md') 18 | 19 | 20 | base_dir = path.abspath(path.dirname(__file__)) 21 | 22 | 23 | def get_readme_content(): 24 | readme_file = path.join(base_dir, 'README.md') 25 | with open(readme_file, 'r') as f: 26 | return f.read() 27 | 28 | 29 | if __name__ == '__main__': 30 | setup( 31 | version=get_version(), 32 | name='kubetools', 33 | description=( 34 | 'Kubetools is a tool and processes for developing and deploying ' 35 | 'microservices to Kubernetes.' 36 | ), 37 | author='EDITED devs', 38 | author_email='dev@edited.com', 39 | url='http://github.com/EDITD/kubetools', 40 | long_description=get_readme_content(), 41 | long_description_content_type='text/markdown', 42 | packages=find_packages(), 43 | entry_points={ 44 | 'console_scripts': ( 45 | # kubetools client commands 46 | 'kubetools=kubetools.cli.__main__:main', 47 | # ktd dev commands 48 | 'ktd=kubetools.dev.__main__:main', 49 | ), 50 | }, 51 | python_requires='>=3.6', 52 | install_requires=( 53 | 'click>=7,<8', 54 | 'docker>=3,<5', 55 | 'pyyaml>=3,<6', 56 | 'requests>=2,<2.29.0', # https://github.com/docker/docker-py/issues/3113 57 | 'pyretry', 58 | 'setuptools', 59 | # To support CronJob api versions 'batch/v1beta1' & 'batch/v1' 60 | 'kubernetes>=21.7.0,<25.0.0', 61 | 'tabulate<1', 62 | # compose v2 has broken container naming 63 | 'docker-compose<2', 64 | ), 65 | extras_require={ 66 | 'dev': ( 67 | 'ipdb', 68 | 'pytest~=6.0', 69 | 'pytest-cov~=2.10', 70 | 'flake8', 71 | 'flake8-import-order', 72 | 'flake8-commas', 73 | ), 74 | }, 75 | classifiers=[ 76 | 'Development Status :: 5 - Production/Stable', 77 | 'Environment :: Console', 78 | 'Intended Audience :: Developers', 79 | 'Intended Audience :: Information Technology', 80 | 'License :: OSI Approved :: MIT License', 81 | 'Operating System :: POSIX', 82 | 'Programming Language :: Python', 83 | 'Programming Language :: Python :: 3.6', 84 | 'Programming Language :: Python :: 3.7', 85 | 'Programming Language :: Python :: 3.8', 86 | 'Programming Language :: Python :: 3.9', 87 | 'Programming Language :: Python :: 3.10', 88 | 'Programming Language :: Python :: 3.11', 89 | 'Programming Language :: Python :: 3.12', 90 | 'Topic :: Software Development :: Build Tools', 91 | 'Topic :: Software Development :: Testing', 92 | 'Topic :: System :: Software Distribution', 93 | ], 94 | ) 95 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EDITD/kubetools/e836c75768d9e4af9e13680afcd5164c6f4ed7a0/tests/__init__.py -------------------------------------------------------------------------------- /tests/configs/basic_app/k8s_cronjobs.yml: -------------------------------------------------------------------------------- 1 | kind: CronJob 2 | metadata: 3 | name: generic-cronjob 4 | labels: { 5 | kubetools/name: generic-cronjob, 6 | kubetools/project_name: generic-app, 7 | kubetools/role: cronjob 8 | } 9 | annotations: { 10 | app.kubernetes.io/managed-by: kubetools, 11 | description: 'Run: [''generic-command'']' 12 | } 13 | spec: 14 | schedule: "*/1 * * * *" 15 | startingDeadlineSeconds: 10 16 | concurrencyPolicy: "Allow" 17 | jobTemplate: 18 | spec: 19 | template: 20 | metadata: 21 | name: generic-cronjob 22 | labels: { 23 | kubetools/name: generic-cronjob, 24 | kubetools/project_name: generic-app, 25 | kubetools/role: cronjob 26 | } 27 | annotations: { 28 | app.kubernetes.io/managed-by: kubetools, 29 | description: 'Run: [''generic-command'']' 30 | } 31 | spec: 32 | containers: 33 | - command: [generic-command] 34 | containerContext: generic-context 35 | env: 36 | - {name: KUBE, value: 'true'} 37 | image: generic-image 38 | imagePullPolicy: 'Always' 39 | name: generic-container 40 | restartPolicy: OnFailure 41 | 42 | --- 43 | 44 | kind: CronJob 45 | metadata: 46 | name: generic-cronjob-with-annotations 47 | labels: { 48 | kubetools/name: generic-cronjob-with-annotations, 49 | kubetools/project_name: generic-app, 50 | kubetools/role: cronjob 51 | } 52 | annotations: { 53 | app.kubernetes.io/managed-by: kubetools, 54 | imageregistry: https://hub.docker.com/, 55 | description: 'Run: [''generic-command'']' 56 | } 57 | spec: 58 | schedule: "*/1 * * * *" 59 | startingDeadlineSeconds: 10 60 | concurrencyPolicy: "Allow" 61 | jobTemplate: 62 | spec: 63 | template: 64 | metadata: 65 | name: generic-cronjob-with-annotations 66 | labels: { 67 | kubetools/name: generic-cronjob-with-annotations, 68 | kubetools/project_name: generic-app, 69 | kubetools/role: cronjob, 70 | } 71 | annotations: { 72 | app.kubernetes.io/managed-by: kubetools, 73 | imageregistry: https://hub.docker.com/, 74 | description: 'Run: [''generic-command'']' 75 | } 76 | spec: 77 | containers: 78 | - command: [generic-command] 79 | containerContext: generic-context 80 | env: 81 | - {name: KUBE, value: 'true'} 82 | image: generic-image 83 | imagePullPolicy: 'Always' 84 | name: generic-container 85 | restartPolicy: OnFailure 86 | 87 | --- 88 | 89 | kind: CronJob 90 | metadata: 91 | name: generic-cronjob-with-labels 92 | labels: { 93 | app.kubernetes.io/name: generic-cronjob-with-labels, 94 | kubetools/name: generic-cronjob-with-labels, 95 | kubetools/project_name: generic-app, 96 | kubetools/role: cronjob 97 | } 98 | annotations: { 99 | app.kubernetes.io/managed-by: kubetools, 100 | description: 'Run: [''generic-command'']' 101 | } 102 | spec: 103 | schedule: "*/1 * * * *" 104 | startingDeadlineSeconds: 10 105 | concurrencyPolicy: "Allow" 106 | jobTemplate: 107 | spec: 108 | template: 109 | metadata: 110 | name: generic-cronjob-with-labels 111 | labels: { 112 | app.kubernetes.io/name: generic-cronjob-with-labels, 113 | kubetools/name: generic-cronjob-with-labels, 114 | kubetools/project_name: generic-app, 115 | kubetools/role: cronjob, 116 | } 117 | annotations: { 118 | app.kubernetes.io/managed-by: kubetools, 119 | description: 'Run: [''generic-command'']' 120 | } 121 | spec: 122 | containers: 123 | - command: [generic-command] 124 | containerContext: generic-context 125 | env: 126 | - {name: KUBE, value: 'true'} 127 | image: generic-image 128 | imagePullPolicy: 'Always' 129 | name: generic-container 130 | restartPolicy: OnFailure 131 | 132 | --- 133 | 134 | kind: CronJob 135 | metadata: 136 | name: generic-cronjob-with-success-history 137 | labels: { 138 | kubetools/name: generic-cronjob-with-success-history, 139 | kubetools/project_name: generic-app, 140 | kubetools/role: cronjob 141 | } 142 | annotations: { 143 | app.kubernetes.io/managed-by: kubetools, 144 | description: 'Run: [''generic-command'']' 145 | } 146 | spec: 147 | schedule: "*/1 * * * *" 148 | startingDeadlineSeconds: 10 149 | concurrencyPolicy: "Allow" 150 | successfulJobsHistoryLimit: 1 151 | jobTemplate: 152 | spec: 153 | template: 154 | metadata: 155 | name: generic-cronjob-with-success-history 156 | labels: { 157 | kubetools/name: generic-cronjob-with-success-history, 158 | kubetools/project_name: generic-app, 159 | kubetools/role: cronjob 160 | } 161 | annotations: { 162 | app.kubernetes.io/managed-by: kubetools, 163 | description: 'Run: [''generic-command'']' 164 | } 165 | spec: 166 | containers: 167 | - command: [generic-command] 168 | containerContext: generic-context 169 | env: 170 | - {name: KUBE, value: 'true'} 171 | image: generic-image 172 | imagePullPolicy: 'Always' 173 | name: generic-container 174 | restartPolicy: OnFailure 175 | 176 | --- 177 | 178 | kind: CronJob 179 | metadata: 180 | name: generic-cronjob-with-fail-history 181 | labels: { 182 | kubetools/name: generic-cronjob-with-fail-history, 183 | kubetools/project_name: generic-app, 184 | kubetools/role: cronjob 185 | } 186 | annotations: { 187 | app.kubernetes.io/managed-by: kubetools, 188 | description: 'Run: [''generic-command'']' 189 | } 190 | spec: 191 | schedule: "*/1 * * * *" 192 | startingDeadlineSeconds: 10 193 | concurrencyPolicy: "Allow" 194 | failedJobsHistoryLimit: 2 195 | jobTemplate: 196 | spec: 197 | template: 198 | metadata: 199 | name: generic-cronjob-with-fail-history 200 | labels: { 201 | kubetools/name: generic-cronjob-with-fail-history, 202 | kubetools/project_name: generic-app, 203 | kubetools/role: cronjob 204 | } 205 | annotations: { 206 | app.kubernetes.io/managed-by: kubetools, 207 | description: 'Run: [''generic-command'']' 208 | } 209 | spec: 210 | containers: 211 | - command: [generic-command] 212 | containerContext: generic-context 213 | env: 214 | - {name: KUBE, value: 'true'} 215 | image: generic-image 216 | imagePullPolicy: 'Always' 217 | name: generic-container 218 | restartPolicy: OnFailure 219 | -------------------------------------------------------------------------------- /tests/configs/basic_app/k8s_deployments.yml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | annotations: {app.kubernetes.io/managed-by: kubetools} 5 | labels: {kubetools/name: generic-app, kubetools/project_name: generic-app, kubetools/role: app} 6 | name: generic-app 7 | spec: 8 | replicas: 1 9 | revisionHistoryLimit: 5 10 | selector: 11 | matchLabels: {kubetools/name: generic-app, kubetools/project_name: generic-app, 12 | kubetools/role: app} 13 | template: 14 | metadata: 15 | labels: {kubetools/name: generic-app, kubetools/project_name: generic-app, kubetools/role: app} 16 | spec: 17 | containers: 18 | - command: [generic-command] 19 | containerContext: generic-context 20 | env: 21 | - {name: KUBE, value: 'true'} 22 | image: generic-image 23 | imagePullPolicy: Always 24 | livenessProbe: 25 | httpGet: {path: /ping, port: 80} 26 | timeoutSeconds: 5 27 | name: webserver 28 | readinessProbe: 29 | httpGet: {path: /ping, port: 80} 30 | timeoutSeconds: 5 31 | 32 | --- 33 | 34 | apiVersion: apps/v1 35 | kind: Deployment 36 | metadata: 37 | annotations: { 38 | app.kubernetes.io/managed-by: kubetools, 39 | imageregistry: https://hub.docker.com/ 40 | } 41 | labels: { 42 | kubetools/name: generic-app-with-annotations, 43 | kubetools/project_name: generic-app, 44 | kubetools/role: app 45 | } 46 | name: generic-app-with-annotations 47 | spec: 48 | replicas: 1 49 | revisionHistoryLimit: 5 50 | selector: 51 | matchLabels: { 52 | kubetools/name: generic-app-with-annotations, 53 | kubetools/project_name: generic-app, 54 | kubetools/role: app 55 | } 56 | template: 57 | metadata: 58 | labels: { 59 | kubetools/name: generic-app-with-annotations, 60 | kubetools/project_name: generic-app, 61 | kubetools/role: app 62 | } 63 | spec: 64 | containers: 65 | - command: [generic-command] 66 | containerContext: generic-context 67 | env: 68 | - {name: KUBE, value: 'true'} 69 | image: generic-image 70 | imagePullPolicy: Always 71 | livenessProbe: 72 | httpGet: {path: /ping, port: 80} 73 | timeoutSeconds: 5 74 | name: webserver 75 | readinessProbe: 76 | httpGet: {path: /ping, port: 80} 77 | timeoutSeconds: 5 78 | 79 | --- 80 | 81 | apiVersion: apps/v1 82 | kind: Deployment 83 | metadata: 84 | annotations: {app.kubernetes.io/managed-by: kubetools} 85 | labels: { 86 | app.kubernetes.io/name: generic-app-with-labels, 87 | kubetools/name: generic-app-with-labels, 88 | kubetools/project_name: generic-app, 89 | kubetools/role: app 90 | } 91 | name: generic-app-with-labels 92 | spec: 93 | replicas: 1 94 | revisionHistoryLimit: 5 95 | selector: 96 | matchLabels: { 97 | app.kubernetes.io/name: generic-app-with-labels, 98 | kubetools/name: generic-app-with-labels, 99 | kubetools/project_name: generic-app, 100 | kubetools/role: app 101 | } 102 | template: 103 | metadata: 104 | labels: { 105 | app.kubernetes.io/name: generic-app-with-labels, 106 | kubetools/name: generic-app-with-labels, 107 | kubetools/project_name: generic-app, 108 | kubetools/role: app 109 | } 110 | spec: 111 | containers: 112 | - command: [generic-command] 113 | containerContext: generic-context 114 | env: 115 | - {name: KUBE, value: 'true'} 116 | image: generic-image 117 | imagePullPolicy: Always 118 | livenessProbe: 119 | httpGet: {path: /ping, port: 80} 120 | timeoutSeconds: 5 121 | name: webserver 122 | readinessProbe: 123 | httpGet: {path: /ping, port: 80} 124 | timeoutSeconds: 5 125 | -------------------------------------------------------------------------------- /tests/configs/basic_app/k8s_jobs.yml: -------------------------------------------------------------------------------- 1 | apiVersion: batch/v1 2 | kind: Job 3 | metadata: 4 | annotations: {app.kubernetes.io/managed-by: kubetools, description: 'Run: [''generic-command'']'} 5 | labels: {job-id: UUID, kubetools/project_name: generic-app, 6 | kubetools/role: job} 7 | name: UUID 8 | spec: 9 | completions: 1 10 | parallelism: 1 11 | selector: {job-id: UUID, kubetools/project_name: generic-app, 12 | kubetools/role: job} 13 | template: 14 | metadata: 15 | labels: {job-id: UUID, kubetools/project_name: generic-app, 16 | kubetools/role: job} 17 | spec: 18 | containers: 19 | - chdir: / 20 | command: [generic-command] 21 | env: 22 | - {name: KUBE, value: 'true'} 23 | - {name: KUBE_JOB_ID, value: UUID} 24 | image: generic-image 25 | imagePullPolicy: Always 26 | name: upgrade 27 | resources: 28 | requests: 29 | memory: "1Gi" 30 | restartPolicy: Never 31 | -------------------------------------------------------------------------------- /tests/configs/basic_app/k8s_services.yml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Service 3 | metadata: 4 | annotations: {app.kubernetes.io/managed-by: kubetools} 5 | labels: {kubetools/name: generic-app, kubetools/project_name: generic-app, kubetools/role: app} 6 | name: generic-app 7 | spec: 8 | ports: 9 | - {port: 80, targetPort: 80} 10 | selector: {kubetools/name: generic-app, kubetools/project_name: generic-app, kubetools/role: app} 11 | type: NodePort 12 | 13 | --- 14 | 15 | apiVersion: v1 16 | kind: Service 17 | metadata: 18 | annotations: { 19 | app.kubernetes.io/managed-by: kubetools, 20 | imageregistry: https://hub.docker.com/ 21 | } 22 | labels: { 23 | kubetools/name: generic-app-with-annotations, 24 | kubetools/project_name: generic-app, 25 | kubetools/role: app 26 | } 27 | name: generic-app-with-annotations 28 | spec: 29 | ports: 30 | - {port: 80, targetPort: 80} 31 | selector: { 32 | kubetools/name: generic-app-with-annotations, 33 | kubetools/project_name: generic-app, 34 | kubetools/role: app 35 | } 36 | type: NodePort 37 | 38 | --- 39 | 40 | apiVersion: v1 41 | kind: Service 42 | metadata: 43 | annotations: { 44 | app.kubernetes.io/managed-by: kubetools 45 | } 46 | labels: { 47 | app.kubernetes.io/name: generic-app-with-labels, 48 | kubetools/name: generic-app-with-labels, 49 | kubetools/project_name: generic-app, 50 | kubetools/role: app 51 | } 52 | name: generic-app-with-labels 53 | spec: 54 | ports: 55 | - {port: 80, targetPort: 80} 56 | selector: { 57 | app.kubernetes.io/name: generic-app-with-labels, 58 | kubetools/name: generic-app-with-labels, 59 | kubetools/project_name: generic-app, 60 | kubetools/role: app 61 | } 62 | type: NodePort 63 | -------------------------------------------------------------------------------- /tests/configs/basic_app/kubetools.yml: -------------------------------------------------------------------------------- 1 | name: generic-app 2 | 3 | 4 | containerContexts: 5 | generic-context: 6 | image: generic-image 7 | command: [generic-command] 8 | ports: 9 | - 80 10 | 11 | upgrades: 12 | - name: Upgrade the database 13 | containerContext: generic-context 14 | command: [generic-command, generic-arg] 15 | resources: 16 | requests: 17 | memory: "1Gi" 18 | 19 | 20 | deployments: 21 | generic-app: 22 | containers: 23 | webserver: 24 | command: [uwsgi, --ini, /etc/uwsgi.conf] 25 | containerContext: generic-context 26 | probes: 27 | timeoutSeconds: 5 28 | httpGet: 29 | path: /ping 30 | generic-app-with-annotations: 31 | annotations: 32 | imageregistry: "https://hub.docker.com/" 33 | containers: 34 | webserver: 35 | command: [uwsgi, --ini, /etc/uwsgi.conf] 36 | containerContext: generic-context 37 | probes: 38 | timeoutSeconds: 5 39 | httpGet: 40 | path: /ping 41 | generic-app-with-labels: 42 | labels: 43 | app.kubernetes.io/name: generic-app-with-labels 44 | containers: 45 | webserver: 46 | command: [uwsgi, --ini, /etc/uwsgi.conf] 47 | containerContext: generic-context 48 | probes: 49 | timeoutSeconds: 5 50 | httpGet: 51 | path: /ping 52 | 53 | 54 | cronjobs: 55 | generic-cronjob: 56 | schedule: "*/1 * * * *" 57 | concurrency_policy: "Allow" 58 | containers: 59 | generic-container: 60 | containerContext: generic-context 61 | generic-cronjob-with-annotations: 62 | annotations: 63 | imageregistry: "https://hub.docker.com/" 64 | schedule: "*/1 * * * *" 65 | concurrency_policy: "Allow" 66 | containers: 67 | generic-container: 68 | containerContext: generic-context 69 | generic-cronjob-with-labels: 70 | labels: 71 | app.kubernetes.io/name: generic-cronjob-with-labels 72 | schedule: "*/1 * * * *" 73 | concurrency_policy: "Allow" 74 | containers: 75 | generic-container: 76 | containerContext: generic-context 77 | generic-cronjob-with-success-history: 78 | successfulJobsHistoryLimit: 1 79 | schedule: "*/1 * * * *" 80 | concurrency_policy: "Allow" 81 | containers: 82 | generic-container: 83 | containerContext: generic-context 84 | generic-cronjob-with-fail-history: 85 | failedJobsHistoryLimit: 2 86 | schedule: "*/1 * * * *" 87 | concurrency_policy: "Allow" 88 | containers: 89 | generic-container: 90 | containerContext: generic-context 91 | -------------------------------------------------------------------------------- /tests/configs/dependencies/k8s_deployments.yml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | annotations: {app.kubernetes.io/managed-by: kubetools} 5 | labels: {kubetools/name: dependencies-memcache-1, kubetools/project_name: dependencies, 6 | kubetools/role: dependency} 7 | name: dependencies-memcache-1 8 | spec: 9 | replicas: 1 10 | revisionHistoryLimit: 5 11 | selector: 12 | matchLabels: {kubetools/name: dependencies-memcache-1, kubetools/project_name: dependencies, 13 | kubetools/role: dependency} 14 | template: 15 | metadata: 16 | labels: {kubetools/name: dependencies-memcache-1, kubetools/project_name: dependencies, 17 | kubetools/role: dependency} 18 | spec: 19 | containers: 20 | - command: [memcached, -u, root, -I, 10m] 21 | containerContext: memcache 22 | env: 23 | - {name: KUBE, value: 'true'} 24 | image: memcache:1.4.33 25 | imagePullPolicy: Always 26 | name: memcache-1 27 | 28 | --- 29 | 30 | apiVersion: apps/v1 31 | kind: Deployment 32 | metadata: 33 | annotations: {app.kubernetes.io/managed-by: kubetools} 34 | labels: {kubetools/name: dependencies-memcache-2, kubetools/project_name: dependencies, 35 | kubetools/role: dependency} 36 | name: dependencies-memcache-2 37 | spec: 38 | replicas: 1 39 | revisionHistoryLimit: 5 40 | selector: 41 | matchLabels: {kubetools/name: dependencies-memcache-2, kubetools/project_name: dependencies, 42 | kubetools/role: dependency} 43 | template: 44 | metadata: 45 | labels: {kubetools/name: dependencies-memcache-2, kubetools/project_name: dependencies, 46 | kubetools/role: dependency} 47 | spec: 48 | containers: 49 | - command: [memcached, -u, root, -I, 10m] 50 | containerContext: memcache 51 | env: 52 | - {name: KUBE, value: 'true'} 53 | image: memcache:1.4.33 54 | imagePullPolicy: Always 55 | name: memcache-2 56 | 57 | --- 58 | 59 | apiVersion: apps/v1 60 | kind: Deployment 61 | metadata: 62 | annotations: { 63 | app.kubernetes.io/managed-by: kubetools, 64 | imageregistry: https://hub.docker.com/ 65 | } 66 | labels: {kubetools/name: dependencies-memcache-with-annotations, kubetools/project_name: dependencies, 67 | kubetools/role: dependency} 68 | name: dependencies-memcache-with-annotations 69 | spec: 70 | replicas: 1 71 | revisionHistoryLimit: 5 72 | selector: 73 | matchLabels: {kubetools/name: dependencies-memcache-with-annotations, kubetools/project_name: dependencies, 74 | kubetools/role: dependency} 75 | template: 76 | metadata: 77 | labels: {kubetools/name: dependencies-memcache-with-annotations, kubetools/project_name: dependencies, 78 | kubetools/role: dependency} 79 | spec: 80 | containers: 81 | - command: [memcached, -u, root, -I, 10m] 82 | containerContext: memcache 83 | env: 84 | - {name: KUBE, value: 'true'} 85 | image: memcache:1.4.33 86 | imagePullPolicy: Always 87 | name: memcache-with-annotations 88 | 89 | --- 90 | 91 | apiVersion: apps/v1 92 | kind: Deployment 93 | metadata: 94 | annotations: {app.kubernetes.io/managed-by: kubetools} 95 | labels: { 96 | app.kubernetes.io/name: memcache-with-labels, 97 | kubetools/name: dependencies-memcache-with-labels, 98 | kubetools/project_name: dependencies, 99 | kubetools/role: dependency 100 | } 101 | name: dependencies-memcache-with-labels 102 | spec: 103 | replicas: 1 104 | revisionHistoryLimit: 5 105 | selector: 106 | matchLabels: { 107 | app.kubernetes.io/name: memcache-with-labels, 108 | kubetools/name: dependencies-memcache-with-labels, 109 | kubetools/project_name: dependencies, 110 | kubetools/role: dependency 111 | } 112 | template: 113 | metadata: 114 | labels: { 115 | app.kubernetes.io/name: memcache-with-labels, 116 | kubetools/name: dependencies-memcache-with-labels, 117 | kubetools/project_name: dependencies, 118 | kubetools/role: dependency 119 | } 120 | spec: 121 | containers: 122 | - command: [memcached, -u, root, -I, 10m] 123 | containerContext: memcache 124 | env: 125 | - {name: KUBE, value: 'true'} 126 | image: memcache:1.4.33 127 | imagePullPolicy: Always 128 | name: memcache-with-labels 129 | -------------------------------------------------------------------------------- /tests/configs/dependencies/k8s_services.yml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Service 3 | metadata: 4 | annotations: {app.kubernetes.io/managed-by: kubetools} 5 | labels: {kubetools/name: dependencies-memcache-1, kubetools/project_name: dependencies, 6 | kubetools/role: dependency} 7 | name: dependencies-memcache-1 8 | spec: 9 | ports: 10 | - {port: 11211, targetPort: 11211} 11 | selector: {kubetools/name: dependencies-memcache-1, kubetools/project_name: dependencies, 12 | kubetools/role: dependency} 13 | type: NodePort 14 | 15 | --- 16 | 17 | apiVersion: v1 18 | kind: Service 19 | metadata: 20 | annotations: {app.kubernetes.io/managed-by: kubetools} 21 | labels: {kubetools/name: dependencies-memcache-2, kubetools/project_name: dependencies, 22 | kubetools/role: dependency} 23 | name: dependencies-memcache-2 24 | spec: 25 | ports: 26 | - {port: 11211, targetPort: 11211} 27 | selector: {kubetools/name: dependencies-memcache-2, kubetools/project_name: dependencies, 28 | kubetools/role: dependency} 29 | type: NodePort 30 | 31 | --- 32 | 33 | apiVersion: v1 34 | kind: Service 35 | metadata: 36 | annotations: { 37 | app.kubernetes.io/managed-by: kubetools, 38 | imageregistry: https://hub.docker.com/ 39 | } 40 | labels: {kubetools/name: dependencies-memcache-with-annotations, kubetools/project_name: dependencies, 41 | kubetools/role: dependency} 42 | name: dependencies-memcache-with-annotations 43 | spec: 44 | ports: 45 | - {port: 11211, targetPort: 11211} 46 | selector: {kubetools/name: dependencies-memcache-with-annotations, kubetools/project_name: dependencies, 47 | kubetools/role: dependency} 48 | type: NodePort 49 | 50 | --- 51 | 52 | apiVersion: v1 53 | kind: Service 54 | metadata: 55 | annotations: {app.kubernetes.io/managed-by: kubetools} 56 | labels: { 57 | app.kubernetes.io/name: memcache-with-labels, 58 | kubetools/name: dependencies-memcache-with-labels, 59 | kubetools/project_name: dependencies, 60 | kubetools/role: dependency 61 | } 62 | name: dependencies-memcache-with-labels 63 | spec: 64 | ports: 65 | - {port: 11211, targetPort: 11211} 66 | selector: { 67 | app.kubernetes.io/name: memcache-with-labels, 68 | kubetools/name: dependencies-memcache-with-labels, 69 | kubetools/project_name: dependencies, 70 | kubetools/role: dependency 71 | } 72 | type: NodePort 73 | -------------------------------------------------------------------------------- /tests/configs/dependencies/kubetools.yml: -------------------------------------------------------------------------------- 1 | name: dependencies 2 | 3 | 4 | containerContexts: 5 | memcache: 6 | image: memcache:1.4.33 7 | command: [memcached, -u, root, -I, 10m] 8 | ports: 9 | - 11211 10 | 11 | 12 | dependencies: 13 | memcache-1: 14 | containers: 15 | memcache-1: 16 | containerContext: memcache 17 | 18 | memcache-2: 19 | containers: 20 | memcache-2: 21 | containerContext: memcache 22 | 23 | memcache-with-annotations: 24 | annotations: 25 | imageregistry: https://hub.docker.com/ 26 | containers: 27 | memcache-with-annotations: 28 | containerContext: memcache 29 | memcache-with-labels: 30 | labels: 31 | app.kubernetes.io/name: memcache-with-labels 32 | containers: 33 | memcache-with-labels: 34 | containerContext: memcache 35 | 36 | elasticsearch: 37 | conditions: 38 | dev: true 39 | containers: 40 | elasticsearch: 41 | image: elasticsearch:v6.2 42 | ports: 43 | - 9200 44 | probes: 45 | exec: 46 | command: [curl, 'wardrobe-elasticsearch:9200'] 47 | 48 | riak: 49 | conditions: 50 | dev: true 51 | containers: 52 | riak: 53 | image: basho/riak-kv:2.1.4 54 | ports: 55 | - 8098 56 | probes: 57 | exec: 58 | command: [riak-admin, test] 59 | -------------------------------------------------------------------------------- /tests/configs/dev_overrides/k8s_deployments.yml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | annotations: {app.kubernetes.io/managed-by: kubetools} 5 | labels: {kubetools/name: generic-app-dev-overrides-generic-app, kubetools/project_name: generic-app-dev-overrides, 6 | kubetools/role: app} 7 | name: generic-app-dev-overrides-generic-app 8 | spec: 9 | replicas: 1 10 | revisionHistoryLimit: 5 11 | selector: 12 | matchLabels: {kubetools/name: generic-app-dev-overrides-generic-app, kubetools/project_name: generic-app-dev-overrides, 13 | kubetools/role: app} 14 | template: 15 | metadata: 16 | labels: {kubetools/name: generic-app-dev-overrides-generic-app, kubetools/project_name: generic-app-dev-overrides, 17 | kubetools/role: app} 18 | spec: 19 | containers: 20 | - command: [generic-dev-command] 21 | containerContext: generic-context 22 | env: 23 | - {name: KUBE, value: 'true'} 24 | image: generic-image 25 | imagePullPolicy: Always 26 | name: webserver 27 | - command: [generic-dev-command] 28 | containerContext: generic-context 29 | env: 30 | - {name: KUBE, value: 'true'} 31 | image: generic-image 32 | imagePullPolicy: Always 33 | name: worker 34 | -------------------------------------------------------------------------------- /tests/configs/dev_overrides/k8s_services.yml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Service 3 | metadata: 4 | annotations: {app.kubernetes.io/managed-by: kubetools} 5 | labels: {kubetools/name: generic-app-dev-overrides-generic-app, kubetools/project_name: generic-app-dev-overrides, 6 | kubetools/role: app} 7 | name: generic-app-dev-overrides-generic-app 8 | spec: 9 | ports: 10 | - {port: 80, targetPort: 80} 11 | - {port: 80, targetPort: 80} 12 | selector: {kubetools/name: generic-app-dev-overrides-generic-app, kubetools/project_name: generic-app-dev-overrides, 13 | kubetools/role: app} 14 | type: NodePort 15 | -------------------------------------------------------------------------------- /tests/configs/dev_overrides/kubetools.yml: -------------------------------------------------------------------------------- 1 | name: generic-app-dev-overrides 2 | 3 | 4 | containerContexts: 5 | generic-context: 6 | image: generic-image 7 | command: [generic-command] 8 | ports: 9 | - 80 10 | dev: 11 | command: [generic-dev-command] 12 | 13 | 14 | deployments: 15 | generic-app: 16 | containers: 17 | webserver: 18 | command: [uwsgi, --ini, /etc/uwsgi.conf] 19 | containerContext: generic-context 20 | 21 | worker: 22 | command: [celery-worker] 23 | containerContext: generic-context 24 | -------------------------------------------------------------------------------- /tests/configs/docker_registry/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.9-slim 2 | ENTRYPOINT ["python"] 3 | -------------------------------------------------------------------------------- /tests/configs/docker_registry/k8s_deployments.yml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | annotations: {app.kubernetes.io/managed-by: kubetools} 5 | labels: {kubetools/name: generic-app, kubetools/project_name: generic-app, kubetools/role: app} 6 | name: generic-app 7 | spec: 8 | replicas: 1 9 | revisionHistoryLimit: 5 10 | selector: 11 | matchLabels: {kubetools/name: generic-app, kubetools/project_name: generic-app, 12 | kubetools/role: app} 13 | template: 14 | metadata: 15 | labels: {kubetools/name: generic-app, kubetools/project_name: generic-app, kubetools/role: app} 16 | spec: 17 | containers: 18 | - command: [uwsgi, --ini, /etc/uwsgi.conf] 19 | env: 20 | - {name: KUBE, value: 'true'} 21 | image: generic-registry/generic-image 22 | imagePullPolicy: Always 23 | name: image-with-embedded-registry 24 | # - command: [uwsgi, --ini, /etc/uwsgi.conf] 25 | # env: 26 | # - {name: KUBE, value: 'true'} 27 | # image: specific-registry/generic-image 28 | # imagePullPolicy: Always 29 | # name: image-with-specify-registry 30 | - command: [uwsgi, --ini, /etc/uwsgi.conf] 31 | env: 32 | - {name: KUBE, value: 'true'} 33 | image: generic-image 34 | imagePullPolicy: Always 35 | name: image-without-registry 36 | - command: [uwsgi, --ini, /etc/uwsgi.conf] 37 | env: 38 | - {name: KUBE, value: 'true'} 39 | image: default-registry/generic-app:generic-app-build-dockerfile-commit-thisisacommithash 40 | imagePullPolicy: Always 41 | name: build-dockerfile 42 | - command: [uwsgi, --ini, /etc/uwsgi.conf] 43 | env: 44 | - {name: KUBE, value: 'true'} 45 | image: default-registry/generic-app:generic-containerContext-commit-thisisacommithash 46 | imagePullPolicy: Always 47 | name: build-containerContext 48 | - command: [uwsgi, --ini, /etc/uwsgi.conf] 49 | env: 50 | - {name: KUBE, value: 'true'} 51 | image: specific-registry/generic-app:generic-app-build-dockerfile-specify-registry-commit-thisisacommithash 52 | imagePullPolicy: Always 53 | name: build-dockerfile-specify-registry 54 | - command: [uwsgi, --ini, /etc/uwsgi.conf] 55 | env: 56 | - {name: KUBE, value: 'true'} 57 | image: specific-registry/generic-app:registry-containerContext-commit-thisisacommithash 58 | imagePullPolicy: Always 59 | name: build-containerContext-specify-registry 60 | -------------------------------------------------------------------------------- /tests/configs/docker_registry/kubetools.yml: -------------------------------------------------------------------------------- 1 | name: generic-app 2 | 3 | containerContexts: 4 | generic-containerContext: 5 | build: 6 | dockerfile: Dockerfile 7 | registry-containerContext: 8 | build: 9 | dockerfile: Dockerfile 10 | registry: specific-registry 11 | 12 | deployments: 13 | generic-app: 14 | containers: 15 | image-with-embedded-registry: 16 | image: generic-registry/generic-image 17 | command: [uwsgi, --ini, /etc/uwsgi.conf] 18 | 19 | # Not supported. Do we want to support this? I think it's covered by ^this^ 20 | # image-with-specify-registry: 21 | # image: generic-image 22 | # registry: specific-registry 23 | # command: [uwsgi, --ini, /etc/uwsgi.conf] 24 | 25 | image-without-registry: 26 | image: generic-image 27 | command: [uwsgi, --ini, /etc/uwsgi.conf] 28 | 29 | build-dockerfile: 30 | build: 31 | dockerfile: Dockerfile 32 | command: [ uwsgi, --ini, /etc/uwsgi.conf ] 33 | 34 | build-containerContext: 35 | containerContext: generic-containerContext 36 | command: [ uwsgi, --ini, /etc/uwsgi.conf ] 37 | 38 | build-dockerfile-specify-registry: 39 | build: 40 | dockerfile: Dockerfile 41 | registry: specific-registry 42 | command: [ uwsgi, --ini, /etc/uwsgi.conf ] 43 | 44 | build-containerContext-specify-registry: 45 | containerContext: registry-containerContext 46 | command: [ uwsgi, --ini, /etc/uwsgi.conf ] 47 | -------------------------------------------------------------------------------- /tests/configs/k8s_container_passthrough/k8s_deployments.yml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | annotations: {app.kubernetes.io/managed-by: kubetools} 5 | labels: {kubetools/name: generic-app-passthrough-generic-app, kubetools/project_name: generic-app-passthrough, 6 | kubetools/role: app} 7 | name: generic-app-passthrough-generic-app 8 | spec: 9 | replicas: 1 10 | revisionHistoryLimit: 5 11 | selector: 12 | matchLabels: {kubetools/name: generic-app-passthrough-generic-app, kubetools/project_name: generic-app-passthrough, 13 | kubetools/role: app} 14 | template: 15 | metadata: 16 | labels: {kubetools/name: generic-app-passthrough-generic-app, kubetools/project_name: generic-app-passthrough, 17 | kubetools/role: app} 18 | spec: 19 | containers: 20 | - arbitrary-passthrough-key: true 21 | command: [generic-command] 22 | containerContext: generic-context 23 | env: 24 | - {name: KUBE, value: 'true'} 25 | image: generic-image 26 | imagePullPolicy: Always 27 | livenessProbe: 28 | httpGet: {path: /ping, port: 80} 29 | timeoutSeconds: 5 30 | name: webserver 31 | readinessProbe: 32 | httpGet: {path: /ping, port: 80} 33 | timeoutSeconds: 5 34 | -------------------------------------------------------------------------------- /tests/configs/k8s_container_passthrough/k8s_services.yml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Service 3 | metadata: 4 | annotations: {app.kubernetes.io/managed-by: kubetools} 5 | labels: {kubetools/name: generic-app-passthrough-generic-app, kubetools/project_name: generic-app-passthrough, 6 | kubetools/role: app} 7 | name: generic-app-passthrough-generic-app 8 | spec: 9 | ports: 10 | - {port: 80, targetPort: 80} 11 | selector: {kubetools/name: generic-app-passthrough-generic-app, kubetools/project_name: generic-app-passthrough, 12 | kubetools/role: app} 13 | type: NodePort 14 | -------------------------------------------------------------------------------- /tests/configs/k8s_container_passthrough/kubetools.yml: -------------------------------------------------------------------------------- 1 | name: generic-app-passthrough 2 | 3 | 4 | containerContexts: 5 | generic-context: 6 | image: generic-image 7 | command: [generic-command] 8 | ports: 9 | - 80 10 | 11 | 12 | deployments: 13 | generic-app: 14 | containers: 15 | webserver: 16 | command: [uwsgi, --ini, /etc/uwsgi.conf] 17 | containerContext: generic-context 18 | probes: 19 | timeoutSeconds: 5 20 | httpGet: 21 | path: /ping 22 | arbitrary-passthrough-key: yes 23 | -------------------------------------------------------------------------------- /tests/configs/k8s_cronjobs_beta_api_version/k8s_cronjobs_beta.yml: -------------------------------------------------------------------------------- 1 | apiVersion: batch/v1 2 | kind: CronJob 3 | metadata: 4 | name: cronjob-v1-apiversion 5 | labels: { 6 | kubetools/name: cronjob-v1-apiversion, 7 | kubetools/project_name: generic-app, 8 | kubetools/role: cronjob 9 | } 10 | annotations: { 11 | app.kubernetes.io/managed-by: kubetools, 12 | description: 'Run: [''generic-command'']' 13 | } 14 | spec: 15 | schedule: "*/1 * * * *" 16 | startingDeadlineSeconds: 10 17 | concurrencyPolicy: "Allow" 18 | jobTemplate: 19 | spec: 20 | template: 21 | metadata: 22 | name: cronjob-v1-apiversion 23 | labels: { 24 | kubetools/name: cronjob-v1-apiversion, 25 | kubetools/project_name: generic-app, 26 | kubetools/role: cronjob 27 | } 28 | annotations: { 29 | app.kubernetes.io/managed-by: kubetools, 30 | description: 'Run: [''generic-command'']' 31 | } 32 | spec: 33 | containers: 34 | - command: [generic-command] 35 | containerContext: generic-context 36 | env: 37 | - {name: KUBE, value: 'true'} 38 | image: generic-image 39 | imagePullPolicy: 'Always' 40 | name: generic-container 41 | restartPolicy: OnFailure 42 | 43 | --- 44 | 45 | apiVersion: batch/v1beta1 46 | kind: CronJob 47 | metadata: 48 | name: cronjob-beta-apiversion 49 | labels: { 50 | kubetools/name: cronjob-beta-apiversion, 51 | kubetools/project_name: generic-app, 52 | kubetools/role: cronjob 53 | } 54 | annotations: { 55 | app.kubernetes.io/managed-by: kubetools, 56 | description: 'Run: [''generic-command'']' 57 | } 58 | spec: 59 | schedule: "0 0 * * *" 60 | startingDeadlineSeconds: 10 61 | concurrencyPolicy: "Replace" 62 | jobTemplate: 63 | spec: 64 | template: 65 | metadata: 66 | name: cronjob-beta-apiversion 67 | labels: { 68 | kubetools/name: cronjob-beta-apiversion, 69 | kubetools/project_name: generic-app, 70 | kubetools/role: cronjob 71 | } 72 | annotations: { 73 | app.kubernetes.io/managed-by: kubetools, 74 | description: 'Run: [''generic-command'']' 75 | } 76 | spec: 77 | containers: 78 | - command: [generic-command] 79 | containerContext: generic-context 80 | env: 81 | - {name: KUBE, value: 'true'} 82 | image: generic-image 83 | imagePullPolicy: 'Always' 84 | name: generic-container 85 | restartPolicy: OnFailure 86 | 87 | --- 88 | 89 | kind: CronJob 90 | metadata: 91 | name: cronjob-default-apiversion 92 | labels: 93 | kubetools/name: cronjob-default-apiversion 94 | kubetools/project_name: generic-app 95 | kubetools/role: cronjob 96 | annotations: 97 | app.kubernetes.io/managed-by: kubetools 98 | description: 'Run: [''generic-command'']' 99 | spec: 100 | schedule: "0 0 * * *" 101 | startingDeadlineSeconds: 10 102 | concurrencyPolicy: "Replace" 103 | jobTemplate: 104 | spec: 105 | template: 106 | metadata: 107 | name: cronjob-default-apiversion 108 | labels: 109 | kubetools/name: cronjob-default-apiversion 110 | kubetools/project_name: generic-app 111 | kubetools/role: cronjob 112 | annotations: 113 | app.kubernetes.io/managed-by: kubetools 114 | description: 'Run: [''generic-command'']' 115 | spec: 116 | containers: 117 | - command: [generic-command] 118 | containerContext: generic-context 119 | env: 120 | - {name: KUBE, value: 'true'} 121 | image: generic-image 122 | imagePullPolicy: 'Always' 123 | name: generic-container 124 | restartPolicy: OnFailure 125 | -------------------------------------------------------------------------------- /tests/configs/k8s_cronjobs_beta_api_version/kubetools.yml: -------------------------------------------------------------------------------- 1 | name: generic-app 2 | 3 | 4 | containerContexts: 5 | generic-context: 6 | image: generic-image 7 | command: [generic-command] 8 | ports: 9 | - 80 10 | 11 | 12 | cronjobs: 13 | cronjob-v1-apiversion: 14 | batch-api-version: 'batch/v1' 15 | schedule: "*/1 * * * *" 16 | concurrency_policy: "Allow" 17 | containers: 18 | generic-container: 19 | containerContext: generic-context 20 | 21 | cronjob-beta-apiversion: 22 | batch-api-version: 'batch/v1beta1' 23 | schedule: "0 0 * * *" 24 | concurrency_policy: "Replace" 25 | containers: 26 | generic-container: 27 | containerContext: generic-context 28 | 29 | cronjob-default-apiversion: 30 | schedule: "0 0 * * *" 31 | concurrency_policy: "Replace" 32 | containers: 33 | generic-container: 34 | containerContext: generic-context 35 | -------------------------------------------------------------------------------- /tests/configs/k8s_with_mounted_secrets/k8s_cronjobs.yml: -------------------------------------------------------------------------------- 1 | kind: CronJob 2 | metadata: 3 | name: generic-cronjob 4 | labels: { 5 | kubetools/name: generic-cronjob, 6 | kubetools/project_name: generic-app-with-secrets, 7 | kubetools/role: cronjob 8 | } 9 | annotations: { 10 | app.kubernetes.io/managed-by: kubetools, 11 | description: 'Run: [''generic-command'']' 12 | } 13 | spec: 14 | schedule: "*/1 * * * *" 15 | startingDeadlineSeconds: 10 16 | concurrencyPolicy: "Allow" 17 | jobTemplate: 18 | spec: 19 | template: 20 | metadata: 21 | name: generic-cronjob 22 | labels: { 23 | kubetools/name: generic-cronjob, 24 | kubetools/project_name: generic-app-with-secrets, 25 | kubetools/role: cronjob 26 | } 27 | annotations: { 28 | app.kubernetes.io/managed-by: kubetools, 29 | description: 'Run: [''generic-command'']' 30 | } 31 | spec: 32 | serviceAccountName: cronjob-account 33 | containers: 34 | - command: [generic-command] 35 | containerContext: generic-context 36 | env: 37 | - {name: KUBE, value: 'true'} 38 | image: generic-image 39 | imagePullPolicy: 'Always' 40 | name: generic-container 41 | volumeMounts: 42 | - {name: secret-volume, mountPath: /mnt/cronjobs-secrets-store, readonly: True} 43 | restartPolicy: OnFailure 44 | volumes: 45 | - name: secret-volume 46 | csi: 47 | driver: secrets-store.csi.k8s.io 48 | readOnly: true 49 | volumeAttributes: 50 | secretProviderClass: cronjob-secrets 51 | -------------------------------------------------------------------------------- /tests/configs/k8s_with_mounted_secrets/k8s_deployments.yml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | annotations: {app.kubernetes.io/managed-by: kubetools} 5 | labels: {kubetools/name: generic-app-with-secrets-generic-deployment-with-secrets, kubetools/project_name: generic-app-with-secrets, 6 | kubetools/role: app} 7 | name: generic-app-with-secrets-generic-deployment-with-secrets 8 | spec: 9 | replicas: 1 10 | revisionHistoryLimit: 5 11 | selector: 12 | matchLabels: {kubetools/name: generic-app-with-secrets-generic-deployment-with-secrets, kubetools/project_name: generic-app-with-secrets, 13 | kubetools/role: app} 14 | template: 15 | metadata: 16 | labels: {kubetools/name: generic-app-with-secrets-generic-deployment-with-secrets, kubetools/project_name: generic-app-with-secrets, 17 | kubetools/role: app} 18 | spec: 19 | serviceAccountName: deployment-account 20 | containers: 21 | - command: [generic-command] 22 | containerContext: generic-context 23 | env: 24 | - {name: KUBE, value: 'true'} 25 | image: generic-image 26 | imagePullPolicy: Always 27 | name: generic-deployment-workers 28 | volumeMounts: 29 | - {name: secret-volume, mountPath: /mnt/secrets-store, readonly: True} 30 | volumes: 31 | - name: secret-volume 32 | csi: 33 | driver: secrets-store.csi.k8s.io 34 | readOnly: true 35 | volumeAttributes: 36 | secretProviderClass: deployment-secrets 37 | 38 | --- 39 | 40 | apiVersion: apps/v1 41 | kind: Deployment 42 | metadata: 43 | annotations: {app.kubernetes.io/managed-by: kubetools} 44 | labels: {kubetools/name: generic-app-with-secrets-generic-dependency-with-secrets, kubetools/project_name: generic-app-with-secrets, 45 | kubetools/role: dependency} 46 | name: generic-app-with-secrets-generic-dependency-with-secrets 47 | spec: 48 | replicas: 1 49 | revisionHistoryLimit: 5 50 | selector: 51 | matchLabels: {kubetools/name: generic-app-with-secrets-generic-dependency-with-secrets, kubetools/project_name: generic-app-with-secrets, 52 | kubetools/role: dependency} 53 | template: 54 | metadata: 55 | labels: {kubetools/name: generic-app-with-secrets-generic-dependency-with-secrets, kubetools/project_name: generic-app-with-secrets, 56 | kubetools/role: dependency} 57 | spec: 58 | serviceAccountName: dependency-account 59 | containers: 60 | - command: [generic-command] 61 | containerContext: generic-context 62 | env: 63 | - {name: KUBE, value: 'true'} 64 | image: generic-image 65 | imagePullPolicy: Always 66 | name: generic-dependency 67 | volumeMounts: 68 | - {name: secret-volume, mountPath: /mnt/secrets-store, readonly: True} 69 | volumes: 70 | - name: secret-volume 71 | csi: 72 | driver: secrets-store.csi.k8s.io 73 | readOnly: true 74 | volumeAttributes: 75 | secretProviderClass: dependency-secrets 76 | -------------------------------------------------------------------------------- /tests/configs/k8s_with_mounted_secrets/k8s_jobs.yml: -------------------------------------------------------------------------------- 1 | apiVersion: batch/v1 2 | kind: Job 3 | metadata: 4 | annotations: {app.kubernetes.io/managed-by: kubetools, description: 'Run: [''generic-command'']'} 5 | labels: {job-id: UUID, kubetools/project_name: generic-app-with-secrets, 6 | kubetools/role: job} 7 | name: UUID 8 | spec: 9 | completions: 1 10 | parallelism: 1 11 | selector: {job-id: UUID, kubetools/project_name: generic-app-with-secrets, 12 | kubetools/role: job} 13 | template: 14 | metadata: 15 | labels: {job-id: UUID, kubetools/project_name: generic-app-with-secrets, 16 | kubetools/role: job} 17 | spec: 18 | serviceAccountName: upgrade-account 19 | containers: 20 | - chdir: / 21 | command: [generic-command] 22 | env: 23 | - {name: KUBE, value: 'true'} 24 | - {name: KUBE_JOB_ID, value: UUID} 25 | image: generic-image 26 | imagePullPolicy: Always 27 | name: upgrade 28 | resources: {} 29 | volumeMounts: 30 | - {name: secret-volume, mountPath: /mnt/upgrades-secrets-store, readonly: True} 31 | restartPolicy: Never 32 | volumes: 33 | - name: secret-volume 34 | csi: 35 | driver: secrets-store.csi.k8s.io 36 | readOnly: true 37 | volumeAttributes: 38 | secretProviderClass: upgrade-secrets 39 | -------------------------------------------------------------------------------- /tests/configs/k8s_with_mounted_secrets/kubetools.yml: -------------------------------------------------------------------------------- 1 | name: generic-app-with-secrets 2 | 3 | containerContexts: 4 | generic-context: 5 | image: generic-image 6 | command: [generic-command] 7 | 8 | upgrades: 9 | - name: Upgrade the database 10 | containerContext: generic-context 11 | serviceAccountName: upgrade-account 12 | secrets: 13 | secret-volume: 14 | mountPath: /mnt/upgrades-secrets-store 15 | secretProviderClass: upgrade-secrets 16 | 17 | dependencies: 18 | generic-dependency-with-secrets: 19 | serviceAccountName: dependency-account 20 | secrets: 21 | secret-volume: 22 | mountPath: /mnt/secrets-store 23 | secretProviderClass: dependency-secrets 24 | containers: 25 | generic-dependency: 26 | containerContext: generic-context 27 | 28 | 29 | deployments: 30 | generic-deployment-with-secrets: 31 | serviceAccountName: deployment-account 32 | secrets: 33 | secret-volume: 34 | mountPath: /mnt/secrets-store 35 | secretProviderClass: deployment-secrets 36 | containers: 37 | generic-deployment-workers: 38 | containerContext: generic-context 39 | 40 | 41 | cronjobs: 42 | generic-cronjob: 43 | schedule: "*/1 * * * *" 44 | concurrency_policy: "Allow" 45 | serviceAccountName: cronjob-account 46 | secrets: 47 | secret-volume: 48 | mountPath: /mnt/cronjobs-secrets-store 49 | secretProviderClass: cronjob-secrets 50 | containers: 51 | generic-container: 52 | containerContext: generic-context 53 | -------------------------------------------------------------------------------- /tests/configs/k8s_with_node_selectors/k8s_cronjobs.yml: -------------------------------------------------------------------------------- 1 | kind: CronJob 2 | metadata: 3 | name: generic-cronjob 4 | labels: { 5 | kubetools/name: generic-cronjob, 6 | kubetools/project_name: generic-app-with-node-selectors, 7 | kubetools/role: cronjob 8 | } 9 | annotations: { 10 | app.kubernetes.io/managed-by: kubetools, 11 | description: 'Run: [''generic-command'']' 12 | } 13 | spec: 14 | schedule: "*/1 * * * *" 15 | startingDeadlineSeconds: 10 16 | concurrencyPolicy: "Allow" 17 | jobTemplate: 18 | spec: 19 | template: 20 | metadata: 21 | name: generic-cronjob 22 | labels: { 23 | kubetools/name: generic-cronjob, 24 | kubetools/project_name: generic-app-with-node-selectors, 25 | kubetools/role: cronjob 26 | } 27 | annotations: { 28 | app.kubernetes.io/managed-by: kubetools, 29 | description: 'Run: [''generic-command'']' 30 | } 31 | spec: 32 | containers: 33 | - command: [generic-command] 34 | containerContext: generic-context 35 | env: 36 | - {name: KUBE, value: 'true'} 37 | image: generic-image 38 | imagePullPolicy: 'Always' 39 | name: generic-container 40 | restartPolicy: OnFailure 41 | nodeSelector: 42 | - nodeLabelName: node-upgrades 43 | nodeLabelOS: ubuntu22 44 | -------------------------------------------------------------------------------- /tests/configs/k8s_with_node_selectors/k8s_deployments.yml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | annotations: {app.kubernetes.io/managed-by: kubetools} 5 | labels: {kubetools/name: generic-app-with-node-selectors-generic-deployment-with-node-selectors, kubetools/project_name: generic-app-with-node-selectors, 6 | kubetools/role: app} 7 | name: generic-app-with-node-selectors-generic-deployment-with-node-selectors 8 | spec: 9 | replicas: 1 10 | revisionHistoryLimit: 5 11 | selector: 12 | matchLabels: {kubetools/name: generic-app-with-node-selectors-generic-deployment-with-node-selectors, kubetools/project_name: generic-app-with-node-selectors, 13 | kubetools/role: app} 14 | template: 15 | metadata: 16 | labels: {kubetools/name: generic-app-with-node-selectors-generic-deployment-with-node-selectors, kubetools/project_name: generic-app-with-node-selectors, 17 | kubetools/role: app} 18 | spec: 19 | containers: 20 | - command: [generic-command] 21 | containerContext: generic-context 22 | env: 23 | - {name: KUBE, value: 'true'} 24 | image: generic-image 25 | imagePullPolicy: Always 26 | name: generic-deployment-workers 27 | nodeSelector: 28 | - nodeLabelName: node-deployments 29 | nodeLabelOS: ubuntu22 30 | 31 | --- 32 | 33 | apiVersion: apps/v1 34 | kind: Deployment 35 | metadata: 36 | annotations: {app.kubernetes.io/managed-by: kubetools} 37 | labels: {kubetools/name: generic-app-with-node-selectors-generic-dependency-with-node-selectors, kubetools/project_name: generic-app-with-node-selectors, 38 | kubetools/role: dependency} 39 | name: generic-app-with-node-selectors-generic-dependency-with-node-selectors 40 | spec: 41 | replicas: 1 42 | revisionHistoryLimit: 5 43 | selector: 44 | matchLabels: {kubetools/name: generic-app-with-node-selectors-generic-dependency-with-node-selectors, kubetools/project_name: generic-app-with-node-selectors, 45 | kubetools/role: dependency} 46 | template: 47 | metadata: 48 | labels: {kubetools/name: generic-app-with-node-selectors-generic-dependency-with-node-selectors, kubetools/project_name: generic-app-with-node-selectors, 49 | kubetools/role: dependency} 50 | spec: 51 | containers: 52 | - command: [generic-command] 53 | containerContext: generic-context 54 | env: 55 | - {name: KUBE, value: 'true'} 56 | image: generic-image 57 | imagePullPolicy: Always 58 | name: generic-dependency 59 | nodeSelector: 60 | - nodeLabelName: node-dependencies 61 | nodeLabelOS: ubuntu22 62 | -------------------------------------------------------------------------------- /tests/configs/k8s_with_node_selectors/k8s_jobs.yml: -------------------------------------------------------------------------------- 1 | apiVersion: batch/v1 2 | kind: Job 3 | metadata: 4 | annotations: {app.kubernetes.io/managed-by: kubetools, description: 'Run: [''generic-command'']'} 5 | labels: {job-id: UUID, kubetools/project_name: generic-app-with-node-selectors, 6 | kubetools/role: job} 7 | name: UUID 8 | spec: 9 | completions: 1 10 | parallelism: 1 11 | selector: {job-id: UUID, kubetools/project_name: generic-app-with-node-selectors, 12 | kubetools/role: job} 13 | template: 14 | metadata: 15 | labels: {job-id: UUID, kubetools/project_name: generic-app-with-node-selectors, 16 | kubetools/role: job} 17 | spec: 18 | containers: 19 | - chdir: / 20 | command: [generic-command] 21 | env: 22 | - {name: KUBE, value: 'true'} 23 | - {name: KUBE_JOB_ID, value: UUID} 24 | image: generic-image 25 | imagePullPolicy: Always 26 | name: upgrade 27 | resources: {} 28 | restartPolicy: Never 29 | nodeSelector: 30 | - nodeLabelName: node-upgrades 31 | nodeLabelOS: ubuntu22 32 | -------------------------------------------------------------------------------- /tests/configs/k8s_with_node_selectors/kubetools.yml: -------------------------------------------------------------------------------- 1 | name: generic-app-with-node-selectors 2 | 3 | containerContexts: 4 | generic-context: 5 | image: generic-image 6 | command: [generic-command] 7 | 8 | upgrades: 9 | - name: Upgrade the database 10 | containerContext: generic-context 11 | nodeSelector: 12 | nodeLabelName: node-upgrades 13 | nodeLabelOS: ubuntu22 14 | 15 | dependencies: 16 | generic-dependency-with-node-selectors: 17 | nodeSelector: 18 | nodeLabelName: node-dependencies 19 | nodeLabelOS: ubuntu22 20 | containers: 21 | generic-dependency: 22 | containerContext: generic-context 23 | 24 | 25 | deployments: 26 | generic-deployment-with-node-selectors: 27 | nodeSelector: 28 | nodeLabelName: node-deployments 29 | nodeLabelOS: ubuntu22 30 | containers: 31 | generic-deployment-workers: 32 | containerContext: generic-context 33 | 34 | 35 | cronjobs: 36 | generic-cronjob-with-node-selectors: 37 | schedule: "*/1 * * * *" 38 | concurrency_policy: "Allow" 39 | nodeSelector: 40 | nodeLabelName: node-cronjobs 41 | nodeLabelOS: ubuntu22 42 | containers: 43 | generic-container: 44 | containerContext: generic-context 45 | -------------------------------------------------------------------------------- /tests/configs/ktd-compose-bug/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.9-slim 2 | ENTRYPOINT ["python"] 3 | -------------------------------------------------------------------------------- /tests/configs/ktd-compose-bug/kubetools.yml: -------------------------------------------------------------------------------- 1 | name: reproduce-ktd-bug-with-compose 2 | 3 | containerContexts: 4 | container-with-entrypoint: 5 | build: 6 | dockerfile: Dockerfile 7 | 8 | 9 | deployments: 10 | demo-app: 11 | containers: 12 | demo-container: 13 | containerContext: container-with-entrypoint 14 | args: ["-c", "print('Hello World')"] # this works in k8s 15 | dev: 16 | command: ["-c", "print('Hello World')"] # this works in ktd 17 | -------------------------------------------------------------------------------- /tests/configs/multiple_deployments/k8s_deployments.yml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | annotations: {app.kubernetes.io/managed-by: kubetools} 5 | labels: {kubetools/name: generic-multi-app-generic-app-webserver, kubetools/project_name: generic-multi-app, 6 | kubetools/role: app} 7 | name: generic-multi-app-generic-app-webserver 8 | spec: 9 | replicas: 1 10 | revisionHistoryLimit: 5 11 | selector: 12 | matchLabels: {kubetools/name: generic-multi-app-generic-app-webserver, kubetools/project_name: generic-multi-app, 13 | kubetools/role: app} 14 | template: 15 | metadata: 16 | labels: {kubetools/name: generic-multi-app-generic-app-webserver, kubetools/project_name: generic-multi-app, 17 | kubetools/role: app} 18 | spec: 19 | containers: 20 | - command: [generic-command] 21 | containerContext: generic-context 22 | env: 23 | - {name: KUBE, value: 'true'} 24 | image: generic-image 25 | imagePullPolicy: Always 26 | name: webserver 27 | 28 | --- 29 | 30 | apiVersion: apps/v1 31 | kind: Deployment 32 | metadata: 33 | annotations: {app.kubernetes.io/managed-by: kubetools} 34 | labels: {kubetools/name: generic-multi-app-generic-app-workers, kubetools/project_name: generic-multi-app, 35 | kubetools/role: app} 36 | name: generic-multi-app-generic-app-workers 37 | spec: 38 | replicas: 1 39 | revisionHistoryLimit: 5 40 | selector: 41 | matchLabels: {kubetools/name: generic-multi-app-generic-app-workers, kubetools/project_name: generic-multi-app, 42 | kubetools/role: app} 43 | template: 44 | metadata: 45 | labels: {kubetools/name: generic-multi-app-generic-app-workers, kubetools/project_name: generic-multi-app, 46 | kubetools/role: app} 47 | spec: 48 | containers: 49 | - command: [generic-command] 50 | containerContext: generic-context 51 | env: 52 | - {name: KUBE, value: 'true'} 53 | image: generic-image 54 | imagePullPolicy: Always 55 | name: workers 56 | -------------------------------------------------------------------------------- /tests/configs/multiple_deployments/k8s_services.yml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Service 3 | metadata: 4 | annotations: {app.kubernetes.io/managed-by: kubetools} 5 | labels: {kubetools/name: generic-multi-app-generic-app-webserver, kubetools/project_name: generic-multi-app, 6 | kubetools/role: app} 7 | name: generic-multi-app-generic-app-webserver 8 | spec: 9 | ports: 10 | - {port: 80, targetPort: 80} 11 | selector: {kubetools/name: generic-multi-app-generic-app-webserver, kubetools/project_name: generic-multi-app, 12 | kubetools/role: app} 13 | type: NodePort 14 | 15 | --- 16 | 17 | apiVersion: v1 18 | kind: Service 19 | metadata: 20 | annotations: {app.kubernetes.io/managed-by: kubetools} 21 | labels: {kubetools/name: generic-multi-app-generic-app-workers, kubetools/project_name: generic-multi-app, 22 | kubetools/role: app} 23 | name: generic-multi-app-generic-app-workers 24 | spec: 25 | ports: 26 | - {port: 80, targetPort: 80} 27 | selector: {kubetools/name: generic-multi-app-generic-app-workers, kubetools/project_name: generic-multi-app, 28 | kubetools/role: app} 29 | type: NodePort 30 | -------------------------------------------------------------------------------- /tests/configs/multiple_deployments/kubetools.yml: -------------------------------------------------------------------------------- 1 | name: generic-multi-app 2 | 3 | 4 | containerContexts: 5 | generic-context: 6 | image: generic-image 7 | command: [generic-command] 8 | ports: 9 | - 80 10 | 11 | 12 | deployments: 13 | generic-app-webserver: 14 | containers: 15 | webserver: 16 | command: [uwsgi, --ini, /etc/uwsgi.conf] 17 | containerContext: generic-context 18 | 19 | generic-app-workers: 20 | maxReplicas: 1 21 | containers: 22 | workers: 23 | command: [celery-worker] 24 | containerContext: generic-context 25 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EDITD/kubetools/e836c75768d9e4af9e13680afcd5164c6f4ed7a0/tests/conftest.py -------------------------------------------------------------------------------- /tests/test_build_compose_command.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | 3 | from kubetools.dev.backends.docker_compose import build_run_compose_command 4 | 5 | 6 | class TestBuildComposeCommand(TestCase): 7 | def setUp(self): 8 | self.container = 'container' 9 | 10 | def test_simple_composite_command(self): 11 | command = ('python', 'scripts/blah.py', '--argument') 12 | result = build_run_compose_command(self.container, command, envvars=None) 13 | expected_result = ['run', '--entrypoint', 'python scripts/blah.py --argument', 'container'] 14 | self.assertEqual(expected_result, result) 15 | 16 | def test_simple_single_command(self): 17 | command = ('python scripts/blah.py --argument',) 18 | result = build_run_compose_command(self.container, command, envvars=None) 19 | expected_result = ['run', '--entrypoint', 'python scripts/blah.py --argument', 'container'] 20 | self.assertEqual(expected_result, result) 21 | 22 | def test_composite_command_is_shell_escaped(self): 23 | command = ('python', 'scripts/blah.py', '--argument', 'key: value') 24 | result = build_run_compose_command(self.container, command, envvars=None) 25 | expected_result = [ 26 | 'run', 27 | '--entrypoint', 28 | "python scripts/blah.py --argument 'key: value'", 29 | 'container', 30 | ] 31 | self.assertEqual(expected_result, result) 32 | 33 | def test_environment_variables_are_added(self): 34 | command = ('python', 'scripts/blah.py', '--argument') 35 | envvars = ('var1=val1', 'var2=val2') 36 | result = build_run_compose_command(self.container, command, envvars) 37 | expected_result = [ 38 | 'run', 39 | '-evar1=val1', 40 | '-evar2=val2', 41 | '--entrypoint', 42 | 'python scripts/blah.py --argument', 43 | 'container', 44 | ] 45 | self.assertEqual(expected_result, result) 46 | -------------------------------------------------------------------------------- /tests/test_config_generation.py: -------------------------------------------------------------------------------- 1 | from os import listdir, path 2 | from unittest import mock, TestCase 3 | 4 | import yaml 5 | 6 | from kubetools.config import load_kubetools_config 7 | from kubetools.deploy.image import get_container_contexts_from_config, get_docker_tag_for_commit 8 | from kubetools.kubernetes.api import get_object_name 9 | from kubetools.kubernetes.config import generate_kubernetes_configs_for_project 10 | 11 | 12 | class TestKubernetesConfigGeneration(TestCase): 13 | def test_basic_app_configs(self): 14 | _test_configs('basic_app') 15 | 16 | def test_dependencies_configs(self): 17 | _test_configs('dependencies') 18 | 19 | def test_dev_overrides_configs(self): 20 | _test_configs('dev_overrides', dev=True) 21 | 22 | def test_k8s_container_passthrough_configs(self): 23 | _test_configs('k8s_container_passthrough') 24 | 25 | def test_k8s_cronjobs_beta_api_version_configs(self): 26 | _test_configs('k8s_cronjobs_beta_api_version') 27 | 28 | def test_multiple_deployments_configs(self): 29 | _test_configs('multiple_deployments') 30 | 31 | def test_docker_registry_configs(self): 32 | _test_configs('docker_registry', default_registry='default-registry') 33 | 34 | def test_k8s_with_mounted_secrets_configs(self): 35 | _test_configs('k8s_with_mounted_secrets') 36 | 37 | 38 | def _test_configs(folder_name, default_registry=None, **kwargs): 39 | app_dir = path.join('tests', 'configs', folder_name) 40 | 41 | kubetools_config = load_kubetools_config(app_dir, **kwargs) 42 | 43 | # TODO: refactor deploy.image._ensure_docker_images to extract the logic to a function and 44 | # de-duplicate it from here 45 | build_contexts = get_container_contexts_from_config(kubetools_config) 46 | context_name_to_registry = { 47 | context_name: build_context.get('registry', default_registry) 48 | for context_name, build_context in build_contexts.items() 49 | } 50 | context_images = { 51 | # Build the context name -> image dict 52 | context_name: get_docker_tag_for_commit( 53 | context_name_to_registry[context_name], 54 | kubetools_config['name'], 55 | context_name, 56 | 'thisisacommithash', 57 | ) 58 | for context_name in build_contexts.keys() 59 | } 60 | 61 | with mock.patch('kubetools.kubernetes.config.job.uuid4', lambda: 'UUID'): 62 | services, deployments, jobs, cronjobs = generate_kubernetes_configs_for_project( 63 | kubetools_config, 64 | default_registry=default_registry, 65 | context_name_to_image=context_images, 66 | ) 67 | 68 | k8s_files = listdir(app_dir) 69 | 70 | if services or 'k8s_services.yml' in k8s_files: 71 | _assert_yaml_objects(services, path.join(app_dir, 'k8s_services.yml')) 72 | if deployments or 'k8s_deployments.yml' in k8s_files: 73 | _assert_yaml_objects(deployments, path.join(app_dir, 'k8s_deployments.yml')) 74 | if jobs or 'k8s_jobs.yml' in k8s_files: 75 | _assert_yaml_objects(jobs, path.join(app_dir, 'k8s_jobs.yml')) 76 | if cronjobs and 'k8s_cronjobs.yml' in k8s_files: 77 | _assert_yaml_objects(cronjobs, path.join(app_dir, 'k8s_cronjobs.yml')) 78 | if cronjobs and 'k8s_cronjobs_beta.yml' in k8s_files: 79 | _assert_yaml_objects(cronjobs, path.join(app_dir, 'k8s_cronjobs_beta.yml')) 80 | 81 | 82 | def _assert_yaml_objects(objects, yaml_filename): 83 | with open(yaml_filename, 'r') as f: 84 | desired_objects = list(yaml.safe_load_all(f)) 85 | 86 | objects.sort(key=get_object_name) 87 | desired_objects.sort(key=get_object_name) 88 | 89 | assert objects == desired_objects 90 | -------------------------------------------------------------------------------- /tests/test_make_job_config.py: -------------------------------------------------------------------------------- 1 | from os import path 2 | from unittest import TestCase 3 | 4 | from kubetools.config import load_kubetools_config 5 | from kubetools.kubernetes.config import make_job_config 6 | 7 | 8 | def load_job_spec(): 9 | app_dir = path.join('tests', 'configs', 'basic_app') 10 | kubetools_config = load_kubetools_config(app_dir) 11 | return kubetools_config.get('upgrades')[0] 12 | 13 | 14 | class TestMakeJobConfig(TestCase): 15 | def test_job_id_is_added_to_envvars(self): 16 | job_config = make_job_config(load_job_spec()) 17 | container_env = job_config['spec']['template']['spec']['containers'][0]['env'] 18 | self.assertIn('KUBE_JOB_ID', [env['name'] for env in container_env]) 19 | 20 | def test_job_name_defaults_to_job_id(self): 21 | job_config = make_job_config(load_job_spec()) 22 | job_name = job_config['metadata']['name'] 23 | job_id = job_config['metadata']['labels']['job-id'] 24 | self.assertEqual(job_name, job_id) 25 | 26 | def test_job_name_can_be_set_by_caller(self): 27 | job_config = make_job_config(load_job_spec(), job_name='myawesomejob') 28 | job_name = job_config['metadata']['name'] 29 | self.assertEqual('myawesomejob', job_name) 30 | 31 | def test_container_name_defaults_to_upgrade(self): 32 | job_config = make_job_config(load_job_spec()) 33 | container_name = job_config['spec']['template']['spec']['containers'][0]['name'] 34 | self.assertEqual('upgrade', container_name) 35 | 36 | def test_container_name_can_be_set_by_caller(self): 37 | job_config = make_job_config(load_job_spec(), container_name='mycoolcontainer') 38 | container_name = job_config['spec']['template']['spec']['containers'][0]['name'] 39 | self.assertEqual('mycoolcontainer', container_name) 40 | 41 | def test_resources_being_passed(self): 42 | expected_resources = { 43 | "requests": { 44 | "memory": "1Gi", 45 | }, 46 | } 47 | job_config = make_job_config(load_job_spec()) 48 | resource_config = job_config['spec']['template']['spec']['containers'][0]['resources'] 49 | self.assertEqual(expected_resources, resource_config) 50 | 51 | def test_ttl_is_set_if_provided_in_config(self): 52 | job_spec = load_job_spec() 53 | ttl_option = {'ttl_seconds_after_finished': 100} 54 | job_spec.update(ttl_option) 55 | job_config = make_job_config(job_spec) 56 | self.assertIn('ttlSecondsAfterFinished', job_config['spec']) 57 | self.assertEqual( 58 | job_config['spec']['ttlSecondsAfterFinished'], 59 | ttl_option['ttl_seconds_after_finished'], 60 | ) 61 | --------------------------------------------------------------------------------