├── .gitignore ├── .gitlab-ci.yml ├── .travis.yml ├── Dockerfile ├── Makefile ├── README.md ├── __main__.py ├── deploy.png ├── deploy.py ├── deployment.py ├── kubectl.py ├── kubeutil.py ├── make_version.py ├── manifest.py ├── requirements.txt ├── shell.py ├── status.py ├── travis-build.sh ├── undeploy.py └── util.py /.gitignore: -------------------------------------------------------------------------------- 1 | *.swp 2 | /.kube 3 | /__pycache__ 4 | /kdtool.pyz 5 | /version.py 6 | /_dist 7 | /venv 8 | -------------------------------------------------------------------------------- /.gitlab-ci.yml: -------------------------------------------------------------------------------- 1 | # vim:set sw=2 ts=2 et: 2 | --- 3 | image: docker:latest 4 | 5 | variables: 6 | IMAGE_TAG: $CI_REGISTRY_IMAGE:$CI_BUILD_REF 7 | 8 | before_script: 9 | - docker login -u gitlab-ci-token -p $CI_BUILD_TOKEN $CI_REGISTRY 10 | 11 | build: 12 | stage: build 13 | tags: 14 | - docker 15 | 16 | script: 17 | - docker build --pull -t $IMAGE_TAG . 18 | - docker push $IMAGE_TAG 19 | - docker tag $IMAGE_TAG $CI_REGISTRY_IMAGE:latest 20 | - docker push $CI_REGISTRY_IMAGE:latest 21 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 3 | - 3.5 4 | sudo: required 5 | services: 6 | - docker 7 | addons: 8 | apt: 9 | packages: 10 | - docker-ce 11 | script: 12 | - ./travis-build.sh 13 | env: 14 | global: 15 | - COMMIT=${TRAVIS_COMMIT::8} 16 | - secure: nD52dwZB1fFyZ63IscwMIfVFryBkN/hknR4bfENhRDoNKJmy15PRPy0lc95obxq1NBPojWoYdeFkx31O1C2SMFmgHu0DRep8snn5LQdHIGjrxssF/2gCXuLj8RyDii1MjT6CEWS6Ni5yPu4rKDLO7JxSPwsCyyf3IqR8lofkuHaF/5cOpZdqTkkCPD0AfIP1ZJkxyDDernCbJJIs8S7M7QmHNhea8SgCYSk2mXkJNhChfXzxDQe1H8YRqJMfQaiY4x6k4fvaFkYN/CnQIR5fCxnmiCQUx56MMdL3EYbbcYDxiuow7VbaEgpVTSmqwRQEcW+toeZpuADx1nL6MMK2W25hESJdzMFWkTGr9ztC0f1uOVL6wdrmZBMXuts5bPeKq6pO1/8Knh2W+VJfvuFhAWfOfDXq7zpVHfPLXQMc3RQT/rpoge+3EeuQMcdeXnUvSTWuzulzxKdxAoIMywstPyVzVTWSvzvPj7C/zdjbZglQ+LUIySt13gBp2f6LSpnwKytfr6KYs/bwoF/cDVm7Mb++5rDlyy7ogbd7AeqrxVkh6EV34YYxmuWkVQoO8jFs5n8hLi9EccdnL03qLrajrLnPffEb9e3VFaqWX+0wLFh6CsrC14vGHnMv+/zKVf6TC7Lmp5bzR9ihS8aoPkbpE/N1yRIhi+WXx/1bBs7TWoM= 17 | - secure: IbPBtfexRuT0Nl8HnnysWhdd4v4o6MfRpZMu7a1P3dEmAOyUTnyoqDtWHRQVLJizLHJTzXSJGyilGY4Y5SAycpg8g/XrBD2c/7n4KbLs0xXIuTu/J/Mg/YKD103iQx1f00vo7Sv9aVJV2jubDBWKC8HWq3V+/3PxCc5R4fNPqirbb5dpjbq2bOZN1WVwnmioVwUUxc1B4+3PzbLLJNFjOrfCo8rn0atgCEw46SIItS3IVu1DlkBcZeWuEHJHZki2bR6540XtuaN7Xl+mnABYG+fSv2tZNX5rEKxSnGfL8lQybx5z/DSOo3NjvxIEmJGYnMBadD3GzGCeRMoNb1FKDQcxYEPJlNtt1SSod7ffAsvZjVdd6S4jBK99B7B2fyZktqD2/zSz97UD+wW4moMlIbRMb+4JbxJ8rX+4QsMkJ2DX1HU7WTdSjTmyyVVfgeWRrJPquKZiw7yyn07rih0bbfm8XdjEnICoNedCs6MVFExwPb9e0IDY9DUpJfrrc5QZhl2FqPJply+wpwGkFQYFqehASo1BFe2dxT+/3YhXCRLZlCrjyCvlDmk4iXogUVUVWZWx919w/UXvwFdewbJyQy6CQB2O7wR4cjkWK2RLAEeOnBMCM4Te32IcegHhgEViOcsK2W5pjaEZBzT5StpE3f0h/Fc6eQDX8Ngd2iRG/nU= 18 | deploy: 19 | provider: releases 20 | api_key: 21 | secure: JYtmSzjFysF/3NCxlVCmlX92m7dZOpFt5zxb4w9j3E15k/nXkg48PK5rh7BBnTsmFV5cHGYn85Kj7MDcCnBCO77kfp1ceLvlL5I6V4q8TNrnFaUrtPyIrNNhOnCtO1OGtHM06RdCYI8mEZYavQ+rGvWyUi4rjLaerpAbAAtziYC07+BZa6iPg6VhfmIvSnizZXRJVOvxYoZ8G7ddU/c9oOvWQmfDzNEbPbi5MFnM9l9wqyz93G+PZzarseiVdntMRc9Kr4m5PsCtLwzmeHZBIEfY8xEiFqpmUGS6ssqepEh+yXv4QwfiFx4LYRaRxQS/ObC36vkd3aEVtro7UdPhOXGsoX4XFLlHNk5Z42jUwk/pdFEUWWdamgydCOuzWj0toDlApnBSSk1M8I+8VowPu6g5DJ9AnvZRuxM/hm1OrMUyIc4ETu+fZGKNYoS/Zhp/Y8TjhQSuCyP3o/xoCTnrJtSjLZJVO0g11CHiUz78xqRhFfrThb6obEKEWuN7KewHXCiIAW+Gmj+LuapS3MAu+QyJCRNnwuvtmV8C7mEe4I2+/O4AVXqq7PJDIp7PuWdtoOPVsz7mURdN6oLDoMMQ8DIB5PkhTW7foQV1Clr4+hZCDk5BrU1lvwbPLPQhZzBm7NKJKJ3jzmUo6MEAncHxUNiQfD0LzBMlyAv8xIHVrfM= 22 | file: kdtool-${TRAVIS_TAG}.pyz 23 | skip_cleanup: true 24 | on: 25 | repo: torchbox/kdtool 26 | tags: true 27 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # vim:set sw=8 ts=8 noet: 2 | # Copyright (c) 2016-2017 Torchbox Ltd. 3 | # 4 | # Permission is granted to anyone to use this software for any purpose, 5 | # including commercial applications, and to alter it and redistribute it 6 | # freely. This software is provided 'as-is', without any express or implied 7 | # warranty. 8 | FROM alpine:3.6 AS build 9 | 10 | RUN apk update 11 | RUN apk add ca-certificates curl python3 make 12 | WORKDIR /usr/src/deploy 13 | COPY . . 14 | RUN make dist 15 | RUN curl -Lo kubectl \ 16 | https://storage.googleapis.com/kubernetes-release/release/v1.8.0/bin/linux/amd64/kubectl 17 | 18 | FROM alpine:3.6 19 | 20 | COPY --from=build /usr/src/deploy/kdtool.pyz /usr/local/bin/kdtool 21 | COPY --from=build /usr/src/deploy/kubectl /usr/local/bin/kubectl 22 | RUN apk add --no-cache ca-certificates python3 23 | RUN chmod 755 /usr/local/bin/kdtool /usr/local/bin/kubectl 24 | 25 | ENTRYPOINT [ "/usr/local/bin/kdtool" ] 26 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # vim:set sw=8 ts=8 noet: 2 | # Copyright (c) 2016-2017 Torchbox Ltd. 3 | # 4 | # Permission is granted to anyone to use this software for any purpose, 5 | # including commercial applications, and to alter it and redistribute it 6 | # freely. This software is provided 'as-is', without any express or implied 7 | # warranty. 8 | 9 | VERSION?= 1.8.0-dev 10 | PYTHON?= python3 11 | PIP?= pip3 12 | REPOSITORY?= torchbox/kdtool 13 | TAG?= latest 14 | 15 | # Debian has broken 'pip install --target'. The fix makes the command 16 | # incompatible with non-Debian systems, so it's impossible to support both. 17 | # On Debian and derived operating systems, run 'make BROKEN_DEBIAN=yes'. 18 | BROKEN_DEBIAN= no 19 | 20 | default: dist 21 | 22 | version.py: Makefile make_version.py 23 | ${PYTHON} make_version.py $(VERSION) 24 | 25 | dist: version.py 26 | rm -rf _dist kdtool.pyz 27 | mkdir _dist 28 | if test "${BROKEN_DEBIAN}" = "yes"; then \ 29 | ${PIP} install --system --no-compile --target _dist -r requirements.txt; \ 30 | else \ 31 | ${PIP} install --no-compile --target _dist -r requirements.txt; \ 32 | fi 33 | cp -r *.py _dist/ 34 | rm -rf _dist/*.egg-info _dist/*.dist-info 35 | ${PYTHON} -m zipapp -p "/usr/bin/env python3" -o kdtool.pyz _dist 36 | chmod 755 kdtool.pyz 37 | @ls -l kdtool.pyz 38 | 39 | docker-build: 40 | docker build -t ${REPOSITORY}:${TAG} . 41 | 42 | docker-push: 43 | docker push ${REPOSITORY}:${TAG} 44 | 45 | testing: 46 | ${MAKE} TAG=testing docker-build 47 | ${MAKE} TAG=testing docker-push 48 | 49 | .PHONY: default dist build push version.py 50 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # DEPRECATED 2 | This tool is no longer supported or expected to work. 3 | 4 | # Kubernetes deployment tool 5 | 6 | Note: Prior to version 1.8.0-1, kdtool was known as gitlab-kube-deploy. It has 7 | been renamed as its scope has expanded and it's no longer specific to GitLab. 8 | 9 | kdtool is a utility for deploying applications and interacting with deployed 10 | applications on Kubernetes, with an emphasis on deploying from GitLab and other 11 | CI applications. It can be installed and used standalone from the command 12 | line, or as a container image in Docker-based CI workflows. 13 | 14 | kdtool can deploy simple applications without needing a manifest. Based on 15 | command-line options, it can provision Deployments, Service and Ingresses, 16 | and configure environment variables (via Secrets or ConfigMaps), persistent 17 | volumes and databases (with an 18 | [external database controller](https://github.com/torchbox/k8s-database-controller/)), 19 | as well as ACME/Let's Encrypt TLS certificates (with 20 | [kube-lego](https://github.com/jetstack/kube-lego)) and HTTP authentication. 21 | 22 | For more complicated applications, kdtool can simplify YAML (or JSON) 23 | manifests, providing environment-based template variable substitution to deploy 24 | several different copies of an application (for example, a staging site and any 25 | number of review apps) from a single manifest. When deploying from CI, you can 26 | substitute `$IMAGE` in the manifest to easily deploy the image that was built. 27 | 28 | Even if you don't use kdtool to deploy your application, its `shell` command 29 | makes it easier to interact with running applications, e.g. to debug problems, 30 | copy files from to or from the application, or connect to the application's 31 | database. kdtool can start a shell (or any other command) using the same 32 | image, environment variables, volume mounts, and other configuration as any 33 | existing deployment, using a single command. 34 | 35 | kdtool's `status` command provides an overview of a Deployment, its ReplicaSets 36 | and their pods, to make problems with deployments easier to diagnose without 37 | having to examine each pod or ReplicaSet individually. 38 | 39 | ## Screenshot 40 | 41 | Use it from GitLab CI: 42 | 43 | ![A screenshot of a Gitlab CI build showing deploy being used](deploy.png) 44 | 45 | Or from the command line: 46 | 47 | ``` 48 | % kdtool deploy -H testapp.example.com --port=8080 gcr.io/google_containers/echoserver:1.4 testapp 49 | deployment "testapp" created 50 | service "testapp" created 51 | ingress "testapp" created 52 | % curl -sSi http://testapp.example.com 53 | HTTP/1.1 200 OK 54 | ... 55 | ``` 56 | 57 | ## Installation 58 | 59 | Download `kdtool.pyz` from the latest release and copy it to a convenient 60 | location, such as `/usr/local/bin/kdtool`. This is a Python zipapp and requires 61 | Python 3.4 or later to run. 62 | 63 | To use `kdtool deploy`, you must have `kubectl` installed. If you have an 64 | existing kubeconfig file (e.g. `$HOME/.kube/config`, or specified in 65 | `$KUBECONFIG`), kdtool will take configuration from there by default. 66 | 67 | Or, build from source: 68 | 69 | ``` 70 | % make dist 71 | % ./kdtool.pyz -h 72 | ``` 73 | 74 | Or, run from the Docker image: 75 | 76 | ``` 77 | % docker run --rm -ti torchbox/kdtool:latest -h 78 | ``` 79 | 80 | You don't need to install anything to use kdtool from GitLab CI; just specify 81 | the Docker image in the job. 82 | 83 | ## Using with GitLab CI 84 | 85 | Since GitLab 9.4, no additional configuration is required for use with GitLab. 86 | Use the Docker image `torchbox/kdtool:latest` in your CI job and invoke 87 | `kdtool deploy ...` as normal. 88 | 89 | Prior to GitLab 9.4, you must use `kdtool --gitlab deploy ...` to pick up the 90 | authentication configuration. 91 | 92 | Either way, kdtool requires that you have 93 | [set up Kubernetes integration](https://docs.gitlab.com/ce/user/project/integrations/kubernetes.html) 94 | for your GitLab project. 95 | 96 | ## General options 97 | 98 | These options affect the overall behaviour of kdtool and how it connects to 99 | your Kubernetes cluster. If you already have a working kubeconfig (e.g. for 100 | kubectl), you won't normally need any of these options except `--namespace`. 101 | 102 | * `-n ns, --namespace=ns`: Set the Kubernetes namespace to operate in. Default: 103 | `default`. 104 | * `-c ctx, --context=ctx`: Set the cluster context to use; the context must 105 | exist in the loaded kubeconfig file. 106 | * `-K path, --kubectl=path`: Specify the location of `kubectl` (default: 107 | autodetect). 108 | * `-G, --gitlab`: Take Kubernetes cluster details from GitLab environment 109 | variables. (Replaces `--namespace`, `--server`, `--ca-certificate` and 110 | `--token`; unnecessary since GitLab 9.4.) 111 | * `-S url, --server=url`: Set the URL of the Kubernetes API server (default: 112 | `http://localhost:8080`). 113 | * `-T token, --token=token`: Set Kubernetes authentication token. This can be a 114 | user token, a serviceaccount JWT token or whatever. No default. 115 | * `-C path, --ca-certificate=path`: Set Kubernetes API server CA certificate. 116 | No default. 117 | 118 | If you're running in-cluster and want to authenticate with the pod service 119 | account credentials, do not specify any authentication options; kubectl will 120 | pick up the service account details from the pod. 121 | 122 | ## Simple applications 123 | 124 | You can use `kdtool deploy` to deploy a simple application without creating a 125 | manifest. To deploy the image "myapp:latest" on "www.mysite.com": 126 | 127 | ``` 128 | kdtool deploy --hostname=www.mysite.com myapp:latest myapp 129 | ``` 130 | 131 | In a GitLab CI job, you can take the image and deployment name from environment 132 | variables: 133 | 134 | ``` 135 | kdtool deploy --hostname=www.mysite.com $CI_REGISTRY_IMAGE:$CI_BUILD_REF $CI_ENVIRONMENT_SLUG 136 | ``` 137 | 138 | This will create a Deployment, Service and Ingress in the Kubernete namespace 139 | configured in Gitlab. 140 | 141 | ### Undeploying 142 | 143 | Use `kdtool undeploy ` to delete a deployment. This will list the 144 | resource(s) that will be deleted and prompt for confirmation. 145 | 146 | To avoid the confirmation prompt, use `undeploy -f`. 147 | 148 | To delete all resources associated with the deployment, such as ingresses, 149 | databases and volumes, use `undeploy -A`. The list of resources to delete is 150 | taken from an annotation added to the deployment at creation time, so you don't 151 | need to tell kdtool what to delete. 152 | 153 | Older versions supported a different undeploy command, `kdtool deploy --undeploy`. 154 | This is obsolete and should not be used. 155 | 156 | ### Application options 157 | 158 | These options to `kdtool deploy` control how the application will be deployed. 159 | 160 | * `-H HOST, --hostname=HOST`: create an Ingress resource to route requets for 161 | the given hostname to the application. Providing a URL will also work, but 162 | everything except the hostname will be ignored. May be specified multiple 163 | times. 164 | * `-p PORT, --port=PORT`: Set the port the application container listens on 165 | (default 80). If your application doesn't listen on port 80, you must 166 | specify this for `--hostname` to work. 167 | * `-A, --acme`: Add annotations to the created Ingress to tell 168 | [kube-lego](https://github.com/jetstack/kube-lego) to issue a TLS certificate. 169 | * `-r N, --replicas=N`: Create N replicas of the application. 170 | * `-P policy, --image-pull-policy=policy`: Set the Kubernete images pull 171 | policy to `IfNotPresent` or `Always`. `Always` is only required if you push 172 | new versions without updating the image tag, which usually should not be the 173 | case with CI builds. 174 | * `-e VAR[=VALUE], --env=VAR[=VALUE]`: Set the given environment variable in the 175 | applications's environment. If no value is specified, it will be taken from 176 | the current environment. 177 | * `-s VAR=VALUE, --secret=VAR=VALUE`: Set the given environment variable in 178 | application's environment using a Kubernetes Secret. 179 | * `--memory-request`: Set Kubernetes memory request. 180 | * `--memory-limit`: Set Kubernetes memory limit. 181 | * `--cpu-request`: Set Kubernetes CPU request. 182 | * `--cpu-limit`: Set Kubernetes CPU limit. 183 | * `--strategy=TYPE`: set Deployment 184 | [update strategy](https://kubernetes.io/docs/concepts/workloads/controllers/deployment/#strategy). 185 | `rollingupdate` will replace each replica one at a time, enabling 186 | zero-downtime deployments. `recreate` will delete all pods, then create new 187 | pods to replace them; this will cause downtime during the deployment. The 188 | default is `rollingupdate`. 189 | 190 | ### Service options 191 | 192 | * `-v NAME:PATH, --volume=NAME:PATH`: Create a Persistent Volume Claim called 193 | `NAME` and mount it at `PATH`. For this to work, your cluster must have a 194 | functional PVC provisioner. Currently this always requests a `ReadWriteMany` 195 | volume, which means it does work with `--replicas` but does not work with GCE 196 | or AWS volumes. (It does work with NFS, CephFS, GlusterFS, etc.) 197 | * `--database=TYPE`: Attach a persistent database of the given type, which 198 | should be `mysql` or `postgresql`. This requires the Torchbox 199 | [database controller](https://github.com/torchbox/k8s-database-controller). 200 | Database connection details will be placed in `$DATABASE_URL`. 201 | * `--postgres=VERSION` (e.g. `--postgres=9.6`; EXPERIMENTAL, UNTESTED): 202 | Deploy a PostgreSQL sidecar container alongside the application and configure 203 | `$DATABASE_URL` with the access details. The PostgreSQL data is stored in a 204 | PVC, so this requires that your cluster has a functional PVC provisioner. The 205 | PVC will be deleted when the application is undeployed. This is intended for 206 | review apps, not production sites. **This will not work with --replicas**. 207 | * `--redis=MEMORY` (e.g. `--redis=64m`; EXPERIMENTAL, UNTESTED): Deploy a Redis 208 | sidecar container alongside the application and set `$CACHE_URL` to its 209 | location. Data stored in Redis is not persisted and the container is deleted 210 | when the application is undeployed. This is intended for review apps, not 211 | production sites. If used with `--replicas`, every replica will get its own 212 | Redis instance, which is probably not what you want. 213 | 214 | ### HTTP authentication options 215 | 216 | * `--htauth-user=USERNAME:PASSWORD`: Require HTTP basic authentication using 217 | this username and plaintext password. This may be specified multiple times. 218 | * `--htauth-address=1.2.3.0/24`: Reject requests from outside this IP range. 219 | May be specified multiple times. 220 | * `--htauth-satisfy=`: Control behaviour when both `--htauth-user` and 221 | `--htauth-address` are specified. If `all` (default), a valid password _and_ 222 | a whitelisted IP address are required or the connection will be rejected. If 223 | `any`, either is sufficient for access. 224 | 225 | Support for HTTP authentication varies greatly among Kubernetes Ingress 226 | controllers. As far as I know, the GKE/GCE Ingress controller doesn't support 227 | it at all. The nginx controller supports all the options except 228 | `--htauth-satisfy`. The only controller that supports `--htauth-satisfy` is 229 | [Traffic Server](https://github.com/torchbox/k8s-ts-ingress). 230 | 231 | This authentication is not intended to be secure: it accepts passwords in 232 | plaintext and hashes them using FreeBSD MD5. It's intended to prevent search 233 | engines and curious users from finding your staging sites, not to replace proper 234 | application-level authentication. 235 | 236 | ### Example .gitlab-ci.yml 237 | 238 | Use Gitlab dynamic environments to deploy any branch at 239 | `https://.myapp-staging.com`, except for `master` which is 240 | deployed at `https://www.myapp.com/` with two replicas: 241 | 242 | ``` 243 | --- 244 | image: docker:latest 245 | variables: 246 | IMAGE_TAG: $CI_REGISTRY_IMAGE:$CI_BUILD_REF 247 | 248 | stages: 249 | - build 250 | - deploy 251 | 252 | build: 253 | stage: build 254 | before_script: 255 | - docker login -u gitlab-ci-token -p $CI_BUILD_TOKEN $CI_REGISTRY 256 | script: 257 | - docker build -t $IMAGE_TAG . 258 | - docker push $IMAGE_TAG 259 | 260 | deploy_production: 261 | stage: deploy 262 | only: 263 | - master 264 | environment: 265 | name: $CI_BUILD_REF_NAME 266 | url: https://www.myapp.com 267 | image: torchbox/kdtool:latest 268 | script: 269 | - kdtool deploy -r2 -A -H www.myapp.com $IMAGE_TAG $CI_ENVIRONMENT_SLUG 270 | 271 | deploy_review: 272 | stage: deploy 273 | only: 274 | - branches 275 | except: 276 | - master 277 | environment: 278 | name: $CI_BUILD_REF_NAME 279 | url: https://$CI_ENVIRONMENT_SLUG.myapp-staging.com 280 | on_stop: undeploy_review 281 | image: torchbox/gitlab-kube-deploy:latest 282 | script: 283 | - kdtool deploy -A -H $CI_ENVIRONMENT_URL $IMAGE_TAG $CI_ENVIRONMENT_SLUG 284 | 285 | undeploy_review: 286 | stage: deploy 287 | when: manual 288 | 289 | environment: 290 | name: $CI_BUILD_REF_NAME 291 | action: stop 292 | 293 | image: torchbox/gitlab-kube-deploy:latest 294 | 295 | script: 296 | - deploy -G --undeploy -A -H $CI_ENVIRONMENT_URL $IMAGE_TAG $CI_ENVIRONMENT_SLUG 297 | ``` 298 | 299 | ## Custom manifests 300 | 301 | kdtool's automatic manifest generation isn't intended to cover every possible 302 | use case. If you like, you can provide your own manifest; kdtool will do 303 | variable substitution inside the manifest. 304 | 305 | Specifically, any string `$varname` or `${varname}` in the manifest will be 306 | replaced with the corresponding environment variable. This includes variables 307 | defined in `.gitlab-ci.yml`, like `$IMAGE`, and any variables defined as Gitlab 308 | Pipeline secrets. 309 | 310 | A variable of the form `${varname:b64encode}` will be Base64-encoded, which is 311 | useful for populating Kubernetes Secrets. 312 | 313 | Here is an example manifest that assumes `DATABASE_URL` and `SECRET_KEY` have 314 | been set as Gitlab secrets: 315 | 316 | ``` 317 | --- 318 | 319 | apiVersion: v1 320 | kind: Secret 321 | metadata: 322 | namespace: $KUBE_NAMESPACE 323 | name: $CI_ENVIRONMENT_SLUG 324 | type: Opaque 325 | data: 326 | DATABASE_URL: ${DATABASE_URL:b64encode} 327 | SECRET_KEY: ${SECRET_KEY:b64encode} 328 | 329 | --- 330 | 331 | apiVersion: extensions/v1beta1 332 | kind: Deployment 333 | metadata: 334 | namespace: $KUBE_NAMESPACE 335 | name: $CI_ENVIRONMENT_SLUG 336 | spec: 337 | replicas: 1 338 | strategy: 339 | type: Recreate 340 | selector: 341 | matchLabels: 342 | app: $CI_ENVIRONMENT_SLUG 343 | template: 344 | metadata: 345 | labels: 346 | app: $CI_ENVIRONMENT_SLUG 347 | spec: 348 | containers: 349 | - name: app 350 | image: $IMAGE 351 | envFrom: 352 | - secretRef: 353 | name: $CI_ENVIRONMENT_SLUG 354 | ports: 355 | - name: http 356 | containerPort: 80 357 | protocol: TCP 358 | 359 | --- 360 | 361 | apiVersion: v1 362 | kind: Service 363 | metadata: 364 | name: $CI_ENVIRONMENT_SLUG 365 | namespace: $KUBE_NAMESPACE 366 | spec: 367 | type: ClusterIP 368 | ports: 369 | - name: http 370 | port: 80 371 | protocol: TCP 372 | targetPort: http 373 | selector: 374 | app: $CI_ENVIRONMENT_SLUG 375 | 376 | --- 377 | 378 | apiVersion: extensions/v1beta1 379 | kind: Ingress 380 | metadata: 381 | annotations: 382 | kubernetes.io/tls-acme: "true" 383 | name: $CI_ENVIRONMENT_SLUG 384 | namespace: $KUBE_NAMESPACE 385 | spec: 386 | rules: 387 | - host: www.myapp.com 388 | http: 389 | paths: 390 | - backend: 391 | serviceName: $CI_ENVIRONMENT_SLUG 392 | servicePort: http 393 | tls: 394 | - hosts: 395 | - www.myapp.com 396 | secretName: ${CI_ENVIRONMENT_SLUG}-tls 397 | ``` 398 | 399 | You could use this manifest in `.gitlab-ci.yml` like this: 400 | 401 | ``` 402 | deploy_production: 403 | stage: deploy 404 | only: 405 | - master 406 | environment: 407 | name: $CI_BUILD_REF_NAME 408 | url: https://www.myapp.com 409 | image: torchbox/gitlab-kube-deploy:latest 410 | script: 411 | - kdtool deploy --manifest=deployment.yaml $IMAGE_TAG $CI_ENVIRONMENT_SLUG 412 | ``` 413 | 414 | ## Shell mode 415 | 416 | Use `kdtool shell` to start a shell for a deployment: 417 | 418 | ``` 419 | kdtool shell myapp 420 | ``` 421 | 422 | The argument should be the name of the deployment. Environment variables and 423 | volume mounts will be configured from the deployment, so the application's data 424 | and configuration will be available in the shell. 425 | 426 | By default, the application's image will be used for the shell. To use a 427 | different image, use `-i` / `--image`: 428 | 429 | ``` 430 | kdtool shell -i fedora:latest myapp 431 | ``` 432 | 433 | To run a different shell, use `-c` / `--command`: 434 | 435 | ``` 436 | kdtool shell -c /bin/zsh myapp 437 | ``` 438 | 439 | To run a non-interactive command, use `kdtool exec`: 440 | 441 | ``` 442 | kdtool exec myapp pg_dump '$(DATABASE_URL)' 443 | ``` 444 | 445 | ## Status 446 | 447 | Use `kdtool status` to show the status for a deployment. kdtool will attempt 448 | to detect errors and include them in the output: 449 | 450 | ``` 451 | % kdtool status testapp 452 | deployment testapp: 1 replica(s), current generation 5 453 | 2 active replica sets (* = current, ! = error): 454 | *!testapp-54d6fdb796: generation 5, 1 replicas configured, 0 ready 455 | pod testapp-54d6fdb796-94pck: Pending 456 | ImagePullBackOff: Back-off pulling image "torchbox/invalid-image:latest" 457 | testapp-755c4c48f: generation 4, 1 replicas configured, 1 ready 458 | pod testapp-755c4c48f-lxn57: Running 459 | ``` 460 | -------------------------------------------------------------------------------- /__main__.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env python3 2 | # vim:set sw=4 ts=4 et: 3 | # 4 | # Copyright (c) 2016-2017 Torchbox Ltd. 5 | # 6 | # Permission is granted to anyone to use this software for any purpose, 7 | # including commercial applications, and to alter it and redistribute it 8 | # freely. This software is provided 'as-is', without any express or implied 9 | # warranty. 10 | 11 | 12 | import argparse, json, subprocess, tempfile, re, humanfriendly 13 | from base64 import b64encode 14 | from os import environ 15 | from sys import stdout, stderr, exit, argv 16 | 17 | from kubectl import find_kubectl 18 | 19 | import deploy, undeploy, shell, status, kubeutil 20 | 21 | class PrintVersion(argparse.Action): 22 | def __call__(self, parser, namespace, values, option_string): 23 | try: 24 | import version 25 | print('kdtool version {}, {}.'.format( 26 | version.__version__, version.__info__)) 27 | except ImportError: 28 | print('kdtool, development build or manual installation.') 29 | exit(0) 30 | 31 | # Create our argument parser. 32 | parser = argparse.ArgumentParser(description='Kubernetes deployment tool') 33 | subparsers = parser.add_subparsers( 34 | title='commands', 35 | description='valid commands', 36 | help='additional help') 37 | 38 | # Global options for all modes; mostly to do with Kubernetes connection. 39 | parser.add_argument('-V', '--version', nargs=0, action=PrintVersion, 40 | help="Type program version and exit") 41 | parser.add_argument('-K', '--kubectl', type=str, metavar='PATH', 42 | help='Location of kubectl binary') 43 | parser.add_argument('-n', '--namespace', type=str, 44 | help='Kubernetes namespace to deploy in') 45 | parser.add_argument('-S', '--server', type=str, metavar='URL', 46 | help="Kubernetes API server URL") 47 | parser.add_argument('-T', '--token', type=str, 48 | help="Kubernetes authentication token") 49 | parser.add_argument('-C', '--ca-certificate', type=str, 50 | help="Kubernetes API server CA certificate") 51 | parser.add_argument('-G', '--gitlab', action='store_true', 52 | help="Configure Kubernetes from Gitlab CI") 53 | parser.add_argument('-c', '--context', type=str, metavar='CLUSTER', 54 | help='Configuration context from kubeconfig') 55 | 56 | # Add commands and their options from modules. 57 | def add_commands(cmds): 58 | for cmd in sorted(cmds): 59 | func = cmds[cmd] 60 | if hasattr(func, 'arguments'): 61 | p = subparsers.add_parser(cmd, help=func.help) 62 | p.set_defaults(func=func) 63 | for arg in func.arguments: 64 | p.add_argument(*arg[0], **arg[1]) 65 | 66 | add_commands(deploy.commands) 67 | add_commands(shell.commands) 68 | add_commands(status.commands) 69 | add_commands(undeploy.commands) 70 | args = parser.parse_args(argv[1:]) 71 | 72 | # Try to find kubectl. 73 | if args.kubectl is None: 74 | args.kubectl = find_kubectl() 75 | if args.kubectl is None: 76 | stderr.write('could not find kubectl executable anywhere in $PATH.\n') 77 | stderr.write('install kubectl in $PATH or pass -K/path/to/kubectl.\n') 78 | exit(1) 79 | 80 | # The Python client doesn't seem to pick up the namespace from kubeconfig, 81 | # which breaks GitLab's automatic configuration. Try to guess what it should 82 | # be. 83 | if args.namespace is None: 84 | if 'KUBE_NAMESPACE' in environ: 85 | args.namespace = environ['KUBE_NAMESPACE'] 86 | else: 87 | args.namespace = 'default' 88 | 89 | # Check for GitLab mode. 90 | if args.gitlab: 91 | if 'KUBECONFIG' in environ: 92 | stderr.write("""\ 93 | warning: argument -G/--gitlab specified but $KUBECONFIG is set in environment. 94 | since GitLab 9.4, the --gitlab option is no longer required and should 95 | be removed. 96 | warning: $KUBECONFIG will be ignored and Kubernetes configuration will be taken 97 | from legacy GitLab environment configuration. 98 | """) 99 | del environ['KUBECONFIG'] 100 | 101 | try: 102 | if 'KUBE_CA_PEM_FILE' in environ: 103 | args.ca_certificate = environ['KUBE_CA_PEM_FILE'] 104 | elif 'KUBE_CA_PEM' in environ: 105 | tmpf = tempfile.NamedTemporaryFile(delete=False) 106 | tmpf.write(environ['KUBE_CA_PEM'].encode('utf-8')) 107 | tmpf.close() 108 | args.ca_certificate = tmpf.name 109 | else: 110 | stderr.write("--gitlab: cannot determine Kubernetes CA certificate\n") 111 | exit(1) 112 | args.namespace = environ['KUBE_NAMESPACE'] 113 | args.server = environ['KUBE_URL'] 114 | args.token = environ['KUBE_TOKEN'] 115 | except KeyError as e: 116 | stderr.write("--gitlab: missing ${0} in environment\n".format(e.args[0])) 117 | exit(1) 118 | 119 | kubeutil.configure(args) 120 | 121 | # Run the subcommand requested by the user. 122 | if not hasattr(args, 'func'): 123 | stderr.write("no command given\n") 124 | exit(0) 125 | exit(args.func(args)) 126 | -------------------------------------------------------------------------------- /deploy.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/torchbox/kdtool/f9d8be687ef2fe446b443f2df9e877432dd5cf90/deploy.png -------------------------------------------------------------------------------- /deploy.py: -------------------------------------------------------------------------------- 1 | # vim:set sw=4 ts=4 et: 2 | # 3 | # Copyright (c) 2016-2017 Torchbox Ltd. 4 | # 5 | # Permission is granted to anyone to use this software for any purpose, 6 | # including commercial applications, and to alter it and redistribute it 7 | # freely. This software is provided 'as-is', without any express or implied 8 | # warranty. 9 | 10 | import subprocess, json, humanfriendly 11 | from base64 import b64encode 12 | from sys import stdin, stdout, stderr 13 | from os import environ 14 | from passlib.hash import md5_crypt 15 | 16 | import kubectl 17 | from util import strip_hostname 18 | 19 | 20 | # make_service: create a Service resource for the given arguments. 21 | def make_service(args): 22 | service = { 23 | 'apiVersion': 'v1', 24 | 'kind': 'Service', 25 | 'metadata': { 26 | 'name': args.name, 27 | 'namespace': args.namespace, 28 | }, 29 | 'spec': { 30 | 'ports': [ 31 | { 32 | 'name': 'http', 33 | 'port': 80, 34 | 'protocol': 'TCP', 35 | 'targetPort': 'http', 36 | }, 37 | ], 38 | 'selector': { 39 | 'app': args.name 40 | }, 41 | 'type': 'ClusterIP', 42 | }, 43 | } 44 | 45 | return service 46 | 47 | # make_ingress: create an Ingress resource for the given arguments. 48 | # returns: API object data structure 49 | def make_ingress(args): 50 | # The basic Ingress 51 | ingress = { 52 | 'apiVersion': 'extensions/v1beta1', 53 | 'kind': 'Ingress', 54 | 'metadata': { 55 | 'name': args.name, 56 | 'namespace': args.namespace, 57 | 'annotations': {} 58 | }, 59 | 'spec': { 60 | 'rules': [ 61 | { 62 | 'host': strip_hostname(hostname), 63 | 'http': { 64 | 'paths': [ 65 | { 66 | 'backend': { 67 | 'serviceName': args.name, 68 | 'servicePort': 80, 69 | }, 70 | }, 71 | ], 72 | }, 73 | } for hostname in args.hostname 74 | ], 75 | }, 76 | } 77 | 78 | # Add htauth 79 | 80 | secrets = [] 81 | 82 | if len(args.htauth_address): 83 | ingress['metadata']['annotations']\ 84 | ['ingress.kubernetes.io/whitelist-source-range'] = \ 85 | ",".join(args.htauth_address) 86 | 87 | if len(args.htauth_user): 88 | ingress['metadata']['annotations'].update({ 89 | 'ingress.kubernetes.io/auth-type': 'basic', 90 | 'ingress.kubernetes.io/auth-realm': args.htauth_realm, 91 | 'ingress.kubernetes.io/auth-satisfy': args.htauth_satisfy, 92 | 'ingress.kubernetes.io/auth-secret': args.name+'-htaccess', 93 | }) 94 | 95 | htpasswd = "" 96 | for auth in args.htauth_user: 97 | (u,p) = auth.split(":", 1) 98 | htpasswd += u + ":" + md5_crypt.hash(p) + "\n" 99 | 100 | secrets.append({ 101 | 'apiVersion': 'v1', 102 | 'kind': 'Secret', 103 | 'metadata': { 104 | 'name': args.name+'-htaccess', 105 | 'namespace': args.namespace, 106 | }, 107 | 'type': 'Opaque', 108 | 'data': { 109 | 'auth': b64encode(htpasswd.encode('utf-8')).decode('ascii'), 110 | }, 111 | }) 112 | 113 | # Add ACME TLS 114 | if args.acme: 115 | ingress['metadata']['annotations']['kubernetes.io/tls-acme'] = 'true' 116 | ingress['spec']['tls'] = [{ 117 | 'hosts': [ strip_hostname(hostname) ], 118 | 'secretName': strip_hostname(hostname) + '-tls', 119 | } for hostname in args.hostname] 120 | 121 | return (ingress, secrets) 122 | 123 | 124 | # make_pod: create a basic Pod for the given arguments 125 | def make_pod(args): 126 | pod = { 127 | 'metadata': { 128 | 'labels': { 129 | 'app': args.name, 130 | } 131 | }, 132 | 'spec': { 133 | 'containers': [], 134 | 'volumes': [], 135 | }, 136 | } 137 | 138 | return pod 139 | 140 | 141 | # make_deployment: convert the given Pod into a Deployment for the given args. 142 | def make_deployment(pod, args): 143 | deployment = { 144 | 'apiVersion': 'extensions/v1beta1', 145 | 'kind': 'Deployment', 146 | 'metadata': { 147 | 'name': args.name, 148 | 'namespace': args.namespace, 149 | 'annotations': {}, 150 | }, 151 | 'spec': { 152 | 'replicas': args.replicas, 153 | 'selector': { 154 | 'matchLabels': { 155 | 'app': args.name, 156 | }, 157 | }, 158 | 'template': pod, 159 | }, 160 | } 161 | 162 | if args.strategy == 'rollingupdate': 163 | deployment['spec']['strategy'] = { 164 | 'type': 'RollingUpdate', 165 | 'rollingUpdate': { 166 | 'maxSurge': 1, 167 | 'maxUnavailable': 0, 168 | }, 169 | } 170 | else: 171 | deployment['spec']['strategy'] = { 172 | 'type': 'Recreate', 173 | } 174 | 175 | return deployment 176 | 177 | 178 | # make_pvc: create a PVC from the given argument 179 | def make_pvc(arg, args): 180 | (volslug, path) = arg.split(':', 1) 181 | name = args.name + '-' + volslug 182 | 183 | pvc = { 184 | 'apiVersion': 'v1', 185 | 'kind': 'PersistentVolumeClaim', 186 | 'metadata': { 187 | 'namespace': args.namespace, 188 | 'name': name, 189 | }, 190 | 'spec': { 191 | 'accessModes': [ 'ReadWriteMany' ], 192 | 'resources': { 193 | 'requests': { 194 | 'storage': '1Gi', 195 | }, 196 | }, 197 | }, 198 | } 199 | 200 | pvcvolume = { 201 | 'name': volslug, 202 | 'persistentVolumeClaim': { 203 | 'claimName': name, 204 | } 205 | } 206 | 207 | pvcmount = { 208 | 'name': volslug, 209 | 'mountPath': path, 210 | } 211 | 212 | return (pvc, pvcvolume, pvcmount) 213 | 214 | 215 | # make_app_container: create the application container. 216 | def make_app_container(args): 217 | # We add some empty values here so it's easier to modify this template later 218 | app_container = { 219 | 'name': 'app', 220 | 'image': args.image, 221 | 'imagePullPolicy': args.image_pull_policy, 222 | 'resources': { 223 | 'limits': {}, 224 | 'requests': {}, 225 | }, 226 | 'volumeMounts': [], 227 | 'env': [], 228 | 'envFrom': [], 229 | } 230 | 231 | # Resource limits 232 | if args.cpu_limit: 233 | app_container['resources']['limits']['cpu'] = args.cpu_limit 234 | if args.cpu_request: 235 | app_container['resources']['requests']['cpu'] = args.cpu_request 236 | if args.memory_limit != 'none': 237 | app_container['resources']['limits']['memory'] = \ 238 | humanfriendly.parse_size(args.memory_limit, binary=True) 239 | if args.memory_request != 'none': 240 | app_container['resources']['requests']['memory'] = \ 241 | humanfriendly.parse_size(args.memory_request, binary=True) 242 | 243 | return app_container 244 | 245 | 246 | # make_redis_container: create a Redis container based on args. 247 | def make_redis_container(args): 248 | container = { 249 | 'name': 'redis', 250 | 'image': "redis:alpine", 251 | 'imagePullPolicy': 'Always', 252 | 'args': [ 253 | '--maxmemory', args.redis_cache, 254 | '--maxmemory-policy', 'allkeys-lru', 255 | ], 256 | } 257 | 258 | env = { 259 | 'name': 'CACHE_URL', 260 | 'value': 'redis://localhost:6379/0', 261 | } 262 | 263 | return (container, env) 264 | 265 | 266 | # make_postgres: create a Postgres container for the given args. 267 | def make_postgres(args): 268 | postgres = { 269 | 'name': 'postgres', 270 | 'image': "postgres:" + args.postgres + "-alpine", 271 | 'imagePullPolicy': 'Always', 272 | 'volumeMounts': [ 273 | { 274 | 'name': 'postgres', 275 | 'mountPath': '/var/lib/postgresql/data', 276 | }, 277 | ], 278 | } 279 | 280 | env = { 281 | 'name': 'DATABASE_URL', 282 | 'value': 'postgres://postgres:postgres@localhost/postgres', 283 | } 284 | 285 | pvc = { 286 | 'apiVersion': 'v1', 287 | 'kind': 'PersistentVolumeClaim', 288 | 'metadata': { 289 | 'namespace': args.namespace, 290 | 'name': args.name + '-postgres', 291 | }, 292 | 'spec': { 293 | 'accessModes': [ 'ReadWriteMany' ], 294 | 'resources': { 295 | 'requests': { 296 | 'storage': '1Gi', 297 | }, 298 | }, 299 | }, 300 | } 301 | 302 | volume = { 303 | 'name': 'postgres', 304 | 'persistentVolumeClaim': { 305 | 'claimName': args.name + '-postgres', 306 | } 307 | } 308 | 309 | return (postgres, env, volume, pvc) 310 | 311 | 312 | # make_secret: create a Secret object based on args. 313 | def make_secret(args): 314 | secret = { 315 | 'apiVersion': 'v1', 316 | 'kind': 'Secret', 317 | 'metadata': { 318 | 'name': args.name, 319 | 'namespace': args.namespace, 320 | }, 321 | 'type': 'Opaque', 322 | 'data': {} 323 | } 324 | 325 | for s in args.secret: 326 | (var, value) = s.split('=', 1) 327 | secret['data'][var] = b64encode(value.encode('utf-8')).decode('ascii') 328 | 329 | return secret 330 | 331 | 332 | # make_database: create a torchbox.com/v1.Database based on args. 333 | def make_database(args): 334 | # Due to Kubernetes bug #53379 (https://github.com/kubernetes/kubernetes/issues/53379) 335 | # we cannot unconditionally include the database in the manifest; it will 336 | # fail to apply correctly when the database provisioner is using CRD 337 | # instead of TPR. As a workaround, attempt to check whether the database 338 | # already exists. This is not a very good check because any failure of 339 | # kubectl will be treated as the database not existing, but it will do to 340 | # make deployments work until the Kubernetes bug is fixed. 341 | # 342 | # This should be removed once #53379 is fixed, and we will mark the 343 | # affected Kubernetes releases as unsupported for -D. 344 | provision_db = True 345 | items = [] 346 | 347 | if args.undeploy == False: 348 | stdout.write('checking if database already exists (bug #53379 workaround)...\n') 349 | kargs = kubectl.get_kubectl_args(args) 350 | kargs.extend([ 'get', 'database', args.name ]) 351 | kubectl_p = subprocess.Popen(kargs, 352 | stdin=subprocess.DEVNULL, 353 | stdout=subprocess.DEVNULL, 354 | stderr=subprocess.DEVNULL) 355 | kubectl_p.communicate() 356 | 357 | if kubectl_p.returncode == 0: 358 | stdout.write('database exists; will not replace\n') 359 | provision_db = False 360 | else: 361 | stdout.write('database does not exist; will create\n') 362 | 363 | if provision_db: 364 | items.append({ 365 | 'apiVersion': 'torchbox.com/v1', 366 | 'kind': 'Database', 367 | 'metadata': { 368 | 'namespace': args.namespace, 369 | 'name': args.name, 370 | }, 371 | 'spec': { 372 | 'class': 'default', 373 | 'secretName': args.name+'-database', 374 | 'type': args.database, 375 | }, 376 | }) 377 | 378 | env = { 379 | 'name': 'DATABASE_URL', 380 | 'valueFrom': { 381 | 'secretKeyRef': { 382 | 'name': args.name+'-database', 383 | 'key': 'database-url', 384 | }, 385 | }, 386 | } 387 | 388 | return (items, env) 389 | 390 | 391 | # make_manifest: create a manifest based on our arguments. 392 | def make_manifest(args): 393 | items = [] 394 | attached_resources = [] 395 | 396 | pod = make_pod(args) 397 | app = make_app_container(args) 398 | pod['spec']['containers'].append(app) 399 | 400 | # Configure any requested PVCs 401 | for vol in args.volume: 402 | (pvc, pvcvolume, pvcmount) = make_pvc(vol, args) 403 | items.append(pvc) 404 | 405 | app['volumeMounts'].append(pvcmount) 406 | pod['spec']['volumes'].append(pvcvolume) 407 | attached_resources.append({ 408 | 'kind': 'volume', 409 | 'name': pvc['metadata']['name'], 410 | }) 411 | 412 | # Add Secret environment variables 413 | if len(args.secret) > 0: 414 | secret = make_secret(args) 415 | items.append(secret) 416 | app['envFrom'].append({ 417 | 'secretRef': { 418 | 'name': args.name, 419 | } 420 | }) 421 | attached_resources.append({ 422 | 'kind': 'secret', 423 | 'name': secret['metadata']['name'], 424 | }) 425 | 426 | # Add (non-secret) environment variables 427 | for env in args.env: 428 | envbits = env.split('=', 1) 429 | if len(envbits) == 1: 430 | envbits.append(environ.get(envbits[0], '')) 431 | app['env'].append({ 432 | 'name': envbits[0], 433 | 'value': envbits[1] 434 | }) 435 | 436 | if args.database is not None: 437 | (db_items, db_env) = make_database(args) 438 | items.extend(db_items) 439 | app['env'].append(db_env) 440 | attached_resources.append({ 441 | 'kind': 'database', 442 | 'name': args.name, 443 | }) 444 | 445 | # Add Redis container 446 | if args.redis_cache is not None: 447 | (redis, redis_env) = make_redis_container(args) 448 | pod['spec']['containers'].append(redis) 449 | app['env'].append(redis_env) 450 | 451 | # Add Postgres container 452 | if args.postgres is not None: 453 | (postgres, pg_env, pg_volume, pg_pvc) = make_postgres(args) 454 | pod['spec']['containers'].append(postgres) 455 | pod['spec']['volumes'].append(pg_volume) 456 | app['env'].append(pg_env) 457 | items.append(pg_pvc) 458 | attached_resources.append({ 459 | 'kind': 'volume', 460 | 'name': pg_pvc['metadata']['name'], 461 | }) 462 | 463 | # If any hostnames are configured, create a Service and some Ingresses. 464 | if len(args.hostname): 465 | app['ports'] = [ 466 | { 467 | 'name': 'http', 468 | 'containerPort': args.port, 469 | 'protocol': 'TCP', 470 | } 471 | ] 472 | 473 | # Service 474 | service = make_service(args) 475 | items.append(service) 476 | attached_resources.append({ 477 | 'kind': 'service', 478 | 'name': service['metadata']['name'], 479 | }) 480 | 481 | # Ingress 482 | (ingress, secrets) = make_ingress(args) 483 | items.append(ingress) 484 | attached_resources.append({ 485 | 'kind': 'ingress', 486 | 'name': ingress['metadata']['name'], 487 | }) 488 | 489 | # Secrets (only present if using http authentication) 490 | items.extend(secrets) 491 | attached_resources.extend([{ 492 | 'kind': 'secret', 493 | 'name': secret['metadata']['name'] 494 | } for secret in secrets]) 495 | 496 | # Create our deployment last, so it can reference other resources. 497 | deployment = make_deployment(pod, args) 498 | deployment['metadata']['annotations']\ 499 | ['kdtool.torchbox.com/attached-resources'] = json.dumps(attached_resources) 500 | 501 | items.append(deployment) 502 | 503 | # Convert our items array into a List. 504 | spec = { 505 | 'apiVersion': 'v1', 506 | 'kind': 'List', 507 | 'items': items, 508 | } 509 | 510 | return spec 511 | 512 | 513 | # Deploy an application. 514 | def deploy(args): 515 | if args.manifest: 516 | spec = load_manifest(args, args.manifest) 517 | else: 518 | spec = make_manifest(args) 519 | 520 | if args.json: 521 | print(json.dumps(spec)) 522 | exit(0) 523 | else: 524 | exit(kubectl.apply_manifest(spec, args)) 525 | 526 | deploy.help = "deploy an application" 527 | deploy.arguments = ( 528 | ( ('-H', '--hostname'), { 529 | 'type': str, 530 | 'action': 'append', 531 | 'default': [], 532 | 'help': 'Hostname to expose the application on' 533 | }), 534 | ( ('-A', '--acme'), { 535 | 'action': 'store_true', 536 | 'help': 'Issue Let\'s Encrypt (ACME) TLS certificate', 537 | }), 538 | ( ('-M', '--manifest'), { 539 | 'type': str, 540 | 'metavar': 'FILE', 541 | 'help': 'Deploy from Kubernetes manifest with environment substitution', 542 | }), 543 | ( ('-r', '--replicas'), { 544 | 'type': int, 545 | 'default': 1, 546 | 'help': 'Number of replicas to create', 547 | }), 548 | ( ('-P', '--image-pull-policy'), { 549 | 'type': str, 550 | 'choices': ('IfNotPresent', 'Always'), 551 | 'default': 'IfNotPresent', 552 | 'help': 'Image pull policy', 553 | }), 554 | ( ('-e', '--env'), { 555 | 'type': str, 556 | 'action': 'append', 557 | 'default': [], 558 | 'metavar': 'VARNAME=VALUE', 559 | 'help': 'Set environment variable', 560 | }), 561 | ( ('-s', '--secret'), { 562 | 'type': str, 563 | 'action': 'append', 564 | 'default': [], 565 | 'metavar': 'VARNAME=VALUE', 566 | 'help': 'Set secret environment variable', 567 | }), 568 | ( ('-v', '--volume'), { 569 | 'type': str, 570 | 'action': 'append', 571 | 'default': [], 572 | 'metavar': 'PATH', 573 | 'help': 'Attach persistent filesystem storage at PATH', 574 | }), 575 | ( ('-p', '--port'), { 576 | 'type': int, 577 | 'default': 80, 578 | 'help': 'HTTP port the application listens on', 579 | }), 580 | ( ('-j', '--json'), { 581 | 'action': 'store_true', 582 | 'help': 'Print JSON instead of applying to cluster', 583 | }), 584 | ( ('-U', '--undeploy'), { 585 | 'action': 'store_true', 586 | 'help': 'Remove existing application', 587 | }), 588 | ( ('-n', '--dry-run'), { 589 | 'action': 'store_true', 590 | 'help': 'Pass --dry-run to kubectl', 591 | }), 592 | ( ('-D', '--database'), { 593 | 'type': str, 594 | 'choices': ('mysql', 'postgresql'), 595 | 'help': 'Provision database', 596 | }), 597 | ( ('--htauth-user',), { 598 | 'type': str, 599 | 'action': 'append', 600 | 'default': [], 601 | 'metavar': 'USERNAME:PASSWORD', 602 | 'help': 'Add HTTP authentication username/password', 603 | }), 604 | ( ('--htauth-address',), { 605 | 'type': str, 606 | 'action': 'append', 607 | 'default': [], 608 | 'metavar': 'ipaddress[/prefix]', 609 | 'help': 'Add HTTP authentication address', 610 | }), 611 | ( ('--htauth-satisfy',), { 612 | 'type': str, 613 | 'default': 'any', 614 | 'choices': ('any', 'all'), 615 | 'help': 'HTTP authentication satisfy policy', 616 | }), 617 | ( ('--htauth-realm',), { 618 | 'type': str, 619 | 'default': 'Authentication required', 620 | 'help': 'HTTP authentication realm', 621 | }), 622 | ( ('--postgres',), { 623 | 'type': str, 624 | 'metavar': '9.6', 625 | 'help': 'Attach PostgreSQL database at $DATABASE_URL', 626 | }), 627 | ( ('--redis-cache',), { 628 | 'type': str, 629 | 'metavar': '64m', 630 | 'help': 'Attach Redis database at $CACHE_URL', 631 | }), 632 | ( ('--memory-request',), { 633 | 'type': str, 634 | 'default': 'none', 635 | 'help': 'Required memory allocation', 636 | }), 637 | ( ('--memory-limit',), { 638 | 'type': str, 639 | 'default': 'none', 640 | 'help': 'Memory limit', 641 | }), 642 | ( ('--cpu-request',), { 643 | 'type': float, 644 | 'default': 0, 645 | 'help': 'Number of dedicated CPU cores', 646 | }), 647 | ( ('--cpu-limit',), { 648 | 'type': float, 649 | 'default': 0, 650 | 'help': 'CPU core use limit', 651 | }), 652 | ( ('--strategy',), { 653 | 'type': str, 654 | 'choices': ('rollingupdate', 'recreate'), 655 | 'default': 'rollingupdate', 656 | 'help': 'Deployment update strategy', 657 | }), 658 | ( ('image',), { 659 | 'type': str, 660 | 'help': 'Docker image to deploy', 661 | }), 662 | ( ('name',), { 663 | 'type': str, 664 | 'help': 'Application name', 665 | }) 666 | ) 667 | 668 | commands = { 669 | 'deploy': deploy, 670 | } 671 | -------------------------------------------------------------------------------- /deployment.py: -------------------------------------------------------------------------------- 1 | # vim:set sw=4 ts=4 et: 2 | # 3 | # Copyright (c) 2016-2017 Torchbox Ltd. 4 | # 5 | # Permission is granted to anyone to use this software for any purpose, 6 | # including commercial applications, and to alter it and redistribute it 7 | # freely. This software is provided 'as-is', without any express or implied 8 | # warranty. 9 | 10 | 11 | import json, kubernetes 12 | 13 | import kubeutil 14 | 15 | # get_deployment: return the named deployment. 16 | def get_deployment(namespace, name): 17 | api_client = kubeutil.get_client() 18 | 19 | # We can't use the normal client API here because it returns Python objects 20 | # that can't be converted back into JSON. Instead, fetch the JSON by hand. 21 | resource_path = ('/apis/extensions/v1beta1/namespaces/' 22 | + namespace 23 | + '/deployments/' 24 | + name) 25 | 26 | header_params = {} 27 | header_params['Accept'] = api_client.select_header_accept(['application/json']) 28 | header_params['Content-Type'] = api_client.select_header_content_type(['*/*']) 29 | header_params.update(kubeutil.config.api_key) 30 | 31 | (resp, code, header) = api_client.call_api( 32 | resource_path, 'GET', {}, {}, header_params, None, [], _preload_content=False) 33 | dp = json.loads(resp.data.decode('utf-8')) 34 | 35 | return dp 36 | 37 | # get_replicasets: return all the active replicasets for a deployment. 38 | # old replicasets (with zero replicas) are not included. 39 | def get_replicasets(dp): 40 | ret = [] 41 | 42 | api_client = kubeutil.get_client() 43 | 44 | # We can't use the normal client API here because it returns Python objects 45 | # that can't be converted back into JSON. Instead, fetch the JSON by hand. 46 | resource_path = ('/apis/extensions/v1beta1/namespaces/' 47 | + dp['metadata']['namespace'] 48 | + '/replicasets') 49 | 50 | header_params = {} 51 | header_params['Accept'] = api_client.select_header_accept(['application/json']) 52 | header_params['Content-Type'] = api_client.select_header_content_type(['*/*']) 53 | header_params.update(kubeutil.config.api_key) 54 | 55 | (resp, code, header) = api_client.call_api( 56 | resource_path, 'GET', {}, {}, header_params, None, [], _preload_content=False) 57 | 58 | rslist = json.loads(resp.data.decode('utf-8')) 59 | 60 | for rs in rslist['items']: 61 | md = rs['metadata'] 62 | 63 | # Check if this RS is owned by the correct deployment. 64 | if 'ownerReferences' not in md: 65 | continue 66 | has_owner = False 67 | for owner in md['ownerReferences']: 68 | if owner['kind'] == 'Deployment' and owner['name'] == dp['metadata']['name']: 69 | has_owner = True 70 | break 71 | if not has_owner: 72 | continue 73 | 74 | if rs['spec']['replicas'] == 0: 75 | continue 76 | 77 | ret.append(rs) 78 | 79 | return ret 80 | 81 | 82 | # get_rs_pods: get all the pods for a replicaset. 83 | def get_rs_pods(rs): 84 | ret = [] 85 | 86 | api_client = kubeutil.get_client() 87 | 88 | # We can't use the normal client API here because it returns Python objects 89 | # that can't be converted back into JSON. Instead, fetch the JSON by hand. 90 | resource_path = ('/api/v1/namespaces/' 91 | + rs['metadata']['namespace'] 92 | + '/pods') 93 | 94 | header_params = {} 95 | header_params['Accept'] = api_client.select_header_accept(['application/json']) 96 | header_params['Content-Type'] = api_client.select_header_content_type(['*/*']) 97 | header_params.update(kubeutil.config.api_key) 98 | 99 | (resp, code, header) = api_client.call_api( 100 | resource_path, 'GET', {}, {}, header_params, None, [], _preload_content=False) 101 | 102 | podlist = json.loads(resp.data.decode('utf-8')) 103 | for pod in podlist['items']: 104 | md = pod['metadata'] 105 | if 'ownerReferences' not in md: 106 | continue 107 | 108 | has_owner = False 109 | 110 | for owner in md['ownerReferences']: 111 | if owner['kind'] != 'ReplicaSet': 112 | continue 113 | if owner['name'] != rs['metadata']['name']: 114 | continue 115 | has_owner = True 116 | break 117 | 118 | if not has_owner: 119 | continue 120 | 121 | ret.append(pod) 122 | 123 | return ret 124 | -------------------------------------------------------------------------------- /kubectl.py: -------------------------------------------------------------------------------- 1 | # vim:set sw=4 ts=4 et: 2 | # 3 | # Copyright (c) 2016-2017 Torchbox Ltd. 4 | # 5 | # Permission is granted to anyone to use this software for any purpose, 6 | # including commercial applications, and to alter it and redistribute it 7 | # freely. This software is provided 'as-is', without any express or implied 8 | # warranty. 9 | 10 | 11 | import os, json, subprocess 12 | from sys import stdout, stderr, exit 13 | 14 | 15 | # get_kubectl_args: return a kubectl command line to connect to the cluster 16 | # based on our arguments. 17 | def get_kubectl_args(args): 18 | kargs = [ args.kubectl ] 19 | 20 | if args.server: 21 | kargs.append('--server='+args.server) 22 | if args.token: 23 | kargs.append('--token='+args.token) 24 | if args.ca_certificate: 25 | kargs.append('--certificate-authority='+args.ca_certificate) 26 | if args.namespace: 27 | kargs.append('--namespace='+args.namespace) 28 | if args.context: 29 | kargs.append('--context='+args.context) 30 | 31 | return kargs 32 | 33 | # find_kubectl: try to locate kubectl. searches $PATH, then tries some likely 34 | # locations. 35 | def find_kubectl(): 36 | try_ = os.environ.get('PATH', '').split(os.pathsep) 37 | try_.extend([ 38 | # Often installed here 39 | '/usr/local/bin', 40 | # The Torchbox package puts it here 41 | '/opt/tbx/bin', 42 | ]) 43 | 44 | for path in try_: 45 | filename = path + "/kubectl" 46 | if os.path.isfile(filename): 47 | return filename 48 | if os.path.isfile(filename + ".exe"): 49 | return filename + ".exe" 50 | 51 | return None 52 | 53 | 54 | # apply_manifest: feed a manifest to kubectl. the input should be an API object 55 | # which will be converted to JSON. to apply multiple objects, use a v1.List 56 | # object. 57 | def apply_manifest(manifest, args): 58 | kargs = get_kubectl_args(args) 59 | 60 | if args.undeploy: 61 | kargs.append('delete') 62 | else: 63 | kargs.append('apply') 64 | 65 | if args.dry_run: 66 | kargs.append('--dry-run') 67 | 68 | kargs.extend(['-f', '-']) 69 | 70 | spec = json.dumps(manifest) 71 | 72 | kubectl = subprocess.Popen(kargs, stdin=subprocess.PIPE) 73 | kubectl.communicate(spec.encode('utf-8')) 74 | return kubectl.returncode 75 | -------------------------------------------------------------------------------- /kubeutil.py: -------------------------------------------------------------------------------- 1 | # vim:set sw=4 ts=4 et: 2 | # 3 | # Copyright (c) 2016-2017 Torchbox Ltd. 4 | # 5 | # Permission is granted to anyone to use this software for any purpose, 6 | # including commercial applications, and to alter it and redistribute it 7 | # freely. This software is provided 'as-is', without any express or implied 8 | # warranty. 9 | 10 | 11 | from sys import stdout, stderr, exit 12 | import kubernetes, json, urllib3 13 | 14 | config = kubernetes.client.Configuration() 15 | 16 | # configure: set configuration based on args. 17 | def configure(args): 18 | try: 19 | kubernetes.config.kube_config.load_kube_config( 20 | client_configuration=config, 21 | context=args.context) 22 | except: 23 | stderr.write("warning: could not load kubeconfig\n") 24 | args.server = 'http://localhost:8080' 25 | 26 | if args.server: 27 | config.host = args.server 28 | if args.token: 29 | config.api_key['authorization'] = "bearer " + args.token 30 | if args.ca_certificate: 31 | config.ssl_ca_cert = args.ca_certificate 32 | 33 | # get_client: return a Kubernetes API client. 34 | def get_client(): 35 | client = kubernetes.client.ApiClient(config=config) 36 | return client 37 | 38 | # get_error: try to extract a printable error message from an exception. 39 | def get_error(exc): 40 | if isinstance(exc, kubernetes.client.rest.ApiException): 41 | try: 42 | body = exc.body.decode('utf-8') 43 | d = json.loads(body) 44 | return d['message'] 45 | except: 46 | return exc.reason 47 | 48 | if isinstance(exc, urllib3.exceptions.HTTPError): 49 | return exc.args[0] 50 | 51 | return str(exc) 52 | -------------------------------------------------------------------------------- /make_version.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env python3 2 | 3 | import sys, getpass, platform, socket, time 4 | 5 | if platform.system() == 'Windows': 6 | userhost='{}\\{}'.format(socket.gethostname(), getpass.getuser()) 7 | else: 8 | userhost='{}@{}'.format(getpass.getuser(), socket.gethostname()) 9 | 10 | with open('version.py', 'w') as f: 11 | f.write(""" 12 | __version__ = "{}" 13 | __info__ = "built by {} on {}" 14 | """.format(sys.argv[1], userhost, time.strftime('%Y-%m-%d %H:%M:%S %Z'))) 15 | -------------------------------------------------------------------------------- /manifest.py: -------------------------------------------------------------------------------- 1 | # vim:set sw=2 ts=2 et: 2 | # 3 | # Copyright (c) 2016-2017 Torchbox Ltd. 4 | # 5 | # Permission is granted to anyone to use this software for any purpose, 6 | # including commercial applications, and to alter it and redistribute it 7 | # freely. This software is provided 'as-is', without any express or implied 8 | # warranty. 9 | 10 | 11 | from os import environ 12 | from base64 import b64encode 13 | from sys import stdout, stderr, exit 14 | import re, yaml 15 | 16 | 17 | # Load a YAML manifest from disk and perform environment substitution on it, 18 | # based on the environment and our arguments. Returns an array of items loaded 19 | # from the YAML; this needs to be converted to a List before sending it to 20 | # kubectl. 21 | def load_manifest(args, filename): 22 | # Avoid modifying the system environment. 23 | menv = environ.copy() 24 | 25 | with open(filename, 'r') as f: 26 | spec = f.read() 27 | 28 | menv['IMAGE'] = args.image 29 | menv['NAME'] = args.name 30 | menv['NAMESPACE'] = args.namespace 31 | 32 | for env in args.env: 33 | (var, value) = env.split('=', 1) 34 | menv[var] = value 35 | 36 | def envrep(m): 37 | funcs = { 38 | 'b64encode': lambda v: b64encode(v.encode('utf-8')).decode('utf-8'), 39 | } 40 | 41 | bits = m.group(2).split(':') 42 | 43 | try: 44 | var = menv[bits[0]] 45 | except KeyError: 46 | stderr.write(args.manifest+ ": $" + bits[0] + " not in environment.\n") 47 | exit(1) 48 | 49 | if len(bits) > 1: 50 | if bits[1] not in funcs: 51 | stderr.write(args.manifest + ": function " + bits[1] + " unknown.\n") 52 | return funcs[bits[1]](var, *bits[2:]) 53 | else: 54 | return var 55 | 56 | spec = re.sub(r"\$({)?([A-Za-z_][A-Za-z0-9_:]+)(?(1)})", envrep, spec) 57 | 58 | items = [] 59 | for item in yaml.load_all(spec): 60 | items.append(item) 61 | return items 62 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | humanfriendly 2 | passlib 3 | PyYAML 4 | kubernetes==3.0.0 5 | -------------------------------------------------------------------------------- /shell.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env python3 2 | # vim:set sw=4 ts=4 et: 3 | # 4 | # Copyright (c) 2016-2017 Torchbox Ltd. 5 | # 6 | # Permission is granted to anyone to use this software for any purpose, 7 | # including commercial applications, and to alter it and redistribute it 8 | # freely. This software is provided 'as-is', without any express or implied 9 | # warranty. 10 | 11 | 12 | from sys import stdout, stderr 13 | import tempfile, argparse, subprocess, random, string, os, json, kubernetes 14 | 15 | from kubectl import find_kubectl, get_kubectl_args 16 | 17 | 18 | # find the application container for a given deployment. if there is only one 19 | # container, then return that one; otherwise return the container called "app". 20 | # if no "app" container exists, return None. 21 | def find_app_container(dp): 22 | if len(dp['spec']['template']['spec']['containers']) == 1: 23 | return dp['spec']['template']['spec']['containers'][0] 24 | 25 | for container in dp['spec']['template']['spec']['containers']: 26 | if container.name == "app": 27 | return container 28 | 29 | return None 30 | 31 | 32 | # make_env: convert an env to a data structure we can pass as a JSON patch. 33 | def make_env(env): 34 | ret = { 35 | 'name': env.name, 36 | } 37 | 38 | if env.value: 39 | ret['value'] = env.value 40 | elif env.value_from: 41 | vf = env.value_from 42 | if vf.secret_key_ref: 43 | ret['valueFrom'] = { 44 | 'secretKeyRef': { 45 | 'key': vf.secret_key_ref.key, 46 | 'name': vf.secret_key_ref.name, 47 | }, 48 | } 49 | else: 50 | return None 51 | 52 | return ret 53 | 54 | 55 | # make_envfrom: convert an env_from to a data structure we can pass as a JSON 56 | # patch. 57 | def make_envfrom(envfrom): 58 | if envfrom.secret_ref: 59 | return { 60 | 'secretRef': { 61 | 'name': envfrom.secret_ref.name, 62 | }, 63 | } 64 | 65 | if envfrom.config_map_ref: 66 | return { 67 | 'configMapRef': { 68 | 'name': envfrom.config_map_ref.name, 69 | }, 70 | } 71 | 72 | return None 73 | 74 | 75 | # start a shell for the given deployment. 76 | def shell(args, tty=True, command=None): 77 | config = kubernetes.client.Configuration() 78 | kubernetes.config.kube_config.load_kube_config(client_configuration=config) 79 | api_client = kubernetes.client.ApiClient(config=config) 80 | 81 | # We can't use the normal client API here because it returns Python objects 82 | # that can't be converted back into JSON. Instead, fetch the JSON by hand. 83 | resource_path = ('/apis/extensions/v1beta1/namespaces/' 84 | + args.namespace 85 | + '/deployments/' 86 | + args.name) 87 | 88 | header_params = {} 89 | header_params['Accept'] = api_client.select_header_accept(['application/json']) 90 | header_params['Content-Type'] = api_client.select_header_content_type(['*/*']) 91 | 92 | (resp, code, header) = api_client.call_api( 93 | resource_path, 'GET', {}, {}, header_params, None, [], _preload_content=False) 94 | dp = json.loads(resp.data.decode('utf-8')) 95 | 96 | app = find_app_container(dp) 97 | if app is None: 98 | stderr.write('could not find application container.\n') 99 | exit(1) 100 | 101 | # Create a complete Pod spec that we will pass to kubectl exec as an 102 | # override. Metadata is not required, only spec. 103 | rng = random.SystemRandom() 104 | chars = string.ascii_lowercase + string.digits 105 | suffix = str().join(rng.choice(chars) for _ in range(4)) 106 | pod_name = 'kdtool-' + dp['metadata']['name'] + '-' + suffix 107 | 108 | if command is None: 109 | if args.command is None: 110 | command = [ '/bin/sh', '-c', 'exec /bin/bash || exec /bin/sh' ] 111 | else: 112 | command = args.command.split(" ") 113 | 114 | if args.image: 115 | pod_image = args.image 116 | else: 117 | pod_image = app['image'] 118 | 119 | pod = { 120 | 'spec': { 121 | 'containers': [{ 122 | 'name': pod_name, 123 | 'image': pod_image, 124 | 'command': command, 125 | 'stdin': True, 126 | 'stdinOnce': True, 127 | 'tty': tty, 128 | }], 129 | }, 130 | } 131 | 132 | if 'env' in app: 133 | pod['spec']['containers'][0]['env'] = app['env'] 134 | if 'envFrom' in app: 135 | pod['spec']['containers'][0]['envFrom'] = app['envFrom'] 136 | if 'volumeMounts' in app: 137 | pod['spec']['containers'][0]['volumeMounts'] = app['volumeMounts'] 138 | if 'volumes' in dp['spec']['template']['spec']: 139 | pod['spec']['volumes'] = dp['spec']['template']['spec']['volumes'] 140 | 141 | patch = json.dumps(pod) 142 | 143 | kargs = get_kubectl_args(args) 144 | kargs.extend([ 145 | 'run', 146 | '--restart=Never', 147 | '--rm', 148 | '-ti' if tty else '-i', 149 | '--image=' + pod_image, 150 | '--overrides='+patch, 151 | pod_name, 152 | '--', 153 | '/bin/false', # not used 154 | ]) 155 | 156 | ret = subprocess.call(kargs, start_new_session=True) 157 | exit(ret) 158 | 159 | shell.help = "start an interactive shell for a deployment" 160 | shell.arguments = ( 161 | ( ('-c', '--command'), { 162 | 'type': str, 163 | 'help': 'command to run', 164 | }), 165 | ( ('-i', '--image'), { 166 | 'type': str, 167 | 'help': 'image to start', 168 | }), 169 | ( ('name',), { 170 | 'type': str, 171 | 'help': 'deployment name', 172 | }), 173 | ) 174 | 175 | 176 | # exec: like shell, but no terminal. 177 | def execcmd(args): 178 | return shell(args, tty=False, command=args.command) 179 | execcmd.help = "run a non-interactive command for a deployment" 180 | execcmd.arguments = ( 181 | ( ('-i', '--image'), { 182 | 'type': str, 183 | 'help': 'image to start', 184 | }), 185 | ( ('name',), { 186 | 'type': str, 187 | 'help': 'deployment name', 188 | }), 189 | ( ('command',), { 190 | 'type': str, 191 | 'help': 'command to run', 192 | 'nargs': argparse.REMAINDER, 193 | }), 194 | ) 195 | 196 | commands = { 197 | 'shell': shell, 198 | 'exec': execcmd, 199 | } 200 | -------------------------------------------------------------------------------- /status.py: -------------------------------------------------------------------------------- 1 | # vim:set sw=4 ts=4 et: 2 | # 3 | # Copyright (c) 2016-2017 Torchbox Ltd. 4 | # 5 | # Permission is granted to anyone to use this software for any purpose, 6 | # including commercial applications, and to alter it and redistribute it 7 | # freely. This software is provided 'as-is', without any express or implied 8 | # warranty. 9 | 10 | 11 | import json, kubernetes 12 | from kubernetes.client.apis import core_v1_api, extensions_v1beta1_api 13 | from sys import stdout, stderr 14 | 15 | import deployment, kubeutil 16 | 17 | # status: print the overall status of a deployment and any errors. 18 | def status(args): 19 | try: 20 | dp = deployment.get_deployment(args.namespace, args.name) 21 | replicasets = deployment.get_replicasets(dp) 22 | except Exception as e: 23 | stderr.write('cannot load deployment {0}: {1}\n'.format( 24 | args.name, kubeutil.get_error(e))) 25 | exit(1) 26 | 27 | try: 28 | generation = dp['metadata']['annotations']['deployment.kubernetes.io/revision'] 29 | except KeyError: 30 | generation = '?' 31 | 32 | stdout.write("deployment {0}/{1}:\n".format( 33 | dp['metadata']['namespace'], 34 | dp['metadata']['name'], 35 | )) 36 | stdout.write(" current generation is {0}, {2} replicas configured, {1} active replica sets\n".format( 37 | generation, 38 | len(replicasets), 39 | dp['spec']['replicas'], 40 | )) 41 | stdout.write("\n active replicasets (status codes: * current, ! error):\n") 42 | 43 | for rs in replicasets: 44 | pods = deployment.get_rs_pods(rs) 45 | error = ' ' 46 | 47 | try: 48 | revision = rs['metadata']['annotations']['deployment.kubernetes.io/revision'] 49 | except KeyError: 50 | revision = '?' 51 | 52 | if str(revision) == str(generation): 53 | active = '*' 54 | else: 55 | active = ' ' 56 | 57 | try: 58 | nready = rs['status']['readyReplicas'] 59 | except KeyError: 60 | error = '!' 61 | nready = 0 62 | 63 | errors = [] 64 | try: 65 | for condition in rs['status']['conditions']: 66 | if condition['type'] == 'ReplicaFailure' and condition['status'] == 'True': 67 | errors.append(condition['message']) 68 | error = '!' 69 | except KeyError: 70 | pass 71 | 72 | stdout.write(" {4}{5}generation {1} is replicaset {0}, {2} replicas configured, {3} ready\n".format( 73 | rs['metadata']['name'], 74 | revision, 75 | rs['spec']['replicas'], 76 | nready, 77 | active, 78 | error 79 | )) 80 | 81 | for container in rs['spec']['template']['spec']['containers']: 82 | stdout.write(" container {0}: image {1}\n".format( 83 | container['name'], 84 | container['image'], 85 | )) 86 | 87 | for error in errors: 88 | stdout.write(" {0}\n".format(error)) 89 | 90 | for pod in pods: 91 | try: 92 | phase = pod['status']['phase'] 93 | except KeyError: 94 | phase = '?' 95 | 96 | stdout.write(" pod {0}: {1}\n".format( 97 | pod['metadata']['name'], 98 | phase, 99 | )) 100 | 101 | if 'status' in pod and 'containerStatuses' in pod['status']: 102 | for cs in pod['status']['containerStatuses']: 103 | if 'waiting' in cs['state']: 104 | try: 105 | message = cs['state']['waiting']['message'] 106 | except KeyError: 107 | message = '(no reason)' 108 | 109 | stdout.write(" {0}: {1}\n".format( 110 | cs['state']['waiting']['reason'], 111 | message, 112 | )) 113 | 114 | resources = None 115 | try: 116 | resources = json.loads(dp['metadata']['annotations']['kdtool.torchbox.com/attached-resources']) 117 | except KeyError: 118 | exit(0) 119 | except ValueError as e: 120 | stderr.write("warning: could not decode kdtool.torchbox.com/attached-resources annotation: {0}\n".format(str(e))) 121 | exit(0) 122 | 123 | if len(resources) == 0: 124 | exit(0) 125 | 126 | stdout.write("\nattached resources:\n") 127 | 128 | client = kubeutil.get_client() 129 | v1 = core_v1_api.CoreV1Api(client) 130 | extv1beta1 = extensions_v1beta1_api.ExtensionsV1beta1Api(client) 131 | 132 | services = [resource['name'] for resource in resources if resource['kind'] == 'service'] 133 | for svc_name in services: 134 | service = v1.read_namespaced_service(svc_name, args.namespace) 135 | stdout.write(" service {0}: selector is ({1})\n".format( 136 | service.metadata.name, 137 | ", ".join([ k+"="+v for k,v in service.spec.selector.items() ]), 138 | )) 139 | for port in service.spec.ports: 140 | stdout.write(" port {0}: {1}/{2} -> {3}\n".format( 141 | port.name, 142 | port.port, 143 | port.protocol, 144 | port.target_port)) 145 | 146 | ingresses = [resource['name'] for resource in resources if resource['kind'] == 'ingress'] 147 | 148 | for ing_name in ingresses: 149 | ingress = extv1beta1.read_namespaced_ingress(ing_name, args.namespace) 150 | stdout.write(" ingress {0}:\n".format(ingress.metadata.name)) 151 | for rule in ingress.spec.rules: 152 | stdout.write(" http[s]://{0} -> {1}/{2}:{3}\n".format( 153 | rule.host, 154 | ingress.metadata.namespace, 155 | rule.http.paths[0].backend.service_name, 156 | rule.http.paths[0].backend.service_port, 157 | )) 158 | 159 | volumes = [resource['name'] for resource in resources if resource['kind'] == 'volume'] 160 | 161 | for vol_name in volumes: 162 | volume = v1.read_namespaced_persistent_volume_claim(vol_name, args.namespace) 163 | if volume.status: 164 | stdout.write(" volume {0}: mode is {1}, size {2}, phase {3}\n".format( 165 | volume.metadata.name, 166 | ",".join(volume.status.access_modes), 167 | volume.status.capacity['storage'], 168 | volume.status.phase, 169 | )) 170 | else: 171 | stdout.write(" volume {0} is unknown (not provisioned)\n".format( 172 | volume.metadata.name, 173 | )) 174 | 175 | databases = [resource['name'] for resource in resources if resource['kind'] == 'database'] 176 | 177 | for db_name in databases: 178 | resource_path = ('/apis/torchbox.com/v1/namespaces/' 179 | + dp['metadata']['namespace'] 180 | + '/databases/' 181 | + db_name) 182 | 183 | header_params = {} 184 | header_params['Accept'] = client.select_header_accept(['application/json']) 185 | header_params['Content-Type'] = client.select_header_content_type(['*/*']) 186 | header_params.update(kubeutil.config.api_key) 187 | 188 | (resp, code, header) = client.call_api( 189 | resource_path, 'GET', {}, {}, header_params, None, [], _preload_content=False) 190 | 191 | database = json.loads(resp.data.decode('utf-8')) 192 | if 'status' in database: 193 | stdout.write(" database {0}: type {1}, phase {2} (on server {3})\n".format( 194 | database['metadata']['name'], 195 | database['spec']['type'], 196 | database['status']['phase'], 197 | database['status']['server'], 198 | )) 199 | else: 200 | stdout.write(" database {0}: type {1}, unknown (not provisioned)\n".format( 201 | database['metadata']['name'], 202 | database['spec']['type'], 203 | )) 204 | 205 | status.help = "show deployment status" 206 | status.arguments = ( 207 | ( ('name',), { 208 | 'type': str, 209 | 'help': 'deployment name', 210 | }), 211 | ) 212 | 213 | commands = { 214 | 'status': status 215 | } 216 | -------------------------------------------------------------------------------- /travis-build.sh: -------------------------------------------------------------------------------- 1 | #! /bin/sh 2 | # vim:set sw=8 ts=8 noet: 3 | # 4 | # Copyright (c) 2016-2017 Torchbox Ltd. 5 | # 6 | # Permission is granted to anyone to use this software for any purpose, 7 | # including commercial applications, and to alter it and redistribute it 8 | # freely. This software is provided 'as-is', without any express or implied 9 | # warranty. 10 | 11 | set -e 12 | 13 | printf '####################################################################\n' 14 | printf '>>> Building zipapp.\n\n' 15 | 16 | if [ -n "$TRAVIS_TAG" ]; then 17 | VERSION="$(echo "${TRAVIS_TAG}" | sed -e 's/^v//')" 18 | else 19 | VERSION="git${TRAVIS_COMIT}" 20 | fi 21 | 22 | make dist VERSION="${VERSION}" 23 | 24 | printf '####################################################################\n' 25 | printf '>>> Building Docker image.\n\n' 26 | 27 | docker build --pull -t torchbox/kdtool:$COMMIT . 28 | 29 | if [ "$TRAVIS_PULL_REQUEST" = "false" ]; then 30 | printf '####################################################################\n' 31 | printf '>>> Pushing test release.\n\n' 32 | 33 | # Push the latest build to the 'testing' tag. 34 | docker login -u $DOCKER_USER -p $DOCKER_PASSWORD 35 | docker tag torchbox/kdtool:$COMMIT torchbox/kdtool:testing 36 | docker push torchbox/kdtool:testing 37 | 38 | # If this is a release, push the Docker image to Docker Hub. 39 | if [ -n "$TRAVIS_TAG" ]; then 40 | cp kdtool.pyz kdtool-${TRAVIS_TAG}.pyz 41 | 42 | printf '####################################################################\n' 43 | printf '>>> Creating release.\n\n' 44 | 45 | docker tag torchbox/kdtool:$COMMIT torchbox/kdtool:$TRAVIS_TAG 46 | docker push torchbox/kdtool:$TRAVIS_TAG 47 | docker tag torchbox/kdtool:$COMMIT torchbox/kdtool:latest 48 | docker push torchbox/kdtool:latest 49 | fi 50 | fi 51 | -------------------------------------------------------------------------------- /undeploy.py: -------------------------------------------------------------------------------- 1 | # vim:set sw=4 ts=4 et: 2 | # 3 | # Copyright (c) 2016-2017 Torchbox Ltd. 4 | # 5 | # Permission is granted to anyone to use this software for any purpose, 6 | # including commercial applications, and to alter it and redistribute it 7 | # freely. This software is provided 'as-is', without any express or implied 8 | # warranty. 9 | 10 | import json 11 | from sys import stdout, stderr, exit 12 | from kubernetes.client.apis import core_v1_api, extensions_v1beta1_api 13 | 14 | import deployment, kubectl, kubeutil 15 | 16 | def undeploy(args): 17 | try: 18 | dp = deployment.get_deployment(args.namespace, args.name) 19 | except Exception as e: 20 | stderr.write('cannot load deployment {0}: {1}\n'.format( 21 | args.name, kubeutil.get_error(e))) 22 | exit(1) 23 | 24 | resources = None 25 | try: 26 | resources = json.loads(dp['metadata']['annotations']['kdtool.torchbox.com/attached-resources']) 27 | except KeyError: 28 | pass 29 | except ValueError as e: 30 | stderr.write("error: could not decode kdtool.torchbox.com/attached-resources annotation: {0}\n".format(str(e))) 31 | exit(1) 32 | 33 | stdout.write("\nthis deployment will be removed:\n") 34 | stdout.write("- {0}/{1}\n".format( 35 | dp['metadata']['namespace'], 36 | dp['metadata']['name'], 37 | )) 38 | 39 | if len(resources): 40 | if args.all: 41 | stdout.write("\nthe following attached resources will also be deleted:\n") 42 | for res in resources: 43 | extra = '' 44 | if res['kind'] == 'database': 45 | extra = ' (database will be dropped)' 46 | elif res['kind'] == 'volume': 47 | extra = ' (contents will be deleted)' 48 | 49 | stdout.write("- {0}: {1}{2}\n".format( 50 | res['kind'], 51 | res['name'], 52 | extra 53 | )) 54 | else: 55 | stdout.write("\nthe following attached resources will NOT be deleted (use --all):\n") 56 | for res in resources: 57 | stdout.write("- {0}: {1}\n".format( 58 | res['kind'], 59 | res['name'], 60 | )) 61 | 62 | stdout.write('\n') 63 | 64 | if not args.force: 65 | pr = input('continue [y/N]? ') 66 | if pr.lower() not in ['yes', 'y']: 67 | stdout.write("okay, aborting\n") 68 | exit(0) 69 | 70 | client = kubeutil.get_client() 71 | extv1beta1 = extensions_v1beta1_api.ExtensionsV1beta1Api(client) 72 | v1 = core_v1_api.CoreV1Api(client) 73 | 74 | stdout.write('deleting deployment <{}/{}>: '.format(args.namespace, args.name)) 75 | extv1beta1.delete_namespaced_deployment(args.name, args.namespace, body={}) 76 | stdout.write('ok\n') 77 | 78 | if not args.all: 79 | exit(0) 80 | 81 | for res in resources: 82 | stdout.write('deleting {} <{}>: '.format(res['kind'], res['name'])) 83 | if res['kind'] == 'volume': 84 | v1.delete_namespaced_persistent_volume_claim( 85 | res['name'], args.namespace, body={}) 86 | elif res['kind'] == 'secret': 87 | v1.delete_namespaced_secret(res['name'], args.namespace, body={}) 88 | elif res['kind'] == 'database': 89 | resource_path = ('/apis/torchbox.com/v1/namespaces/' 90 | + dp['metadata']['namespace'] 91 | + '/databases/' 92 | + res['name']) 93 | 94 | header_params = {} 95 | header_params['Accept'] = client.select_header_accept(['application/json']) 96 | header_params['Content-Type'] = client.select_header_content_type(['*/*']) 97 | header_params.update(kubeutil.config.api_key) 98 | 99 | (resp, code, header) = client.call_api( 100 | resource_path, 'DELETE', {}, {}, header_params, None, [], _preload_content=False) 101 | elif res['kind'] == 'service': 102 | v1.delete_namespaced_service(res['name'], args.namespace) 103 | elif res['kind'] == 'ingress': 104 | extv1beta1.delete_namespaced_ingress(res['name'], args.namespace, body={}) 105 | 106 | stdout.write('ok\n') 107 | 108 | undeploy.help = "undeploy an application" 109 | undeploy.arguments = ( 110 | ( ('-M', '--manifest'), { 111 | 'type': str, 112 | 'metavar': 'FILE', 113 | 'help': 'deploy from Kubernetes manifest with environment substitution', 114 | }), 115 | ( ('-f', '--force'), { 116 | 'action': 'store_true', 117 | 'help': 'do not prompt for confirmation', 118 | }), 119 | ( ('-A', '--all'), { 120 | 'action': 'store_true', 121 | 'help': 'undeploy attached resources', 122 | }), 123 | ( ('name',), { 124 | 'type': str, 125 | 'help': 'application name', 126 | }) 127 | ) 128 | 129 | commands = { 130 | 'undeploy': undeploy, 131 | } 132 | 133 | -------------------------------------------------------------------------------- /util.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env python3 2 | # vim:set sw=2 ts=2 et: 3 | # 4 | # Copyright (c) 2016-2017 Torchbox Ltd. 5 | # 6 | # Permission is granted to anyone to use this software for any purpose, 7 | # including commercial applications, and to alter it and redistribute it 8 | # freely. This software is provided 'as-is', without any express or implied 9 | # warranty. 10 | 11 | import re 12 | 13 | def strip_hostname(hostname): 14 | # Strip https?:// from a hostname so --hostname= works. 15 | return re.sub(r"^https?://([^/]*)(/.*)?$", r'\1', hostname) 16 | --------------------------------------------------------------------------------