Switch to Poetry and Black (#254)

* Pipenv -> Poetry

* poetry and black
This commit is contained in:
Henning Jacobs
2019-12-23 20:07:46 +01:00
committed by GitHub
parent 578b03d214
commit d8b94db671
22 changed files with 1575 additions and 959 deletions

View File

@@ -1,4 +1,4 @@
* *
!Pipfile* !pyproject.toml
!pip* !poetry.lock
!kube_ops_view !kube_ops_view

3
.flake8 Normal file
View File

@@ -0,0 +1,3 @@
[flake8]
max-line-length=240
ignore=E722,W503,E402,E203

1
.gitignore vendored
View File

@@ -13,3 +13,4 @@ scm-source.json
.cache/ .cache/
.coverage .coverage
.pytest_cache/ .pytest_cache/
.mypy_cache

View File

@@ -1,4 +1,4 @@
dist: xenial dist: bionic
sudo: yes sudo: yes
language: python language: python
python: python:
@@ -6,9 +6,7 @@ python:
services: services:
- docker - docker
install: install:
- pip install tox tox-travis coveralls - pip install poetry
- pip install pipenv
- pipenv install --dev
- nvm install 7.4 - nvm install 7.4
- npm install -g eslint - npm install -g eslint
script: script:

View File

@@ -1,27 +1,30 @@
FROM python:3.7-alpine3.10 FROM python:3.8-slim
WORKDIR / WORKDIR /
RUN apk add --no-cache python3 python3-dev gcc musl-dev zlib-dev libffi-dev openssl-dev ca-certificates RUN apt-get update && apt-get install --yes gcc
COPY Pipfile.lock / RUN pip3 install poetry
COPY pipenv-install.py /
RUN /pipenv-install.py && \ COPY poetry.lock /
rm -fr /usr/local/lib/python3.7/site-packages/pip && \ COPY pyproject.toml /
rm -fr /usr/local/lib/python3.7/site-packages/setuptools && \
apk del python3-dev gcc musl-dev zlib-dev libffi-dev openssl-dev && \
rm -rf /var/cache/apk/* /root/.cache /tmp/*
FROM python:3.7-alpine3.10 RUN poetry config virtualenvs.create false && \
poetry install --no-interaction --no-dev --no-ansi
FROM python:3.8-slim
WORKDIR / WORKDIR /
COPY --from=0 /usr/local/lib/python3.7/site-packages /usr/local/lib/python3.7/site-packages # copy pre-built packages to this image
COPY --from=0 /usr/local/lib/python3.8/site-packages /usr/local/lib/python3.8/site-packages
# now copy the actual code we will execute (poetry install above was just for dependencies)
COPY kube_ops_view /kube_ops_view COPY kube_ops_view /kube_ops_view
ARG VERSION=dev ARG VERSION=dev
RUN sed -i "s/__version__ = .*/__version__ = '${VERSION}'/" /kube_ops_view/__init__.py RUN sed -i "s/__version__ = .*/__version__ = '${VERSION}'/" /kube_ops_view/__init__.py
ENTRYPOINT ["/usr/local/bin/python", "-m", "kube_ops_view"] ENTRYPOINT ["python3", "-m", "kube_ops_view"]

View File

@@ -7,13 +7,19 @@ TTYFLAGS = $(shell test -t 0 && echo "-it")
default: docker default: docker
.PHONY: install
install:
poetry install
clean: clean:
rm -fr kube_ops_view/static/build rm -fr kube_ops_view/static/build
test: test: install
pipenv run flake8 poetry run flake8
pipenv run coverage run --source=kube_ops_view -m py.test poetry run black --check kube_ops_view
pipenv run coverage report # poetry run mypy --ignore-missing-imports kube_ops_view
poetry run coverage run --source=kube_ops_view -m py.test -v
poetry run coverage report
appjs: appjs:
docker run $(TTYFLAGS) -u $$(id -u) -v $$(pwd):/workdir -w /workdir/app -e NPM_CONFIG_CACHE=/tmp node:11.10-alpine npm install docker run $(TTYFLAGS) -u $$(id -u) -v $$(pwd):/workdir -w /workdir/app -e NPM_CONFIG_CACHE=/tmp node:11.10-alpine npm install

25
Pipfile
View File

@@ -1,25 +0,0 @@
[[source]]
url = "https://pypi.org/simple"
verify_ssl = true
name = "pypi"
[packages]
click = "*"
gevent = "*"
requests = "*"
stups-tokens = ">=1.1.19"
redlock-py = "*"
json-delta = ">=2.0"
flask = "*"
pykube-ng = "*"
flask-dance = "*"
[dev-packages]
"flake8" = "*"
pytest = "*"
pipenv = "*"
pytest-cov = "*"
coveralls = "*"
[requires]
python_version = "3.7"

513
Pipfile.lock generated
View File

@@ -1,513 +0,0 @@
{
"_meta": {
"hash": {
"sha256": "b08b64b6ea15f966d4c4cdb6f96e61a7b5239bdcc5ea27953c16bd76d9572d34"
},
"pipfile-spec": 6,
"requires": {
"python_version": "3.7"
},
"sources": [
{
"name": "pypi",
"url": "https://pypi.org/simple",
"verify_ssl": true
}
]
},
"default": {
"certifi": {
"hashes": [
"sha256:e4f3620cfea4f83eedc95b24abd9cd56f3c4b146dd0177e83a21b4eb49e21e50",
"sha256:fd7c7c74727ddcf00e9acd26bba8da604ffec95bf1c2144e67aff7a8b50e6cef"
],
"version": "==2019.9.11"
},
"chardet": {
"hashes": [
"sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae",
"sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691"
],
"version": "==3.0.4"
},
"click": {
"hashes": [
"sha256:2335065e6395b9e67ca716de5f7526736bfa6ceead690adf616d925bdc622b13",
"sha256:5b94b49521f6456670fdb30cd82a4eca9412788a93fa6dd6df72c94d5a8ff2d7"
],
"index": "pypi",
"version": "==7.0"
},
"flask": {
"hashes": [
"sha256:13f9f196f330c7c2c5d7a5cf91af894110ca0215ac051b5844701f2bfd934d52",
"sha256:45eb5a6fd193d6cf7e0cf5d8a5b31f83d5faae0293695626f539a823e93b13f6"
],
"index": "pypi",
"version": "==1.1.1"
},
"flask-dance": {
"hashes": [
"sha256:83d6f8d684150ac8fe7d4f2ad8d71170c3233831a09601eb0e5b40d0c28e337d",
"sha256:c3fd1da1c93ada28092e83a5eb843def82701d6d56bd03a512a2e008a25ec106"
],
"index": "pypi",
"version": "==2.2.0"
},
"gevent": {
"hashes": [
"sha256:0774babec518a24d9a7231d4e689931f31b332c4517a771e532002614e270a64",
"sha256:0e1e5b73a445fe82d40907322e1e0eec6a6745ca3cea19291c6f9f50117bb7ea",
"sha256:0ff2b70e8e338cf13bedf146b8c29d475e2a544b5d1fe14045aee827c073842c",
"sha256:107f4232db2172f7e8429ed7779c10f2ed16616d75ffbe77e0e0c3fcdeb51a51",
"sha256:14b4d06d19d39a440e72253f77067d27209c67e7611e352f79fe69e0f618f76e",
"sha256:1b7d3a285978b27b469c0ff5fb5a72bcd69f4306dbbf22d7997d83209a8ba917",
"sha256:1eb7fa3b9bd9174dfe9c3b59b7a09b768ecd496debfc4976a9530a3e15c990d1",
"sha256:2711e69788ddb34c059a30186e05c55a6b611cb9e34ac343e69cf3264d42fe1c",
"sha256:28a0c5417b464562ab9842dd1fb0cc1524e60494641d973206ec24d6ec5f6909",
"sha256:3249011d13d0c63bea72d91cec23a9cf18c25f91d1f115121e5c9113d753fa12",
"sha256:44089ed06a962a3a70e96353c981d628b2d4a2f2a75ea5d90f916a62d22af2e8",
"sha256:4bfa291e3c931ff3c99a349d8857605dca029de61d74c6bb82bd46373959c942",
"sha256:50024a1ee2cf04645535c5ebaeaa0a60c5ef32e262da981f4be0546b26791950",
"sha256:53b72385857e04e7faca13c613c07cab411480822ac658d97fd8a4ddbaf715c8",
"sha256:74b7528f901f39c39cdbb50cdf08f1a2351725d9aebaef212a29abfbb06895ee",
"sha256:7d0809e2991c9784eceeadef01c27ee6a33ca09ebba6154317a257353e3af922",
"sha256:896b2b80931d6b13b5d9feba3d4eebc67d5e6ec54f0cf3339d08487d55d93b0e",
"sha256:8d9ec51cc06580f8c21b41fd3f2b3465197ba5b23c00eb7d422b7ae0380510b0",
"sha256:9f7a1e96fec45f70ad364e46de32ccacab4d80de238bd3c2edd036867ccd48ad",
"sha256:ab4dc33ef0e26dc627559786a4fba0c2227f125db85d970abbf85b77506b3f51",
"sha256:d1e6d1f156e999edab069d79d890859806b555ce4e4da5b6418616322f0a3df1",
"sha256:d752bcf1b98174780e2317ada12013d612f05116456133a6acf3e17d43b71f05",
"sha256:e5bcc4270671936349249d26140c267397b7b4b1381f5ec8b13c53c5b53ab6e1"
],
"index": "pypi",
"version": "==1.4.0"
},
"greenlet": {
"hashes": [
"sha256:000546ad01e6389e98626c1367be58efa613fa82a1be98b0c6fc24b563acc6d0",
"sha256:0d48200bc50cbf498716712129eef819b1729339e34c3ae71656964dac907c28",
"sha256:23d12eacffa9d0f290c0fe0c4e81ba6d5f3a5b7ac3c30a5eaf0126bf4deda5c8",
"sha256:37c9ba82bd82eb6a23c2e5acc03055c0e45697253b2393c9a50cef76a3985304",
"sha256:51503524dd6f152ab4ad1fbd168fc6c30b5795e8c70be4410a64940b3abb55c0",
"sha256:8041e2de00e745c0e05a502d6e6db310db7faa7c979b3a5877123548a4c0b214",
"sha256:81fcd96a275209ef117e9ec91f75c731fa18dcfd9ffaa1c0adbdaa3616a86043",
"sha256:853da4f9563d982e4121fed8c92eea1a4594a2299037b3034c3c898cb8e933d6",
"sha256:8b4572c334593d449113f9dc8d19b93b7b271bdbe90ba7509eb178923327b625",
"sha256:9416443e219356e3c31f1f918a91badf2e37acf297e2fa13d24d1cc2380f8fbc",
"sha256:9854f612e1b59ec66804931df5add3b2d5ef0067748ea29dc60f0efdcda9a638",
"sha256:99a26afdb82ea83a265137a398f570402aa1f2b5dfb4ac3300c026931817b163",
"sha256:a19bf883b3384957e4a4a13e6bd1ae3d85ae87f4beb5957e35b0be287f12f4e4",
"sha256:a9f145660588187ff835c55a7d2ddf6abfc570c2651c276d3d4be8a2766db490",
"sha256:ac57fcdcfb0b73bb3203b58a14501abb7e5ff9ea5e2edfa06bb03035f0cff248",
"sha256:bcb530089ff24f6458a81ac3fa699e8c00194208a724b644ecc68422e1111939",
"sha256:beeabe25c3b704f7d56b573f7d2ff88fc99f0138e43480cecdfcaa3b87fe4f87",
"sha256:d634a7ea1fc3380ff96f9e44d8d22f38418c1c381d5fac680b272d7d90883720",
"sha256:d97b0661e1aead761f0ded3b769044bb00ed5d33e1ec865e891a8b128bf7c656"
],
"markers": "platform_python_implementation == 'CPython'",
"version": "==0.4.15"
},
"idna": {
"hashes": [
"sha256:c357b3f628cf53ae2c4c05627ecc484553142ca23264e593d327bcde5e9c3407",
"sha256:ea8b7f6188e6fa117537c3df7da9fc686d485087abf6ac197f9c46432f7e4a3c"
],
"version": "==2.8"
},
"itsdangerous": {
"hashes": [
"sha256:321b033d07f2a4136d3ec762eac9f16a10ccd60f53c0c91af90217ace7ba1f19",
"sha256:b12271b2047cb23eeb98c8b5622e2e5c5e9abd9784a153e9d8ef9cb4dd09d749"
],
"version": "==1.1.0"
},
"jinja2": {
"hashes": [
"sha256:065c4f02ebe7f7cf559e49ee5a95fb800a9e4528727aec6f24402a5374c65013",
"sha256:14dd6caf1527abb21f08f86c784eac40853ba93edb79552aa1e4b8aef1b61c7b"
],
"version": "==2.10.1"
},
"json-delta": {
"hashes": [
"sha256:462a73672f7527517d863930bb442ed1986c35dfb6960e0fb1cb84393deea652",
"sha256:cdbb8d38b69b5bef9ac54863aff2df8ae95ab897d165448f8d72cc3501fd32f6"
],
"index": "pypi",
"version": "==2.0"
},
"markupsafe": {
"hashes": [
"sha256:00bc623926325b26bb9605ae9eae8a215691f33cae5df11ca5424f06f2d1f473",
"sha256:09027a7803a62ca78792ad89403b1b7a73a01c8cb65909cd876f7fcebd79b161",
"sha256:09c4b7f37d6c648cb13f9230d847adf22f8171b1ccc4d5682398e77f40309235",
"sha256:1027c282dad077d0bae18be6794e6b6b8c91d58ed8a8d89a89d59693b9131db5",
"sha256:24982cc2533820871eba85ba648cd53d8623687ff11cbb805be4ff7b4c971aff",
"sha256:29872e92839765e546828bb7754a68c418d927cd064fd4708fab9fe9c8bb116b",
"sha256:43a55c2930bbc139570ac2452adf3d70cdbb3cfe5912c71cdce1c2c6bbd9c5d1",
"sha256:46c99d2de99945ec5cb54f23c8cd5689f6d7177305ebff350a58ce5f8de1669e",
"sha256:500d4957e52ddc3351cabf489e79c91c17f6e0899158447047588650b5e69183",
"sha256:535f6fc4d397c1563d08b88e485c3496cf5784e927af890fb3c3aac7f933ec66",
"sha256:62fe6c95e3ec8a7fad637b7f3d372c15ec1caa01ab47926cfdf7a75b40e0eac1",
"sha256:6dd73240d2af64df90aa7c4e7481e23825ea70af4b4922f8ede5b9e35f78a3b1",
"sha256:717ba8fe3ae9cc0006d7c451f0bb265ee07739daf76355d06366154ee68d221e",
"sha256:79855e1c5b8da654cf486b830bd42c06e8780cea587384cf6545b7d9ac013a0b",
"sha256:7c1699dfe0cf8ff607dbdcc1e9b9af1755371f92a68f706051cc8c37d447c905",
"sha256:88e5fcfb52ee7b911e8bb6d6aa2fd21fbecc674eadd44118a9cc3863f938e735",
"sha256:8defac2f2ccd6805ebf65f5eeb132adcf2ab57aa11fdf4c0dd5169a004710e7d",
"sha256:98c7086708b163d425c67c7a91bad6e466bb99d797aa64f965e9d25c12111a5e",
"sha256:9add70b36c5666a2ed02b43b335fe19002ee5235efd4b8a89bfcf9005bebac0d",
"sha256:9bf40443012702a1d2070043cb6291650a0841ece432556f784f004937f0f32c",
"sha256:ade5e387d2ad0d7ebf59146cc00c8044acbd863725f887353a10df825fc8ae21",
"sha256:b00c1de48212e4cc9603895652c5c410df699856a2853135b3967591e4beebc2",
"sha256:b1282f8c00509d99fef04d8ba936b156d419be841854fe901d8ae224c59f0be5",
"sha256:b2051432115498d3562c084a49bba65d97cf251f5a331c64a12ee7e04dacc51b",
"sha256:ba59edeaa2fc6114428f1637ffff42da1e311e29382d81b339c1817d37ec93c6",
"sha256:c8716a48d94b06bb3b2524c2b77e055fb313aeb4ea620c8dd03a105574ba704f",
"sha256:cd5df75523866410809ca100dc9681e301e3c27567cf498077e8551b6d20e42f",
"sha256:e249096428b3ae81b08327a63a485ad0878de3fb939049038579ac0ef61e17e7"
],
"version": "==1.1.1"
},
"oauthlib": {
"hashes": [
"sha256:bee41cc35fcca6e988463cacc3bcb8a96224f470ca547e697b604cc697b2f889",
"sha256:df884cd6cbe20e32633f1db1072e9356f53638e4361bef4e8b03c9127c9328ea"
],
"version": "==3.1.0"
},
"pykube-ng": {
"hashes": [
"sha256:22e595aede4caf8eeb4fe5289e22785a986b4ff641b454d18c88347e71b8fe9d",
"sha256:eeac0cb0104d7167ad0c8532cf30de86b2691d1368638e624b86ed36d6f77638"
],
"index": "pypi",
"version": "==19.9.2"
},
"pyyaml": {
"hashes": [
"sha256:0113bc0ec2ad727182326b61326afa3d1d8280ae1122493553fd6f4397f33df9",
"sha256:01adf0b6c6f61bd11af6e10ca52b7d4057dd0be0343eb9283c878cf3af56aee4",
"sha256:5124373960b0b3f4aa7df1707e63e9f109b5263eca5976c66e08b1c552d4eaf8",
"sha256:5ca4f10adbddae56d824b2c09668e91219bb178a1eee1faa56af6f99f11bf696",
"sha256:7907be34ffa3c5a32b60b95f4d95ea25361c951383a894fec31be7252b2b6f34",
"sha256:7ec9b2a4ed5cad025c2278a1e6a19c011c80a3caaac804fd2d329e9cc2c287c9",
"sha256:87ae4c829bb25b9fe99cf71fbb2140c448f534e24c998cc60f39ae4f94396a73",
"sha256:9de9919becc9cc2ff03637872a440195ac4241c80536632fffeb6a1e25a74299",
"sha256:a5a85b10e450c66b49f98846937e8cfca1db3127a9d5d1e31ca45c3d0bef4c5b",
"sha256:b0997827b4f6a7c286c01c5f60384d218dca4ed7d9efa945c3e1aa623d5709ae",
"sha256:b631ef96d3222e62861443cc89d6563ba3eeb816eeb96b2629345ab795e53681",
"sha256:bf47c0607522fdbca6c9e817a6e81b08491de50f3766a7a0e6a5be7905961b41",
"sha256:f81025eddd0327c7d4cfe9b62cf33190e1e736cc6e97502b3ec425f574b3e7a8"
],
"version": "==5.1.2"
},
"redis": {
"hashes": [
"sha256:98a22fb750c9b9bb46e75e945dc3f61d0ab30d06117cbb21ff9cd1d315fedd3b",
"sha256:c504251769031b0dd7dd5cf786050a6050197c6de0d37778c80c08cb04ae8275"
],
"version": "==3.3.8"
},
"redlock-py": {
"hashes": [
"sha256:0b8722c4843ddeabc2fc1dd37c05859e0da29fbce3bd1f6ecc73c98396f139ac"
],
"index": "pypi",
"version": "==1.0.8"
},
"requests": {
"hashes": [
"sha256:11e007a8a2aa0323f5a921e9e6a2d7e4e67d9877e85773fba9ba6419025cbeb4",
"sha256:9cf5292fcd0f598c671cfc1e0d7d1a7f13bb8085e9a590f48c010551dc6c4b31"
],
"index": "pypi",
"version": "==2.22.0"
},
"requests-oauthlib": {
"hashes": [
"sha256:bd6533330e8748e94bf0b214775fed487d309b8b8fe823dc45641ebcd9a32f57",
"sha256:d3ed0c8f2e3bbc6b344fa63d6f933745ab394469da38db16bdddb461c7e25140"
],
"version": "==1.2.0"
},
"six": {
"hashes": [
"sha256:3350809f0555b11f552448330d0b52d5f24c91a322ea4a15ef22629740f3761c",
"sha256:d16a0141ec1a18405cd4ce8b4613101da75da0e9a7aec5bdd4fa804d0e0eba73"
],
"version": "==1.12.0"
},
"stups-tokens": {
"hashes": [
"sha256:317f4386763bac9dd5c0a4c8b0f9f0238dc3fa81de3c6fd1971b6b01662b5750",
"sha256:7830ad83ccbfd52a9734608ffcefcca917137ce9480cc91a4fbd321a4aca3160"
],
"index": "pypi",
"version": "==1.1.19"
},
"urllib3": {
"hashes": [
"sha256:b246607a25ac80bedac05c6f282e3cdaf3afb65420fd024ac94435cabe6e18d1",
"sha256:dbe59173209418ae49d485b87d1681aefa36252ee85884c31346debd19463232"
],
"version": "==1.25.3"
},
"urlobject": {
"hashes": [
"sha256:47b2e20e6ab9c8366b2f4a3566b6ff4053025dad311c4bb71279bbcfa2430caa"
],
"version": "==2.4.3"
},
"werkzeug": {
"hashes": [
"sha256:00d32beac38fcd48d329566f80d39f10ec2ed994efbecfb8dd4b320062d05902",
"sha256:0a24d43be6a7dce81bae05292356176d6c46d63e42a0dd3f9504b210a9cfaa43"
],
"version": "==0.15.6"
}
},
"develop": {
"atomicwrites": {
"hashes": [
"sha256:03472c30eb2c5d1ba9227e4c2ca66ab8287fbfbbda3888aa93dc2e28fc6811b4",
"sha256:75a9445bac02d8d058d5e1fe689654ba5a6556a1dfd8ce6ec55a0ed79866cfa6"
],
"version": "==1.3.0"
},
"attrs": {
"hashes": [
"sha256:69c0dbf2ed392de1cb5ec704444b08a5ef81680a61cb899dc08127123af36a79",
"sha256:f0b870f674851ecbfbbbd364d6b5cbdff9dcedbc7f3f5e18a6891057f21fe399"
],
"version": "==19.1.0"
},
"certifi": {
"hashes": [
"sha256:e4f3620cfea4f83eedc95b24abd9cd56f3c4b146dd0177e83a21b4eb49e21e50",
"sha256:fd7c7c74727ddcf00e9acd26bba8da604ffec95bf1c2144e67aff7a8b50e6cef"
],
"version": "==2019.9.11"
},
"chardet": {
"hashes": [
"sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae",
"sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691"
],
"version": "==3.0.4"
},
"coverage": {
"hashes": [
"sha256:08907593569fe59baca0bf152c43f3863201efb6113ecb38ce7e97ce339805a6",
"sha256:0be0f1ed45fc0c185cfd4ecc19a1d6532d72f86a2bac9de7e24541febad72650",
"sha256:141f08ed3c4b1847015e2cd62ec06d35e67a3ac185c26f7635f4406b90afa9c5",
"sha256:19e4df788a0581238e9390c85a7a09af39c7b539b29f25c89209e6c3e371270d",
"sha256:23cc09ed395b03424d1ae30dcc292615c1372bfba7141eb85e11e50efaa6b351",
"sha256:245388cda02af78276b479f299bbf3783ef0a6a6273037d7c60dc73b8d8d7755",
"sha256:331cb5115673a20fb131dadd22f5bcaf7677ef758741312bee4937d71a14b2ef",
"sha256:386e2e4090f0bc5df274e720105c342263423e77ee8826002dcffe0c9533dbca",
"sha256:3a794ce50daee01c74a494919d5ebdc23d58873747fa0e288318728533a3e1ca",
"sha256:60851187677b24c6085248f0a0b9b98d49cba7ecc7ec60ba6b9d2e5574ac1ee9",
"sha256:63a9a5fc43b58735f65ed63d2cf43508f462dc49857da70b8980ad78d41d52fc",
"sha256:6b62544bb68106e3f00b21c8930e83e584fdca005d4fffd29bb39fb3ffa03cb5",
"sha256:6ba744056423ef8d450cf627289166da65903885272055fb4b5e113137cfa14f",
"sha256:7494b0b0274c5072bddbfd5b4a6c6f18fbbe1ab1d22a41e99cd2d00c8f96ecfe",
"sha256:826f32b9547c8091679ff292a82aca9c7b9650f9fda3e2ca6bf2ac905b7ce888",
"sha256:93715dffbcd0678057f947f496484e906bf9509f5c1c38fc9ba3922893cda5f5",
"sha256:9a334d6c83dfeadae576b4d633a71620d40d1c379129d587faa42ee3e2a85cce",
"sha256:af7ed8a8aa6957aac47b4268631fa1df984643f07ef00acd374e456364b373f5",
"sha256:bf0a7aed7f5521c7ca67febd57db473af4762b9622254291fbcbb8cd0ba5e33e",
"sha256:bf1ef9eb901113a9805287e090452c05547578eaab1b62e4ad456fcc049a9b7e",
"sha256:c0afd27bc0e307a1ffc04ca5ec010a290e49e3afbe841c5cafc5c5a80ecd81c9",
"sha256:dd579709a87092c6dbee09d1b7cfa81831040705ffa12a1b248935274aee0437",
"sha256:df6712284b2e44a065097846488f66840445eb987eb81b3cc6e4149e7b6982e1",
"sha256:e07d9f1a23e9e93ab5c62902833bf3e4b1f65502927379148b6622686223125c",
"sha256:e2ede7c1d45e65e209d6093b762e98e8318ddeff95317d07a27a2140b80cfd24",
"sha256:e4ef9c164eb55123c62411f5936b5c2e521b12356037b6e1c2617cef45523d47",
"sha256:eca2b7343524e7ba246cab8ff00cab47a2d6d54ada3b02772e908a45675722e2",
"sha256:eee64c616adeff7db37cc37da4180a3a5b6177f5c46b187894e633f088fb5b28",
"sha256:ef824cad1f980d27f26166f86856efe11eff9912c4fed97d3804820d43fa550c",
"sha256:efc89291bd5a08855829a3c522df16d856455297cf35ae827a37edac45f466a7",
"sha256:fa964bae817babece5aa2e8c1af841bebb6d0b9add8e637548809d040443fee0",
"sha256:ff37757e068ae606659c28c3bd0d923f9d29a85de79bf25b2b34b148473b5025"
],
"version": "==4.5.4"
},
"coveralls": {
"hashes": [
"sha256:9bc5a1f92682eef59f688a8f280207190d9a6afb84cef8f567fa47631a784060",
"sha256:fb51cddef4bc458de347274116df15d641a735d3f0a580a9472174e2e62f408c"
],
"index": "pypi",
"version": "==1.8.2"
},
"docopt": {
"hashes": [
"sha256:49b3a825280bd66b3aa83585ef59c4a8c82f2c8a522dbe754a8bc8d08c85c491"
],
"version": "==0.6.2"
},
"entrypoints": {
"hashes": [
"sha256:589f874b313739ad35be6e0cd7efde2a4e9b6fea91edcc34e58ecbb8dbe56d19",
"sha256:c70dd71abe5a8c85e55e12c19bd91ccfeec11a6e99044204511f9ed547d48451"
],
"version": "==0.3"
},
"flake8": {
"hashes": [
"sha256:19241c1cbc971b9962473e4438a2ca19749a7dd002dd1a946eaba171b4114548",
"sha256:8e9dfa3cecb2400b3738a42c54c3043e821682b9c840b0448c0503f781130696"
],
"index": "pypi",
"version": "==3.7.8"
},
"idna": {
"hashes": [
"sha256:c357b3f628cf53ae2c4c05627ecc484553142ca23264e593d327bcde5e9c3407",
"sha256:ea8b7f6188e6fa117537c3df7da9fc686d485087abf6ac197f9c46432f7e4a3c"
],
"version": "==2.8"
},
"importlib-metadata": {
"hashes": [
"sha256:652234b6ab8f2506ae58e528b6fbcc668831d3cc758e1bc01ef438d328b68cdb",
"sha256:6f264986fb88042bc1f0535fa9a557e6a376cfe5679dc77caac7fe8b5d43d05f"
],
"markers": "python_version < '3.8'",
"version": "==0.22"
},
"mccabe": {
"hashes": [
"sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42",
"sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f"
],
"version": "==0.6.1"
},
"more-itertools": {
"hashes": [
"sha256:409cd48d4db7052af495b09dec721011634af3753ae1ef92d2b32f73a745f832",
"sha256:92b8c4b06dac4f0611c0729b2f2ede52b2e1bac1ab48f089c7ddc12e26bb60c4"
],
"version": "==7.2.0"
},
"packaging": {
"hashes": [
"sha256:a7ac867b97fdc07ee80a8058fe4435ccd274ecc3b0ed61d852d7d53055528cf9",
"sha256:c491ca87294da7cc01902edbe30a5bc6c4c28172b5138ab4e4aa1b9d7bfaeafe"
],
"version": "==19.1"
},
"pipenv": {
"hashes": [
"sha256:56ad5f5cb48f1e58878e14525a6e3129d4306049cb76d2f6a3e95df0d5fc6330",
"sha256:7df8e33a2387de6f537836f48ac6fcd94eda6ed9ba3d5e3fd52e35b5bc7ff49e",
"sha256:a673e606e8452185e9817a987572b55360f4d28b50831ef3b42ac3cab3fee846"
],
"index": "pypi",
"version": "==2018.11.26"
},
"pluggy": {
"hashes": [
"sha256:0db4b7601aae1d35b4a033282da476845aa19185c1e6964b25cf324b5e4ec3e6",
"sha256:fa5fa1622fa6dd5c030e9cad086fa19ef6a0cf6d7a2d12318e10cb49d6d68f34"
],
"version": "==0.13.0"
},
"py": {
"hashes": [
"sha256:64f65755aee5b381cea27766a3a147c3f15b9b6b9ac88676de66ba2ae36793fa",
"sha256:dc639b046a6e2cff5bbe40194ad65936d6ba360b52b3c3fe1d08a82dd50b5e53"
],
"version": "==1.8.0"
},
"pycodestyle": {
"hashes": [
"sha256:95a2219d12372f05704562a14ec30bc76b05a5b297b21a5dfe3f6fac3491ae56",
"sha256:e40a936c9a450ad81df37f549d676d127b1b66000a6c500caa2b085bc0ca976c"
],
"version": "==2.5.0"
},
"pyflakes": {
"hashes": [
"sha256:17dbeb2e3f4d772725c777fabc446d5634d1038f234e77343108ce445ea69ce0",
"sha256:d976835886f8c5b31d47970ed689944a0262b5f3afa00a5a7b4dc81e5449f8a2"
],
"version": "==2.1.1"
},
"pyparsing": {
"hashes": [
"sha256:6f98a7b9397e206d78cc01df10131398f1c8b8510a2f4d97d9abd82e1aacdd80",
"sha256:d9338df12903bbf5d65a0e4e87c2161968b10d2e489652bb47001d82a9b028b4"
],
"version": "==2.4.2"
},
"pytest": {
"hashes": [
"sha256:95d13143cc14174ca1a01ec68e84d76ba5d9d493ac02716fd9706c949a505210",
"sha256:b78fe2881323bd44fd9bd76e5317173d4316577e7b1cddebae9136a4495ec865"
],
"index": "pypi",
"version": "==5.1.2"
},
"pytest-cov": {
"hashes": [
"sha256:2b097cde81a302e1047331b48cadacf23577e431b61e9c6f49a1170bbe3d3da6",
"sha256:e00ea4fdde970725482f1f35630d12f074e121a23801aabf2ae154ec6bdd343a"
],
"index": "pypi",
"version": "==2.7.1"
},
"requests": {
"hashes": [
"sha256:11e007a8a2aa0323f5a921e9e6a2d7e4e67d9877e85773fba9ba6419025cbeb4",
"sha256:9cf5292fcd0f598c671cfc1e0d7d1a7f13bb8085e9a590f48c010551dc6c4b31"
],
"index": "pypi",
"version": "==2.22.0"
},
"six": {
"hashes": [
"sha256:3350809f0555b11f552448330d0b52d5f24c91a322ea4a15ef22629740f3761c",
"sha256:d16a0141ec1a18405cd4ce8b4613101da75da0e9a7aec5bdd4fa804d0e0eba73"
],
"version": "==1.12.0"
},
"urllib3": {
"hashes": [
"sha256:b246607a25ac80bedac05c6f282e3cdaf3afb65420fd024ac94435cabe6e18d1",
"sha256:dbe59173209418ae49d485b87d1681aefa36252ee85884c31346debd19463232"
],
"version": "==1.25.3"
},
"virtualenv": {
"hashes": [
"sha256:680af46846662bb38c5504b78bad9ed9e4f3ba2d54f54ba42494fdf94337fe30",
"sha256:f78d81b62d3147396ac33fc9d77579ddc42cc2a98dd9ea38886f616b33bc7fb2"
],
"version": "==16.7.5"
},
"virtualenv-clone": {
"hashes": [
"sha256:532f789a5c88adf339506e3ca03326f20ee82fd08ee5586b44dc859b5b4468c5",
"sha256:c88ae171a11b087ea2513f260cdac9232461d8e9369bcd1dc143fc399d220557"
],
"version": "==0.5.3"
},
"wcwidth": {
"hashes": [
"sha256:3df37372226d6e63e1b1e1eda15c594bca98a22d33a23832a90998faa96bc65e",
"sha256:f4ebe71925af7b40a864553f761ed559b43544f8f71746c2d756c7fe788ade7c"
],
"version": "==0.1.7"
},
"zipp": {
"hashes": [
"sha256:3718b1cbcd963c7d4c5511a8240812904164b7f381b647143a89d3b98f9bcd8e",
"sha256:f06903e9f1f43b12d371004b4ac7b06ab39a44adc747266928ae6debfa7b3335"
],
"version": "==0.6.0"
}
}
}

View File

@@ -1,2 +1,2 @@
# This version is replaced during release process. # This version is replaced during release process.
__version__ = '2017.0.dev1' __version__ = "2017.0.dev1"

View File

@@ -9,9 +9,10 @@ import tokens
from requests.auth import AuthBase from requests.auth import AuthBase
from pykube import HTTPClient, KubeConfig from pykube import HTTPClient, KubeConfig
# default URL points to kubectl proxy # default URL points to kubectl proxy
DEFAULT_CLUSTERS = 'http://localhost:8001/' DEFAULT_CLUSTERS = "http://localhost:8001/"
CLUSTER_ID_INVALID_CHARS = re.compile('[^a-z0-9:-]') CLUSTER_ID_INVALID_CHARS = re.compile("[^a-z0-9:-]")
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -19,27 +20,27 @@ tokens.configure(from_file_only=True)
def generate_cluster_id(url: str): def generate_cluster_id(url: str):
'''Generate some "cluster ID" from given API server URL''' """Generate some "cluster ID" from given API server URL"""
for prefix in ('https://', 'http://'): for prefix in ("https://", "http://"):
if url.startswith(prefix): if url.startswith(prefix):
url = url[len(prefix):] url = url[len(prefix) :]
return CLUSTER_ID_INVALID_CHARS.sub('-', url.lower()).strip('-') return CLUSTER_ID_INVALID_CHARS.sub("-", url.lower()).strip("-")
class StaticAuthorizationHeaderAuth(AuthBase): class StaticAuthorizationHeaderAuth(AuthBase):
'''Static authentication with given "Authorization" header''' """Static authentication with given "Authorization" header"""
def __init__(self, authorization): def __init__(self, authorization):
self.authorization = authorization self.authorization = authorization
def __call__(self, request): def __call__(self, request):
request.headers['Authorization'] = self.authorization request.headers["Authorization"] = self.authorization
return request return request
class OAuthTokenAuth(AuthBase): class OAuthTokenAuth(AuthBase):
'''Dynamic authentication using the "tokens" library to load OAuth tokens from file """Dynamic authentication using the "tokens" library to load OAuth tokens from file
(potentially mounted from a Kubernetes secret)''' (potentially mounted from a Kubernetes secret)"""
def __init__(self, token_name): def __init__(self, token_name):
self.token_name = token_name self.token_name = token_name
@@ -47,18 +48,12 @@ class OAuthTokenAuth(AuthBase):
def __call__(self, request): def __call__(self, request):
token = tokens.get(self.token_name) token = tokens.get(self.token_name)
request.headers['Authorization'] = f'Bearer {token}' request.headers["Authorization"] = f"Bearer {token}"
return request return request
class Cluster: class Cluster:
def __init__( def __init__(self, id: str, name: str, api_server_url: str, client: HTTPClient):
self,
id: str,
name: str,
api_server_url: str,
client: HTTPClient
):
self.id = id self.id = id
self.name = name self.name = name
self.api_server_url = api_server_url self.api_server_url = api_server_url
@@ -66,7 +61,6 @@ class Cluster:
class StaticClusterDiscoverer: class StaticClusterDiscoverer:
def __init__(self, api_server_urls: list): def __init__(self, api_server_urls: list):
self._clusters = [] self._clusters = []
@@ -79,15 +73,18 @@ class StaticClusterDiscoverer:
config = KubeConfig.from_url(DEFAULT_CLUSTERS) config = KubeConfig.from_url(DEFAULT_CLUSTERS)
client = HTTPClient(config) client = HTTPClient(config)
cluster = Cluster( cluster = Cluster(
generate_cluster_id(DEFAULT_CLUSTERS), "cluster", DEFAULT_CLUSTERS, client generate_cluster_id(DEFAULT_CLUSTERS),
"cluster",
DEFAULT_CLUSTERS,
client,
) )
else: else:
client = HTTPClient(config) client = HTTPClient(config)
cluster = Cluster( cluster = Cluster(
generate_cluster_id(config.cluster['server']), generate_cluster_id(config.cluster["server"]),
"cluster", "cluster",
config.cluster['server'], config.cluster["server"],
client client,
) )
self._clusters.append(cluster) self._clusters.append(cluster)
else: else:
@@ -95,47 +92,43 @@ class StaticClusterDiscoverer:
config = KubeConfig.from_url(api_server_url) config = KubeConfig.from_url(api_server_url)
client = HTTPClient(config) client = HTTPClient(config)
generated_id = generate_cluster_id(api_server_url) generated_id = generate_cluster_id(api_server_url)
self._clusters.append(Cluster(generated_id, generated_id, api_server_url, client)) self._clusters.append(
Cluster(generated_id, generated_id, api_server_url, client)
)
def get_clusters(self): def get_clusters(self):
return self._clusters return self._clusters
class ClusterRegistryDiscoverer: class ClusterRegistryDiscoverer:
def __init__(self, cluster_registry_url: str, cache_lifetime=60): def __init__(self, cluster_registry_url: str, cache_lifetime=60):
self._url = cluster_registry_url self._url = cluster_registry_url
self._cache_lifetime = cache_lifetime self._cache_lifetime = cache_lifetime
self._last_cache_refresh = 0 self._last_cache_refresh = 0
self._clusters = [] self._clusters = []
self._session = requests.Session() self._session = requests.Session()
self._session.auth = OAuthTokenAuth('read-only') self._session.auth = OAuthTokenAuth("read-only")
def refresh(self): def refresh(self):
try: try:
response = self._session.get(urljoin(self._url, '/kubernetes-clusters'), timeout=10) response = self._session.get(
urljoin(self._url, "/kubernetes-clusters"), timeout=10
)
response.raise_for_status() response.raise_for_status()
clusters = [] clusters = []
for row in response.json()['items']: for row in response.json()["items"]:
# only consider "ready" clusters # only consider "ready" clusters
if row.get("lifecycle_status", "ready") == "ready": if row.get("lifecycle_status", "ready") == "ready":
config = KubeConfig.from_url(row['api_server_url']) config = KubeConfig.from_url(row["api_server_url"])
client = HTTPClient(config) client = HTTPClient(config)
client.session.auth = OAuthTokenAuth("read-only") client.session.auth = OAuthTokenAuth("read-only")
clusters.append( clusters.append(
Cluster( Cluster(row["id"], row["alias"], row["api_server_url"], client)
row["id"],
row["alias"],
row["api_server_url"],
client
)
) )
self._clusters = clusters self._clusters = clusters
self._last_cache_refresh = time.time() self._last_cache_refresh = time.time()
except: except:
logger.exception( logger.exception(f"Failed to refresh from cluster registry {self._url}")
f"Failed to refresh from cluster registry {self._url}"
)
def get_clusters(self): def get_clusters(self):
now = time.time() now = time.time()
@@ -145,7 +138,6 @@ class ClusterRegistryDiscoverer:
class KubeconfigDiscoverer: class KubeconfigDiscoverer:
def __init__(self, kubeconfig_path: Path, contexts: set): def __init__(self, kubeconfig_path: Path, contexts: set):
self._path = kubeconfig_path self._path = kubeconfig_path
self._contexts = contexts self._contexts = contexts
@@ -162,21 +154,17 @@ class KubeconfigDiscoverer:
context_config = KubeConfig(config.doc, context) context_config = KubeConfig(config.doc, context)
client = HTTPClient(context_config) client = HTTPClient(context_config)
cluster = Cluster( cluster = Cluster(
context, context, context, context_config.cluster["server"], client
context,
context_config.cluster['server'],
client
) )
yield cluster yield cluster
class MockDiscoverer: class MockDiscoverer:
def get_clusters(self): def get_clusters(self):
for i in range(3): for i in range(3):
yield Cluster( yield Cluster(
f"mock-cluster-{i}", f"mock-cluster-{i}",
f"mock-cluster-{i}", f"mock-cluster-{i}",
api_server_url=f"https://kube-{i}.example.org", api_server_url=f"https://kube-{i}.example.org",
client=None client=None,
) )

View File

@@ -19,57 +19,67 @@ session = requests.Session()
# https://github.com/kubernetes/community/blob/master/contributors/design-proposals/instrumentation/resource-metrics-api.md # https://github.com/kubernetes/community/blob/master/contributors/design-proposals/instrumentation/resource-metrics-api.md
class NodeMetrics(APIObject): class NodeMetrics(APIObject):
version = 'metrics.k8s.io/v1beta1' version = "metrics.k8s.io/v1beta1"
endpoint = 'nodes' endpoint = "nodes"
kind = 'NodeMetrics' kind = "NodeMetrics"
# https://github.com/kubernetes/community/blob/master/contributors/design-proposals/instrumentation/resource-metrics-api.md # https://github.com/kubernetes/community/blob/master/contributors/design-proposals/instrumentation/resource-metrics-api.md
class PodMetrics(NamespacedAPIObject): class PodMetrics(NamespacedAPIObject):
version = 'metrics.k8s.io/v1beta1' version = "metrics.k8s.io/v1beta1"
endpoint = 'pods' endpoint = "pods"
kind = 'PodMetrics' kind = "PodMetrics"
def map_node_status(status: dict): def map_node_status(status: dict):
return { return {
'addresses': status.get('addresses'), "addresses": status.get("addresses"),
'capacity': status.get('capacity'), "capacity": status.get("capacity"),
'allocatable': status.get('allocatable') "allocatable": status.get("allocatable"),
} }
def map_node(node: dict): def map_node(node: dict):
return { return {
'name': node['metadata']['name'], "name": node["metadata"]["name"],
'labels': node['metadata']['labels'], "labels": node["metadata"]["labels"],
'status': map_node_status(node['status']), "status": map_node_status(node["status"]),
'pods': {} "pods": {},
} }
def map_pod(pod: dict): def map_pod(pod: dict):
return { return {
'name': pod['metadata']['name'], "name": pod["metadata"]["name"],
'namespace': pod['metadata']['namespace'], "namespace": pod["metadata"]["namespace"],
'labels': pod['metadata'].get('labels', {}), "labels": pod["metadata"].get("labels", {}),
'phase': pod['status'].get('phase'), "phase": pod["status"].get("phase"),
'startTime': pod['status']['startTime'] if 'startTime' in pod['status'] else '', "startTime": pod["status"]["startTime"] if "startTime" in pod["status"] else "",
'containers': [] "containers": [],
} }
def map_container(cont: dict, pod: dict): def map_container(cont: dict, pod: dict):
obj = {'name': cont['name'], 'image': cont['image'], 'resources': cont['resources']} obj = {"name": cont["name"], "image": cont["image"], "resources": cont["resources"]}
status = list([s for s in pod.get('status', {}).get('containerStatuses', []) if s['name'] == cont['name']]) status = list(
[
s
for s in pod.get("status", {}).get("containerStatuses", [])
if s["name"] == cont["name"]
]
)
if status: if status:
obj.update(**status[0]) obj.update(**status[0])
return obj return obj
def parse_time(s: str): def parse_time(s: str):
return datetime.datetime.strptime(s, '%Y-%m-%dT%H:%M:%SZ').replace(tzinfo=datetime.timezone.utc).timestamp() return (
datetime.datetime.strptime(s, "%Y-%m-%dT%H:%M:%SZ")
.replace(tzinfo=datetime.timezone.utc)
.timestamp()
)
def query_kubernetes_cluster(cluster): def query_kubernetes_cluster(cluster):
@@ -80,54 +90,66 @@ def query_kubernetes_cluster(cluster):
unassigned_pods = {} unassigned_pods = {}
for node in Node.objects(cluster.client): for node in Node.objects(cluster.client):
obj = map_node(node.obj) obj = map_node(node.obj)
nodes[obj['name']] = obj nodes[obj["name"]] = obj
now = time.time() now = time.time()
for pod in Pod.objects(cluster.client, namespace=pykube.all): for pod in Pod.objects(cluster.client, namespace=pykube.all):
obj = map_pod(pod.obj) obj = map_pod(pod.obj)
if 'deletionTimestamp' in pod.metadata: if "deletionTimestamp" in pod.metadata:
obj['deleted'] = parse_time(pod.metadata['deletionTimestamp']) obj["deleted"] = parse_time(pod.metadata["deletionTimestamp"])
for cont in pod.obj['spec']['containers']: for cont in pod.obj["spec"]["containers"]:
obj['containers'].append(map_container(cont, pod.obj)) obj["containers"].append(map_container(cont, pod.obj))
if obj['phase'] in ('Succeeded', 'Failed'): if obj["phase"] in ("Succeeded", "Failed"):
last_termination_time = 0 last_termination_time = 0
for container in obj['containers']: for container in obj["containers"]:
termination_time = container.get('state', {}).get('terminated', {}).get('finishedAt') termination_time = (
container.get("state", {}).get("terminated", {}).get("finishedAt")
)
if termination_time: if termination_time:
termination_time = parse_time(termination_time) termination_time = parse_time(termination_time)
if termination_time > last_termination_time: if termination_time > last_termination_time:
last_termination_time = termination_time last_termination_time = termination_time
if (last_termination_time and last_termination_time < now - 3600) or (obj.get('reason') == 'Evicted'): if (last_termination_time and last_termination_time < now - 3600) or (
obj.get("reason") == "Evicted"
):
# the job/pod finished more than an hour ago or if it is evicted by cgroup limits # the job/pod finished more than an hour ago or if it is evicted by cgroup limits
# => filter out # => filter out
continue continue
pods_by_namespace_name[(pod.namespace, pod.name)] = obj pods_by_namespace_name[(pod.namespace, pod.name)] = obj
pod_key = f'{pod.namespace}/{pod.name}' pod_key = f"{pod.namespace}/{pod.name}"
node_name = pod.obj['spec'].get('nodeName') node_name = pod.obj["spec"].get("nodeName")
if node_name in nodes: if node_name in nodes:
nodes[node_name]['pods'][pod_key] = obj nodes[node_name]["pods"][pod_key] = obj
else: else:
unassigned_pods[pod_key] = obj unassigned_pods[pod_key] = obj
try: try:
for node_metrics in NodeMetrics.objects(cluster.client): for node_metrics in NodeMetrics.objects(cluster.client):
key = node_metrics.name key = node_metrics.name
nodes[key]['usage'] = node_metrics.obj.get('usage', {}) nodes[key]["usage"] = node_metrics.obj.get("usage", {})
except Exception as e: except Exception as e:
logger.warning('Failed to query node metrics {}: {}'.format(cluster.id, get_short_error_message(e))) logger.warning(
"Failed to query node metrics {}: {}".format(
cluster.id, get_short_error_message(e)
)
)
try: try:
for pod_metrics in PodMetrics.objects(cluster.client, namespace=pykube.all): for pod_metrics in PodMetrics.objects(cluster.client, namespace=pykube.all):
key = (pod_metrics.namespace, pod_metrics.name) key = (pod_metrics.namespace, pod_metrics.name)
pod = pods_by_namespace_name.get(key) pod = pods_by_namespace_name.get(key)
if pod: if pod:
for container in pod['containers']: for container in pod["containers"]:
for container_metrics in pod_metrics.obj.get('containers', []): for container_metrics in pod_metrics.obj.get("containers", []):
if container['name'] == container_metrics['name']: if container["name"] == container_metrics["name"]:
container['resources']['usage'] = container_metrics['usage'] container["resources"]["usage"] = container_metrics["usage"]
except Exception as e: except Exception as e:
logger.warning('Failed to query pod metrics for cluster {}: {}'.format(cluster.id, get_short_error_message(e))) logger.warning(
"Failed to query pod metrics for cluster {}: {}".format(
cluster.id, get_short_error_message(e)
)
)
return { return {
'id': cluster_id, "id": cluster_id,
'api_server_url': api_server_url, "api_server_url": api_server_url,
'nodes': nodes, "nodes": nodes,
'unassigned_pods': unassigned_pods "unassigned_pods": unassigned_pods,
} }

View File

@@ -24,25 +24,32 @@ from urllib.parse import urljoin
from .mock import query_mock_cluster from .mock import query_mock_cluster
from .kubernetes import query_kubernetes_cluster from .kubernetes import query_kubernetes_cluster
from .stores import MemoryStore, RedisStore from .stores import MemoryStore, RedisStore
from .cluster_discovery import DEFAULT_CLUSTERS, StaticClusterDiscoverer, ClusterRegistryDiscoverer, KubeconfigDiscoverer, MockDiscoverer from .cluster_discovery import (
DEFAULT_CLUSTERS,
StaticClusterDiscoverer,
ClusterRegistryDiscoverer,
KubeconfigDiscoverer,
MockDiscoverer,
)
from .update import update_clusters from .update import update_clusters
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
SERVER_STATUS = {'shutdown': False} SERVER_STATUS = {"shutdown": False}
AUTHORIZE_URL = os.getenv('AUTHORIZE_URL') AUTHORIZE_URL = os.getenv("AUTHORIZE_URL")
ACCESS_TOKEN_URL = os.getenv('ACCESS_TOKEN_URL') ACCESS_TOKEN_URL = os.getenv("ACCESS_TOKEN_URL")
APP_URL = os.getenv('APP_URL') APP_URL = os.getenv("APP_URL")
SCOPE = os.getenv('SCOPE') SCOPE = os.getenv("SCOPE")
app = Flask(__name__) app = Flask(__name__)
oauth_blueprint = OAuth2ConsumerBlueprintWithClientRefresh( oauth_blueprint = OAuth2ConsumerBlueprintWithClientRefresh(
"oauth", __name__, "oauth",
__name__,
authorization_url=AUTHORIZE_URL, authorization_url=AUTHORIZE_URL,
token_url=ACCESS_TOKEN_URL, token_url=ACCESS_TOKEN_URL,
scope=SCOPE scope=SCOPE,
) )
app.register_blueprint(oauth_blueprint, url_prefix="/login") app.register_blueprint(oauth_blueprint, url_prefix="/login")
@@ -50,35 +57,48 @@ app.register_blueprint(oauth_blueprint, url_prefix="/login")
def authorize(f): def authorize(f):
@functools.wraps(f) @functools.wraps(f)
def wrapper(*args, **kwargs): def wrapper(*args, **kwargs):
if AUTHORIZE_URL and 'auth_token' not in flask.session and not oauth_blueprint.session.authorized: if (
return redirect(url_for('oauth.login')) AUTHORIZE_URL
and "auth_token" not in flask.session
and not oauth_blueprint.session.authorized
):
return redirect(url_for("oauth.login"))
return f(*args, **kwargs) return f(*args, **kwargs)
return wrapper return wrapper
@app.route('/health') @app.route("/health")
def health(): def health():
if SERVER_STATUS['shutdown']: if SERVER_STATUS["shutdown"]:
flask.abort(503) flask.abort(503)
else: else:
return 'OK' return "OK"
@app.route('/') @app.route("/")
@authorize @authorize
def index(): def index():
static_build_path = Path(__file__).parent / 'static' / 'build' static_build_path = Path(__file__).parent / "static" / "build"
candidates = sorted(static_build_path.glob('app*.js')) candidates = sorted(static_build_path.glob("app*.js"))
if candidates: if candidates:
app_js = candidates[0].name app_js = candidates[0].name
if app.debug: if app.debug:
# cache busting for local development # cache busting for local development
app_js += '?_={}'.format(time.time()) app_js += "?_={}".format(time.time())
else: else:
logger.error('Could not find JavaScript application bundle app*.js in {}'.format(static_build_path)) logger.error(
flask.abort(503, 'JavaScript application bundle not found (missing build)') "Could not find JavaScript application bundle app*.js in {}".format(
return flask.render_template('index.html', app_js=app_js, version=kube_ops_view.__version__, app_config_json=json.dumps(app.app_config)) static_build_path
)
)
flask.abort(503, "JavaScript application bundle not found (missing build)")
return flask.render_template(
"index.html",
app_js=app_js,
version=kube_ops_view.__version__,
app_config_json=json.dumps(app.app_config),
)
def event(cluster_ids: set): def event(cluster_ids: set):
@@ -89,55 +109,72 @@ def event(cluster_ids: set):
if status: if status:
# send the cluster status including last_query_time BEFORE the cluster data # send the cluster status including last_query_time BEFORE the cluster data
# so the UI knows how to render correctly from the start # so the UI knows how to render correctly from the start
yield 'event: clusterstatus\ndata: ' + json.dumps({'cluster_id': cluster_id, 'status': status}, separators=(',', ':')) + '\n\n' yield "event: clusterstatus\ndata: " + json.dumps(
{"cluster_id": cluster_id, "status": status}, separators=(",", ":")
) + "\n\n"
cluster = app.store.get_cluster_data(cluster_id) cluster = app.store.get_cluster_data(cluster_id)
if cluster: if cluster:
yield 'event: clusterupdate\ndata: ' + json.dumps(cluster, separators=(',', ':')) + '\n\n' yield "event: clusterupdate\ndata: " + json.dumps(
yield 'event: bootstrapend\ndata: \n\n' cluster, separators=(",", ":")
) + "\n\n"
yield "event: bootstrapend\ndata: \n\n"
while True: while True:
for event_type, event_data in app.store.listen(): for event_type, event_data in app.store.listen():
# hacky, event_data can be delta or full cluster object # hacky, event_data can be delta or full cluster object
if not cluster_ids or event_data.get('cluster_id', event_data.get('id')) in cluster_ids: if (
yield 'event: ' + event_type + '\ndata: ' + json.dumps(event_data, separators=(',', ':')) + '\n\n' not cluster_ids
or event_data.get("cluster_id", event_data.get("id")) in cluster_ids
):
yield "event: " + event_type + "\ndata: " + json.dumps(
event_data, separators=(",", ":")
) + "\n\n"
@app.route('/events') @app.route("/events")
@authorize @authorize
def get_events(): def get_events():
'''SSE (Server Side Events), for an EventSource''' """SSE (Server Side Events), for an EventSource"""
cluster_ids = set() cluster_ids = set()
for _id in flask.request.args.get('cluster_ids', '').split(): for _id in flask.request.args.get("cluster_ids", "").split():
if _id: if _id:
cluster_ids.add(_id) cluster_ids.add(_id)
return flask.Response(event(cluster_ids), mimetype='text/event-stream', headers={'Cache-Control': 'no-cache', 'X-Accel-Buffering': 'no'}) return flask.Response(
event(cluster_ids),
mimetype="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
)
@app.route('/screen-tokens', methods=['GET', 'POST']) @app.route("/screen-tokens", methods=["GET", "POST"])
@authorize @authorize
def screen_tokens(): def screen_tokens():
new_token = None new_token = None
if flask.request.method == 'POST': if flask.request.method == "POST":
new_token = app.store.create_screen_token() new_token = app.store.create_screen_token()
return flask.render_template('screen-tokens.html', new_token=new_token) return flask.render_template("screen-tokens.html", new_token=new_token)
@app.route('/screen/<token>') @app.route("/screen/<token>")
def redeem_screen_token(token: str): def redeem_screen_token(token: str):
remote_addr = flask.request.headers.get('X-Forwarded-For') or flask.request.remote_addr remote_addr = (
logger.info('Trying to redeem screen token "{}" for IP {}..'.format(token, remote_addr)) flask.request.headers.get("X-Forwarded-For") or flask.request.remote_addr
)
logger.info(
'Trying to redeem screen token "{}" for IP {}..'.format(token, remote_addr)
)
try: try:
app.store.redeem_screen_token(token, remote_addr) app.store.redeem_screen_token(token, remote_addr)
except: except:
flask.abort(401) flask.abort(401)
flask.session['auth_token'] = (token, '') flask.session["auth_token"] = (token, "")
return redirect(urljoin(APP_URL, '/')) return redirect(urljoin(APP_URL, "/"))
@app.route('/logout') @app.route("/logout")
def logout(): def logout():
flask.session.pop('auth_token', None) flask.session.pop("auth_token", None)
return redirect(urljoin(APP_URL, '/')) return redirect(urljoin(APP_URL, "/"))
def shutdown(): def shutdown():
@@ -150,48 +187,120 @@ def shutdown():
def exit_gracefully(signum, frame): def exit_gracefully(signum, frame):
logger.info('Received TERM signal, shutting down..') logger.info("Received TERM signal, shutting down..")
SERVER_STATUS['shutdown'] = True SERVER_STATUS["shutdown"] = True
gevent.spawn(shutdown) gevent.spawn(shutdown)
def print_version(ctx, param, value): def print_version(ctx, param, value):
if not value or ctx.resilient_parsing: if not value or ctx.resilient_parsing:
return return
click.echo('Kubernetes Operational View {}'.format(kube_ops_view.__version__)) click.echo("Kubernetes Operational View {}".format(kube_ops_view.__version__))
ctx.exit() ctx.exit()
class CommaSeparatedValues(click.ParamType): class CommaSeparatedValues(click.ParamType):
name = 'comma_separated_values' name = "comma_separated_values"
def convert(self, value, param, ctx): def convert(self, value, param, ctx):
if isinstance(value, str): if isinstance(value, str):
values = filter(None, value.split(',')) values = filter(None, value.split(","))
else: else:
values = value values = value
return values return values
@click.command(context_settings={'help_option_names': ['-h', '--help']}) @click.command(context_settings={"help_option_names": ["-h", "--help"]})
@click.option('-V', '--version', is_flag=True, callback=print_version, expose_value=False, is_eager=True, @click.option(
help='Print the current version number and exit.') "-V",
@click.option('-p', '--port', type=int, help='HTTP port to listen on (default: 8080)', envvar='SERVER_PORT', default=8080) "--version",
@click.option('-d', '--debug', is_flag=True, help='Run in debugging mode', envvar='DEBUG') is_flag=True,
@click.option('-m', '--mock', is_flag=True, help='Mock Kubernetes clusters', envvar='MOCK') callback=print_version,
@click.option('--secret-key', help='Secret key for session cookies', envvar='SECRET_KEY', default='development') expose_value=False,
@click.option('--redis-url', help='Redis URL to use for pub/sub and job locking', envvar='REDIS_URL') is_eager=True,
@click.option('--clusters', type=CommaSeparatedValues(), help="Print the current version number and exit.",
help='Comma separated list of Kubernetes API server URLs (default: {})'.format(DEFAULT_CLUSTERS), envvar='CLUSTERS') )
@click.option('--cluster-registry-url', help='URL to cluster registry', envvar='CLUSTER_REGISTRY_URL') @click.option(
@click.option('--kubeconfig-path', type=click.Path(exists=True), help='Path to kubeconfig file', envvar='KUBECONFIG_PATH') "-p",
@click.option('--kubeconfig-contexts', type=CommaSeparatedValues(), "--port",
help='List of kubeconfig contexts to use (default: use all defined contexts)', envvar='KUBECONFIG_CONTEXTS') type=int,
@click.option('--query-interval', type=float, help='Interval in seconds for querying clusters (default: 5)', envvar='QUERY_INTERVAL', default=5) help="HTTP port to listen on (default: 8080)",
@click.option('--node-link-url-template', help='Template for target URL when clicking on a Node', envvar='NODE_LINK_URL_TEMPLATE') envvar="SERVER_PORT",
@click.option('--pod-link-url-template', help='Template for target URL when clicking on a Pod', envvar='POD_LINK_URL_TEMPLATE') default=8080,
def main(port, debug, mock, secret_key, redis_url, clusters: list, cluster_registry_url, kubeconfig_path, kubeconfig_contexts: list, query_interval, )
node_link_url_template: str, pod_link_url_template: str): @click.option(
"-d", "--debug", is_flag=True, help="Run in debugging mode", envvar="DEBUG"
)
@click.option(
"-m", "--mock", is_flag=True, help="Mock Kubernetes clusters", envvar="MOCK"
)
@click.option(
"--secret-key",
help="Secret key for session cookies",
envvar="SECRET_KEY",
default="development",
)
@click.option(
"--redis-url",
help="Redis URL to use for pub/sub and job locking",
envvar="REDIS_URL",
)
@click.option(
"--clusters",
type=CommaSeparatedValues(),
help="Comma separated list of Kubernetes API server URLs (default: {})".format(
DEFAULT_CLUSTERS
),
envvar="CLUSTERS",
)
@click.option(
"--cluster-registry-url",
help="URL to cluster registry",
envvar="CLUSTER_REGISTRY_URL",
)
@click.option(
"--kubeconfig-path",
type=click.Path(exists=True),
help="Path to kubeconfig file",
envvar="KUBECONFIG_PATH",
)
@click.option(
"--kubeconfig-contexts",
type=CommaSeparatedValues(),
help="List of kubeconfig contexts to use (default: use all defined contexts)",
envvar="KUBECONFIG_CONTEXTS",
)
@click.option(
"--query-interval",
type=float,
help="Interval in seconds for querying clusters (default: 5)",
envvar="QUERY_INTERVAL",
default=5,
)
@click.option(
"--node-link-url-template",
help="Template for target URL when clicking on a Node",
envvar="NODE_LINK_URL_TEMPLATE",
)
@click.option(
"--pod-link-url-template",
help="Template for target URL when clicking on a Pod",
envvar="POD_LINK_URL_TEMPLATE",
)
def main(
port,
debug,
mock,
secret_key,
redis_url,
clusters: list,
cluster_registry_url,
kubeconfig_path,
kubeconfig_contexts: list,
query_interval,
node_link_url_template: str,
pod_link_url_template: str,
):
logging.basicConfig(level=logging.DEBUG if debug else logging.INFO) logging.basicConfig(level=logging.DEBUG if debug else logging.INFO)
store = RedisStore(redis_url) if redis_url else MemoryStore() store = RedisStore(redis_url) if redis_url else MemoryStore()
@@ -199,7 +308,10 @@ def main(port, debug, mock, secret_key, redis_url, clusters: list, cluster_regis
app.debug = debug app.debug = debug
app.secret_key = secret_key app.secret_key = secret_key
app.store = store app.store = store
app.app_config = {'node_link_url_template': node_link_url_template, 'pod_link_url_template': pod_link_url_template} app.app_config = {
"node_link_url_template": node_link_url_template,
"pod_link_url_template": pod_link_url_template,
}
if mock: if mock:
cluster_query = query_mock_cluster cluster_query = query_mock_cluster
@@ -209,14 +321,23 @@ def main(port, debug, mock, secret_key, redis_url, clusters: list, cluster_regis
if cluster_registry_url: if cluster_registry_url:
discoverer = ClusterRegistryDiscoverer(cluster_registry_url) discoverer = ClusterRegistryDiscoverer(cluster_registry_url)
elif kubeconfig_path: elif kubeconfig_path:
discoverer = KubeconfigDiscoverer(Path(kubeconfig_path), set(kubeconfig_contexts or [])) discoverer = KubeconfigDiscoverer(
Path(kubeconfig_path), set(kubeconfig_contexts or [])
)
else: else:
api_server_urls = clusters or [] api_server_urls = clusters or []
discoverer = StaticClusterDiscoverer(api_server_urls) discoverer = StaticClusterDiscoverer(api_server_urls)
gevent.spawn(update_clusters, cluster_discoverer=discoverer, query_cluster=cluster_query, store=store, query_interval=query_interval, debug=debug) gevent.spawn(
update_clusters,
cluster_discoverer=discoverer,
query_cluster=cluster_query,
store=store,
query_interval=query_interval,
debug=debug,
)
signal.signal(signal.SIGTERM, exit_gracefully) signal.signal(signal.SIGTERM, exit_gracefully)
http_server = gevent.pywsgi.WSGIServer(('0.0.0.0', port), app) http_server = gevent.pywsgi.WSGIServer(("0.0.0.0", port), app)
logger.info('Listening on :{}..'.format(port)) logger.info("Listening on :{}..".format(port))
http_server.serve_forever() http_server.serve_forever()

View File

@@ -4,34 +4,33 @@ import string
def hash_int(x: int): def hash_int(x: int):
x = ((x >> 16) ^ x) * 0x45d9f3b x = ((x >> 16) ^ x) * 0x45D9F3B
x = ((x >> 16) ^ x) * 0x45d9f3b x = ((x >> 16) ^ x) * 0x45D9F3B
x = (x >> 16) ^ x x = (x >> 16) ^ x
return x return x
def generate_mock_pod(index: int, i: int, j: int): def generate_mock_pod(index: int, i: int, j: int):
names = [ names = [
'agent-cooper', "agent-cooper",
'black-lodge', "black-lodge",
'bob', "bob",
'bobby-briggs', "bobby-briggs",
'laura-palmer', "laura-palmer",
'leland-palmer', "leland-palmer",
'log-lady', "log-lady",
'sheriff-truman', "sheriff-truman",
] ]
labels = { labels = {"env": ["prod", "dev"], "owner": ["x-wing", "iris"]}
'env': ['prod', 'dev'], pod_phases = ["Pending", "Running", "Running", "Failed"]
'owner': ['x-wing', 'iris']
}
pod_phases = ['Pending', 'Running', 'Running', 'Failed']
pod_labels = {} pod_labels = {}
for li, k in enumerate(labels): for li, k in enumerate(labels):
v = labels[k] v = labels[k]
label_choice = hash_int((index + 1) * (i + 1) * (j + 1) * (li + 1)) % (len(v) + 1) label_choice = hash_int((index + 1) * (i + 1) * (j + 1) * (li + 1)) % (
if(label_choice != 0): len(v) + 1
)
if label_choice != 0:
pod_labels[k] = v[label_choice - 1] pod_labels[k] = v[label_choice - 1]
phase = pod_phases[hash_int((index + 1) * (i + 1) * (j + 1)) % len(pod_phases)] phase = pod_phases[hash_int((index + 1) * (i + 1) * (j + 1)) % len(pod_phases)]
@@ -44,41 +43,53 @@ def generate_mock_pod(index: int, i: int, j: int):
usage_cpu = max(requests_cpu + random.randint(-30, 30), 1) usage_cpu = max(requests_cpu + random.randint(-30, 30), 1)
usage_memory = max(requests_memory + random.randint(-64, 128), 1) usage_memory = max(requests_memory + random.randint(-64, 128), 1)
container = { container = {
'name': 'myapp', "name": "myapp",
'image': 'foo/bar/{}'.format(j), "image": "foo/bar/{}".format(j),
'resources': { "resources": {
'requests': {'cpu': f'{requests_cpu}m', 'memory': f'{requests_memory}Mi'}, "requests": {
'limits': {}, "cpu": f"{requests_cpu}m",
'usage': {'cpu': f'{usage_cpu}m', 'memory': f'{usage_memory}Mi'}, "memory": f"{requests_memory}Mi",
},
"limits": {},
"usage": {"cpu": f"{usage_cpu}m", "memory": f"{usage_memory}Mi"},
}, },
'ready': True, "ready": True,
'state': {'running': {}} "state": {"running": {}},
} }
if phase == 'Running': if phase == "Running":
if j % 13 == 0: if j % 13 == 0:
container.update(**{'ready': False, 'state': {'waiting': {'reason': 'CrashLoopBackOff'}}}) container.update(
**{
"ready": False,
"state": {"waiting": {"reason": "CrashLoopBackOff"}},
}
)
elif j % 7 == 0: elif j % 7 == 0:
container.update(**{'ready': False, 'state': {'running': {}}, 'restartCount': 3}) container.update(
elif phase == 'Failed': **{"ready": False, "state": {"running": {}}, "restartCount": 3}
del container['state'] )
del container['ready'] elif phase == "Failed":
del container["state"]
del container["ready"]
containers.append(container) containers.append(container)
pod = { pod = {
'name': '{}-{}-{}'.format(names[hash_int((i + 1) * (j + 1)) % len(names)], i, j), "name": "{}-{}-{}".format(
'namespace': 'kube-system' if j < 3 else 'default', names[hash_int((i + 1) * (j + 1)) % len(names)], i, j
'labels': pod_labels, ),
'phase': phase, "namespace": "kube-system" if j < 3 else "default",
'containers': containers "labels": pod_labels,
"phase": phase,
"containers": containers,
} }
if phase == 'Running' and j % 17 == 0: if phase == "Running" and j % 17 == 0:
pod['deleted'] = 123 pod["deleted"] = 123
return pod return pod
def query_mock_cluster(cluster): def query_mock_cluster(cluster):
'''Generate deterministic (no randomness!) mock data''' """Generate deterministic (no randomness!) mock data"""
index = int(cluster.id.split('-')[-1]) index = int(cluster.id.split("-")[-1])
nodes = {} nodes = {}
for i in range(10): for i in range(10):
# add/remove the second to last node every 13 seconds # add/remove the second to last node every 13 seconds
@@ -88,11 +99,11 @@ def query_mock_cluster(cluster):
# only the first two clusters have master nodes # only the first two clusters have master nodes
if i < 2 and index < 2: if i < 2 and index < 2:
if index == 0: if index == 0:
labels['kubernetes.io/role'] = 'master' labels["kubernetes.io/role"] = "master"
elif index == 1: elif index == 1:
labels['node-role.kubernetes.io/master'] = '' labels["node-role.kubernetes.io/master"] = ""
else: else:
labels['master'] = 'true' labels["master"] = "true"
pods = {} pods = {}
for j in range(hash_int((index + 1) * (i + 1)) % 32): for j in range(hash_int((index + 1) * (i + 1)) % 32):
# add/remove some pods every 7 seconds # add/remove some pods every 7 seconds
@@ -100,7 +111,7 @@ def query_mock_cluster(cluster):
pass pass
else: else:
pod = generate_mock_pod(index, i, j) pod = generate_mock_pod(index, i, j)
pods['{}/{}'.format(pod['namespace'], pod['name'])] = pod pods["{}/{}".format(pod["namespace"], pod["name"])] = pod
# use data from containers (usage) # use data from containers (usage)
usage_cpu = 0 usage_cpu = 0
@@ -111,27 +122,27 @@ def query_mock_cluster(cluster):
usage_memory += int(c["resources"]["usage"]["memory"].split("Mi")[0]) usage_memory += int(c["resources"]["usage"]["memory"].split("Mi")[0])
# generate longer name for a node # generate longer name for a node
suffix = ''.join( suffix = "".join(
[random.choice(string.ascii_letters) for n in range(random.randint(1, 20))] [random.choice(string.ascii_letters) for n in range(random.randint(1, 20))]
) )
node = { node = {
'name': f'node-{i}-{suffix}', "name": f"node-{i}-{suffix}",
'labels': labels, "labels": labels,
'status': { "status": {
'capacity': {'cpu': '8', 'memory': '64Gi', 'pods': '110'}, "capacity": {"cpu": "8", "memory": "64Gi", "pods": "110"},
'allocatable': {'cpu': '7800m', 'memory': '62Gi'} "allocatable": {"cpu": "7800m", "memory": "62Gi"},
}, },
'pods': pods, "pods": pods,
# get data from containers (usage) # get data from containers (usage)
'usage': {'cpu': f'{usage_cpu}m', 'memory': f'{usage_memory}Mi'} "usage": {"cpu": f"{usage_cpu}m", "memory": f"{usage_memory}Mi"},
} }
nodes[node['name']] = node nodes[node["name"]] = node
pod = generate_mock_pod(index, 11, index) pod = generate_mock_pod(index, 11, index)
unassigned_pods = {'{}/{}'.format(pod['namespace'], pod['name']): pod} unassigned_pods = {"{}/{}".format(pod["namespace"], pod["name"]): pod}
return { return {
'id': 'mock-cluster-{}'.format(index), "id": "mock-cluster-{}".format(index),
'api_server_url': 'https://kube-{}.example.org'.format(index), "api_server_url": "https://kube-{}.example.org".format(index),
'nodes': nodes, "nodes": nodes,
'unassigned_pods': unassigned_pods "unassigned_pods": unassigned_pods,
} }

View File

@@ -3,17 +3,17 @@ import os
from flask_dance.consumer import OAuth2ConsumerBlueprint from flask_dance.consumer import OAuth2ConsumerBlueprint
CREDENTIALS_DIR = os.getenv('CREDENTIALS_DIR', '') CREDENTIALS_DIR = os.getenv("CREDENTIALS_DIR", "")
class OAuth2ConsumerBlueprintWithClientRefresh(OAuth2ConsumerBlueprint): class OAuth2ConsumerBlueprintWithClientRefresh(OAuth2ConsumerBlueprint):
'''Same as flask_dance.consumer.OAuth2ConsumerBlueprint, but loads client credentials from file''' """Same as flask_dance.consumer.OAuth2ConsumerBlueprint, but loads client credentials from file"""
def refresh_credentials(self): def refresh_credentials(self):
with open(os.path.join(CREDENTIALS_DIR, 'authcode-client-id')) as fd: with open(os.path.join(CREDENTIALS_DIR, "authcode-client-id")) as fd:
# note that we need to set two attributes because of how OAuth2ConsumerBlueprint works :-/ # note that we need to set two attributes because of how OAuth2ConsumerBlueprint works :-/
self._client_id = self.client_id = fd.read().strip() self._client_id = self.client_id = fd.read().strip()
with open(os.path.join(CREDENTIALS_DIR, 'authcode-client-secret')) as fd: with open(os.path.join(CREDENTIALS_DIR, "authcode-client-secret")) as fd:
self.client_secret = fd.read().strip() self.client_secret = fd.read().strip()
def login(self): def login(self):

View File

@@ -14,52 +14,55 @@ ONE_YEAR = 3600 * 24 * 365
def generate_token(n: int): def generate_token(n: int):
'''Generate a random ASCII token of length n''' """Generate a random ASCII token of length n"""
# uses os.urandom() # uses os.urandom()
rng = random.SystemRandom() rng = random.SystemRandom()
return ''.join([rng.choice(string.ascii_letters + string.digits) for i in range(n)]) return "".join([rng.choice(string.ascii_letters + string.digits) for i in range(n)])
def generate_token_data(): def generate_token_data():
'''Generate screen token data for storing''' """Generate screen token data for storing"""
token = generate_token(10) token = generate_token(10)
now = time.time() now = time.time()
return {'token': token, 'created': now, 'expires': now + ONE_YEAR} return {"token": token, "created": now, "expires": now + ONE_YEAR}
def check_token(token: str, remote_addr: str, data: dict): def check_token(token: str, remote_addr: str, data: dict):
'''Check whether the given screen token is valid, raises exception if not''' """Check whether the given screen token is valid, raises exception if not"""
now = time.time() now = time.time()
if data and now < data['expires'] and data.get('remote_addr', remote_addr) == remote_addr: if (
data['remote_addr'] = remote_addr data
and now < data["expires"]
and data.get("remote_addr", remote_addr) == remote_addr
):
data["remote_addr"] = remote_addr
return data return data
else: else:
raise ValueError('Invalid token') raise ValueError("Invalid token")
class AbstractStore: class AbstractStore:
def get_cluster_ids(self): def get_cluster_ids(self):
return self.get('cluster-ids') or [] return self.get("cluster-ids") or []
def set_cluster_ids(self, cluster_ids: set): def set_cluster_ids(self, cluster_ids: set):
self.set('cluster-ids', list(sorted(cluster_ids))) self.set("cluster-ids", list(sorted(cluster_ids)))
def get_cluster_status(self, cluster_id: str) -> dict: def get_cluster_status(self, cluster_id: str) -> dict:
return self.get('clusters:{}:status'.format(cluster_id)) or {} return self.get("clusters:{}:status".format(cluster_id)) or {}
def set_cluster_status(self, cluster_id: str, status: dict): def set_cluster_status(self, cluster_id: str, status: dict):
self.set('clusters:{}:status'.format(cluster_id), status) self.set("clusters:{}:status".format(cluster_id), status)
def get_cluster_data(self, cluster_id: str) -> dict: def get_cluster_data(self, cluster_id: str) -> dict:
return self.get('clusters:{}:data'.format(cluster_id)) or {} return self.get("clusters:{}:data".format(cluster_id)) or {}
def set_cluster_data(self, cluster_id: str, data: dict): def set_cluster_data(self, cluster_id: str, data: dict):
self.set('clusters:{}:data'.format(cluster_id), data) self.set("clusters:{}:data".format(cluster_id), data)
class MemoryStore(AbstractStore): class MemoryStore(AbstractStore):
'''Memory-only backend, mostly useful for local debugging''' """Memory-only backend, mostly useful for local debugging"""
def __init__(self): def __init__(self):
self._data = {} self._data = {}
@@ -74,7 +77,7 @@ class MemoryStore(AbstractStore):
def acquire_lock(self): def acquire_lock(self):
# no-op for memory store # no-op for memory store
return 'fake-lock' return "fake-lock"
def release_lock(self, lock): def release_lock(self, lock):
# no op for memory store # no op for memory store
@@ -96,7 +99,7 @@ class MemoryStore(AbstractStore):
def create_screen_token(self): def create_screen_token(self):
data = generate_token_data() data = generate_token_data()
token = data['token'] token = data["token"]
self._screen_tokens[token] = data self._screen_tokens[token] = data
return token return token
@@ -107,51 +110,54 @@ class MemoryStore(AbstractStore):
class RedisStore(AbstractStore): class RedisStore(AbstractStore):
'''Redis-based backend for deployments with replicas > 1''' """Redis-based backend for deployments with replicas > 1"""
def __init__(self, url: str): def __init__(self, url: str):
logger.info('Connecting to Redis on {}..'.format(url)) logger.info("Connecting to Redis on {}..".format(url))
self._redis = redis.StrictRedis.from_url(url) self._redis = redis.StrictRedis.from_url(url)
self._redlock = Redlock([url]) self._redlock = Redlock([url])
def set(self, key, value): def set(self, key, value):
self._redis.set(key, json.dumps(value, separators=(',', ':'))) self._redis.set(key, json.dumps(value, separators=(",", ":")))
def get(self, key): def get(self, key):
value = self._redis.get(key) value = self._redis.get(key)
if value: if value:
return json.loads(value.decode('utf-8')) return json.loads(value.decode("utf-8"))
def acquire_lock(self): def acquire_lock(self):
return self._redlock.lock('update', 10000) return self._redlock.lock("update", 10000)
def release_lock(self, lock): def release_lock(self, lock):
self._redlock.unlock(lock) self._redlock.unlock(lock)
def publish(self, event_type, event_data): def publish(self, event_type, event_data):
self._redis.publish('default', '{}:{}'.format(event_type, json.dumps(event_data, separators=(',', ':')))) self._redis.publish(
"default",
"{}:{}".format(event_type, json.dumps(event_data, separators=(",", ":"))),
)
def listen(self): def listen(self):
p = self._redis.pubsub() p = self._redis.pubsub()
p.subscribe('default') p.subscribe("default")
for message in p.listen(): for message in p.listen():
if message['type'] == 'message': if message["type"] == "message":
event_type, data = message['data'].decode('utf-8').split(':', 1) event_type, data = message["data"].decode("utf-8").split(":", 1)
yield (event_type, json.loads(data)) yield (event_type, json.loads(data))
def create_screen_token(self): def create_screen_token(self):
'''Generate a new screen token and store it in Redis''' """Generate a new screen token and store it in Redis"""
data = generate_token_data() data = generate_token_data()
token = data['token'] token = data["token"]
self._redis.set('screen-tokens:{}'.format(token), json.dumps(data)) self._redis.set("screen-tokens:{}".format(token), json.dumps(data))
return token return token
def redeem_screen_token(self, token: str, remote_addr: str): def redeem_screen_token(self, token: str, remote_addr: str):
'''Validate the given token and bind it to the IP''' """Validate the given token and bind it to the IP"""
redis_key = 'screen-tokens:{}'.format(token) redis_key = "screen-tokens:{}".format(token)
data = self._redis.get(redis_key) data = self._redis.get(redis_key)
if not data: if not data:
raise ValueError('Invalid token') raise ValueError("Invalid token")
data = json.loads(data.decode('utf-8')) data = json.loads(data.decode("utf-8"))
data = check_token(token, remote_addr, data) data = check_token(token, remote_addr, data)
self._redis.set(redis_key, json.dumps(data)) self._redis.set(redis_key, json.dumps(data))

View File

@@ -18,21 +18,30 @@ def calculate_backoff(tries: int):
def handle_query_failure(e: Exception, cluster, backoff: dict): def handle_query_failure(e: Exception, cluster, backoff: dict):
if not backoff: if not backoff:
backoff = {} backoff = {}
tries = backoff.get('tries', 0) + 1 tries = backoff.get("tries", 0) + 1
backoff['tries'] = tries backoff["tries"] = tries
wait_seconds = calculate_backoff(tries) wait_seconds = calculate_backoff(tries)
backoff['next_try'] = time.time() + wait_seconds backoff["next_try"] = time.time() + wait_seconds
message = get_short_error_message(e) message = get_short_error_message(e)
if isinstance(e, requests.exceptions.RequestException): if isinstance(e, requests.exceptions.RequestException):
log = logger.error log = logger.error
else: else:
log = logger.exception log = logger.exception
log('Failed to query cluster {} ({}): {} (try {}, wait {} seconds)'.format( log(
cluster.id, cluster.api_server_url, message, tries, round(wait_seconds))) "Failed to query cluster {} ({}): {} (try {}, wait {} seconds)".format(
cluster.id, cluster.api_server_url, message, tries, round(wait_seconds)
)
)
return backoff return backoff
def update_clusters(cluster_discoverer, query_cluster: callable, store, query_interval: float=5, debug: bool=False): def update_clusters(
cluster_discoverer,
query_cluster: callable,
store,
query_interval: float = 5,
debug: bool = False,
):
while True: while True:
lock = store.acquire_lock() lock = store.acquire_lock()
if lock: if lock:
@@ -43,42 +52,65 @@ def update_clusters(cluster_discoverer, query_cluster: callable, store, query_in
cluster_ids.add(cluster.id) cluster_ids.add(cluster.id)
status = store.get_cluster_status(cluster.id) status = store.get_cluster_status(cluster.id)
now = time.time() now = time.time()
if now < status.get('last_query_time', 0) + query_interval: if now < status.get("last_query_time", 0) + query_interval:
continue continue
backoff = status.get('backoff') backoff = status.get("backoff")
if backoff and now < backoff['next_try']: if backoff and now < backoff["next_try"]:
# cluster is still in backoff, skip # cluster is still in backoff, skip
continue continue
try: try:
logger.debug('Querying cluster {} ({})..'.format(cluster.id, cluster.api_server_url)) logger.debug(
"Querying cluster {} ({})..".format(
cluster.id, cluster.api_server_url
)
)
data = query_cluster(cluster) data = query_cluster(cluster)
except Exception as e: except Exception as e:
backoff = handle_query_failure(e, cluster, backoff) backoff = handle_query_failure(e, cluster, backoff)
status['backoff'] = backoff status["backoff"] = backoff
store.publish('clusterstatus', {'cluster_id': cluster.id, 'status': status}) store.publish(
"clusterstatus",
{"cluster_id": cluster.id, "status": status},
)
else: else:
status['last_query_time'] = now status["last_query_time"] = now
if backoff: if backoff:
logger.info('Cluster {} ({}) recovered after {} tries.'.format(cluster.id, cluster.api_server_url, backoff['tries'])) logger.info(
del status['backoff'] "Cluster {} ({}) recovered after {} tries.".format(
old_data = store.get_cluster_data(data['id']) cluster.id, cluster.api_server_url, backoff["tries"]
)
)
del status["backoff"]
old_data = store.get_cluster_data(data["id"])
if old_data: if old_data:
# https://pikacode.com/phijaro/json_delta/ticket/11/ # https://pikacode.com/phijaro/json_delta/ticket/11/
# diff is extremely slow without array_align=False # diff is extremely slow without array_align=False
delta = json_delta.diff(old_data, data, verbose=debug, array_align=False) delta = json_delta.diff(
store.publish('clusterdelta', {'cluster_id': cluster.id, 'delta': delta}) old_data, data, verbose=debug, array_align=False
)
store.publish(
"clusterdelta",
{"cluster_id": cluster.id, "delta": delta},
)
if delta: if delta:
store.set_cluster_data(cluster.id, data) store.set_cluster_data(cluster.id, data)
else: else:
logger.info('Discovered new cluster {} ({}).'.format(cluster.id, cluster.api_server_url)) logger.info(
"Discovered new cluster {} ({}).".format(
cluster.id, cluster.api_server_url
)
)
# first send status with last_query_time! # first send status with last_query_time!
store.publish('clusterstatus', {'cluster_id': cluster.id, 'status': status}) store.publish(
store.publish('clusterupdate', data) "clusterstatus",
{"cluster_id": cluster.id, "status": status},
)
store.publish("clusterupdate", data)
store.set_cluster_data(cluster.id, data) store.set_cluster_data(cluster.id, data)
store.set_cluster_status(cluster.id, status) store.set_cluster_status(cluster.id, status)
store.set_cluster_ids(cluster_ids) store.set_cluster_ids(cluster_ids)
except: except:
logger.exception('Failed to update') logger.exception("Failed to update")
finally: finally:
store.release_lock(lock) store.release_lock(lock)
# sleep 1-2 seconds # sleep 1-2 seconds

View File

@@ -2,11 +2,11 @@ import requests.exceptions
def get_short_error_message(e: Exception): def get_short_error_message(e: Exception):
'''Generate a reasonable short message why the HTTP request failed''' """Generate a reasonable short message why the HTTP request failed"""
if isinstance(e, requests.exceptions.RequestException) and e.response is not None: if isinstance(e, requests.exceptions.RequestException) and e.response is not None:
# e.g. "401 Unauthorized" # e.g. "401 Unauthorized"
return '{} {}'.format(e.response.status_code, e.response.reason) return "{} {}".format(e.response.status_code, e.response.reason)
elif isinstance(e, requests.exceptions.ConnectionError): elif isinstance(e, requests.exceptions.ConnectionError):
# e.g. "ConnectionError" or "ConnectTimeout" # e.g. "ConnectionError" or "ConnectTimeout"
return e.__class__.__name__ return e.__class__.__name__

View File

@@ -1,15 +0,0 @@
#!/usr/bin/env python3
"""
Helper script for Docker build to install packages from Pipfile.lock without installing Pipenv
"""
import json
import subprocess
with open("Pipfile.lock") as fd:
data = json.load(fd)
packages = []
for k, v in data["default"].items():
packages.append(k + v["version"])
subprocess.run(["pip3", "install"] + packages, check=True)

1056
poetry.lock generated Normal file

File diff suppressed because it is too large Load Diff

26
pyproject.toml Normal file
View File

@@ -0,0 +1,26 @@
[tool]
[tool.poetry]
name = "kube-ops-view"
version = "2020.0.dev1"
description = "Kubernetes Operational View - read-only system dashboard for multiple K8s clusters"
authors = ["Henning Jacobs <henning@jacobs1.de>"]
[tool.poetry.dependencies]
python = ">=3.7"
click = "*"
flask = "*"
flask-dance = "*"
gevent = "*"
json-delta = ">=2.0"
pykube-ng = "*"
redlock-py = "*"
requests = "*"
stups-tokens = ">=1.1.19"
[tool.poetry.dev-dependencies]
coveralls = "*"
flake8 = "*"
pytest = "*"
pytest-cov = "*"
black = "^19.10b0"
mypy = "^0.761"

View File

@@ -1,78 +0,0 @@
import sys
from setuptools import find_packages, setup
from setuptools.command.test import test as TestCommand
from pathlib import Path
def read_version(package):
with (Path(package) / '__init__.py').open() as fd:
for line in fd:
if line.startswith('__version__ = '):
return line.split()[-1].strip().strip("'")
version = read_version('kube_ops_view')
class PyTest(TestCommand):
user_options = [('cov-html=', None, 'Generate junit html report')]
def initialize_options(self):
TestCommand.initialize_options(self)
self.cov = None
self.pytest_args = ['--cov', 'kube_ops_view', '--cov-report', 'term-missing', '-v']
self.cov_html = False
def finalize_options(self):
TestCommand.finalize_options(self)
if self.cov_html:
self.pytest_args.extend(['--cov-report', 'html'])
self.pytest_args.extend(['tests'])
def run_tests(self):
import pytest
errno = pytest.main(self.pytest_args)
sys.exit(errno)
def readme():
return open('README.rst', encoding='utf-8').read()
tests_require = [
'pytest',
'pytest-cov'
]
setup(
name='kube-ops-view',
packages=find_packages(),
version=version,
description='Kubernetes Operational View - read-only system dashboard for multiple K8s clusters',
long_description=readme(),
author='Henning Jacobs',
url='https://github.com/hjacobs/kube-ops-view',
keywords='kubernetes operations dashboard view k8s',
license='GNU General Public License v3 (GPLv3)',
tests_require=tests_require,
extras_require={'tests': tests_require},
cmdclass={'test': PyTest},
test_suite='tests',
classifiers=[
'Development Status :: 3 - Alpha',
'Intended Audience :: Developers',
'Intended Audience :: System Administrators',
'License :: OSI Approved :: GNU General Public License v3 (GPLv3)',
'Operating System :: OS Independent',
'Programming Language :: Python',
'Programming Language :: Python :: 3.5',
'Topic :: System :: Clustering',
'Topic :: System :: Monitoring',
],
include_package_data=True, # needed to include JavaScript (see MANIFEST.in)
entry_points={'console_scripts': ['kube-ops-view = kube_ops_view.main:main']}
)

26
tox.ini
View File

@@ -1,26 +0,0 @@
[tox]
envlist=py36,flake8,eslint
[tox:travis]
3.6=py36,flake8,eslint
[testenv]
deps=pipenv
commands=
pipenv install --dev
pipenv run python setup.py test
[testenv:flake8]
deps=pipenv
commands=
pipenv install --dev
pipenv run flake8
[testenv:eslint]
whitelist_externals=eslint
changedir=app
commands=eslint src
[flake8]
max-line-length=160
ignore=E402,E722,E252